From 9345dacfbf8279d99eea93dc78bb3f5f5ea2b0d3 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 22:35:15 +0200 Subject: [PATCH] feat(absences): worker detail tab + dashboard alert + CLAUDE.md (Round D) #5 from checkpoint feedback: /workers// now has an Absences tab showing YTD totals (chip row) + 50 most-recent absences (table). Admin dashboard adds a conditional 'X absent in last 7 days' alert card (only renders when count > 0; links to filtered /absences/). CLAUDE.md gets a new Absence model entry + URL routes + dedicated 'Absence-to-PayrollAdjustment cascade' section. Reason-badge CSS moved to static/css/custom.css as single source of truth. 4 new tests. --- CLAUDE.md | 39 +++++++++++++ core/templates/core/absences/list.html | 23 ++------ core/templates/core/index.html | 22 +++++++ core/templates/core/workers/detail.html | 75 ++++++++++++++++++++++++ core/tests.py | 76 +++++++++++++++++++++++++ core/views.py | 42 ++++++++++++++ static/css/custom.css | 15 +++++ 7 files changed, 273 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01e173d..f9a38ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ staticfiles/ — Collected static assets (Bootstrap, admin) — NOT in git ( - **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties. - **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date. - **SiteReport** — optional 1:1 with `WorkLog`, captures what was DONE on site that day: weather, temperature_min/max (°C, IntegerField), free-form `notes`, and a flexible `metrics` JSONField with shape `{'counts': {key: int}, 'checks': {key: bool}}`. The metric KEYS live in `core/site_report_schema.py` (NOT in the model) — see the "SiteReport metric schema" section below for the rationale + how to add a new metric without a migration. Reverse accessor: `work_log.site_report` (1:1, raises DoesNotExist when absent — wrap with try/except or use `WorkLog.objects.filter(site_report__isnull=False)`). +- **Absence** — per-worker dated record of a day not worked. 8 reason choices (Sick, Family Responsibility, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Absconded, Other). Optional `project` FK (SET_NULL). `is_paid` boolean (default False) — when ticked, the save flow auto-creates a Bonus PayrollAdjustment via `_sync_absence_payroll_adjustment(absence)` helper, inheriting the absence's project for cost-attribution. Linked via OneToOneField (`payroll_adjustment`). Unique per (worker, date) at DB layer. Permission scoping: admin (all) or supervisor (workers in their teams). ### Schema name-drifts to remember Fields / accessors that differ from what you'd guess. Each has bitten multiple @@ -176,6 +177,38 @@ log. The form has a "Skip" link to home — site reports are entirely optional. WorkLogs without a SiteReport are completely valid historic rows; they just don't show progress data on `/history/`. +## Absence-to-PayrollAdjustment cascade (May 2026) + +`Absence.is_paid=True` auto-creates a Bonus PayrollAdjustment at +`worker.daily_rate`, inheriting `absence.project` for cost attribution. +Linked via `Absence.payroll_adjustment` OneToOneField. Logic lives in +`_sync_absence_payroll_adjustment(absence)` in `core/views.py` — +called from `absence_log`, `absence_log_confirm`, `absence_edit`, and +any future quick-action save path. Wrapped in `transaction.atomic()` +to prevent orphaned adjustments on partial failure. + +Edit / delete cascades: +- Toggle `is_paid` True → False → adjustment is deleted; refuses + (raises ValueError) if adjustment is already paid (`payroll_record` + is set). Caller surfaces this as a messages.error to admin. +- Toggle `is_paid` False → True → fresh Bonus adjustment created. +- Toggle `is_paid` True → True (re-save while paid) → adjustment is + LEFT ALONE (admin may have manually edited the amount; we don't + second-guess). See `test_paid_with_existing_adj_is_idempotent`. +- Delete of Absence cascades to delete the unpaid linked adjustment. + If the adjustment is already paid, the delete is refused with a + messages.error. + +The "Submit + Log Absences" button on `/attendance/log/` lets admins +jump from logging attendance straight to `/absences/log/` pre-filled +with the same date, team, and project. Uses `next_action=log_absences` +POST param; default Submit keeps the existing SiteReport redirect. + +Permission scoping helper: `_absence_user_queryset(user)` in `core/views.py` +is the single authority for "which absences can this user see/touch". Admin +sees all; supervisor sees absences for workers in any team they supervise +(`worker__teams__supervisor=user`). + ## Payroll Constants Defined at top of views.py — used in dashboard calculations and payment processing: - **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay @@ -312,6 +345,12 @@ numbers on hot pages. | `/history/export/` | `export_work_log_csv` | Download filtered logs as CSV | | `/site-report//edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) | | `/site-report//` | `site_report_detail` | Read-only view of the SiteReport (404 if none — use the edit URL to create) | +| `/absences/log/` | `absence_log` | Admin/supervisor: log absences (date range, multi-worker). Reads `date`, `team`, `project` GET params for prefill from /attendance/log/'s "Submit + Log Absences" shortcut. | +| `/absences/log/confirm/` | `absence_log_confirm` | Yellow conflict-warning page; per-row Remove-from-WorkLog checkboxes; reads pending data from session. | +| `/absences/` | `absence_list` | Filtered list with pagination. Multi-select reason filter (`?reason=sick&reason=iod` etc.). Direct `project_id` filter. | +| `/absences//edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment on is_paid toggle. | +| `/absences//delete/` | `absence_delete` | POST-only; cascades unpaid adjustment; refuses if paid. | +| `/absences/export/` | `absence_export_csv` | Admin-only CSV; honors all list filters. | | `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV | | `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter | | `/workers/new/` | `worker_edit` | Admin: blank worker-create form | diff --git a/core/templates/core/absences/list.html b/core/templates/core/absences/list.html index fe51a76..7530b69 100644 --- a/core/templates/core/absences/list.html +++ b/core/templates/core/absences/list.html @@ -139,7 +139,7 @@ has an inline delete form. CSV export button only shows for admin. {{ a.get_reason_display }} {% if a.is_paid %} - + {% if a.payroll_adjustment %}({{ a.payroll_adjustment.amount|money }}){% endif %} {% else %} @@ -184,22 +184,7 @@ has an inline delete form. CSV export button only shows for admin. {% endif %} -{% comment %} -=== Reason badge colours === -Reuses the existing semantic badge palette from custom.css so dark/ -light theme switching works out of the box. Green-ish for "valid" -leave (sick/family/annual), neutral for unpaid/other, amber for IOD, -purple-ish (deduction) for the disciplinary reasons (suspension, -absconded). -{% endcomment %} - +{# Reason badge colours live in static/css/custom.css under #} +{# "/* === Absence reason badges === */" — single source of truth shared #} +{# between this list and the Absences tab on /workers//. #} {% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 50c0beb..dab0955 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -96,6 +96,28 @@ + + {% if absences_recent_count %} + + {% endif %} + {% if certs_alert_total %} diff --git a/core/templates/core/workers/detail.html b/core/templates/core/workers/detail.html index ecabb61..f48b528 100644 --- a/core/templates/core/workers/detail.html +++ b/core/templates/core/workers/detail.html @@ -48,6 +48,7 @@ + @@ -236,6 +237,80 @@ + + {# Shows year-to-date totals as colored chips at the top, then a table #} + {# of the 50 most-recent absences. "Log Absence" button pre-fills today.#} +
+
+
+
+
Year-to-date
+ + Log Absence + +
+ + {# YTD chip row — one badge per reason that has at least one #} + {# absence this calendar year. Hidden entirely if zero. #} +
+ {% for key, label in absence_reason_choices %} + {% if absence_ytd_totals|dictlookup:key %} + + {{ label }}: {{ absence_ytd_totals|dictlookup:key }} + + {% endif %} + {% endfor %} + {% if not absence_ytd_totals %} + No absences this year. + {% endif %} +
+ + {% if worker_absences %} +
+ + + + + + + + + + + + + {% for a in worker_absences %} + + + + + + + + + {% endfor %} + +
DateReasonProjectPaid?Notes
{{ a.date|date:"d M Y" }}{{ a.get_reason_display }}{{ a.project.name|default:"—" }} + {% if a.is_paid %} + + {% else %} + + {% endif %} + {{ a.notes|truncatechars:50 }} + + + +
+
+ {% else %} +

+ No absences recorded for this worker yet. +

+ {% endif %} +
+
+
+
diff --git a/core/tests.py b/core/tests.py index ddfd5ad..20c1267 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2710,3 +2710,79 @@ class AbsenceAttendanceShortcutTests(TestCase): self.assertEqual(resp2.status_code, 302) self.assertIn('/absences/log/', resp2.url) self.assertIn('date=2026-05-14', resp2.url) + + +# === ROUND D — Absences tab on /workers// + dashboard alert card === +# These tests cover the worker-detail Absences tab (YTD chips + recent-50 +# table) and the conditional "X absent in last 7 days" stat card on the +# admin dashboard. Both pull from the same Absence queryset; the dashboard +# card only renders when count > 0. + +class AbsenceWorkerDetailTests(TestCase): + """Round D — Absences tab on /workers// shows YTD totals + table.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + cls.project = Project.objects.create(name='P') + from django.utils import timezone as _tz + current_year = _tz.now().year + cls.current_year = current_year + Absence.objects.create(worker=cls.worker, date=_date(current_year, 1, 5), reason='sick') + Absence.objects.create(worker=cls.worker, date=_date(current_year, 2, 10), reason='sick') + Absence.objects.create(worker=cls.worker, date=_date(current_year, 3, 1), reason='annual') + # Last year — should NOT count toward YTD + Absence.objects.create(worker=cls.worker, date=_date(current_year - 1, 5, 1), reason='sick') + + def test_worker_detail_includes_ytd_totals(self): + self.client.force_login(self.admin) + resp = self.client.get(f'/workers/{self.worker.id}/') + self.assertEqual(resp.status_code, 200) + ytd = resp.context['absence_ytd_totals'] + # Two sick + one annual this year; last year's sick is excluded. + self.assertEqual(ytd.get('sick'), 2) + self.assertEqual(ytd.get('annual'), 1) + # Last year's sick absence is in worker_absences (not filtered) but + # NOT in YTD totals. All 4 absences appear in the recent-50 table. + worker_absences = resp.context['worker_absences'] + all_dates = [a.date for a in worker_absences] + self.assertEqual(len(all_dates), 4) + + def test_worker_detail_renders_tab_and_chips(self): + self.client.force_login(self.admin) + resp = self.client.get(f'/workers/{self.worker.id}/') + self.assertContains(resp, 'Absences') # tab label + self.assertContains(resp, 'Sick: 2') # YTD chip + self.assertContains(resp, 'Annual Leave: 1') + + +class AbsenceDashboardCardTests(TestCase): + """Round D — Admin dashboard shows 'X absent in last 7 days' alert card + when count > 0; renders nothing when count is 0.""" + + @classmethod + def setUpTestData(cls): + cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True) + cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000')) + + def setUp(self): + self.client.force_login(self.admin) + + def test_card_shows_when_recent_absences_exist(self): + from datetime import timedelta as _td + from django.utils import timezone as _tz + today = _tz.now().date() + Absence.objects.create(worker=self.worker, date=today, reason='sick') + Absence.objects.create(worker=self.worker, date=today - _td(days=3), reason='annual') + # Outside the 7-day window — should NOT count + Absence.objects.create(worker=self.worker, date=today - _td(days=10), reason='other') + resp = self.client.get('/') + self.assertEqual(resp.context['absences_recent_count'], 2) + self.assertContains(resp, 'absent in last 7 days') + + def test_card_hidden_when_zero(self): + Absence.objects.all().delete() + resp = self.client.get('/') + self.assertEqual(resp.context['absences_recent_count'], 0) + self.assertNotContains(resp, 'absent in last 7 days') diff --git a/core/views.py b/core/views.py index ae9538a..0e4df5c 100644 --- a/core/views.py +++ b/core/views.py @@ -418,6 +418,15 @@ def index(request): ).count() certs_alert_total = certs_expired_count + certs_expiring_count + # === ABSENCES IN LAST 7 DAYS (Round D dashboard alert) === + # Conditional stat card — only renders when count > 0. Same look as + # the existing cert-expiry alert card. Admin-only (this branch only + # runs for is_admin()). + seven_days_ago = today - datetime.timedelta(days=7) + absences_recent_count = Absence.objects.filter( + date__gte=seven_days_ago, + ).count() + context = { 'is_admin': True, 'outstanding_payments': outstanding_payments, @@ -437,6 +446,9 @@ def index(request): 'certs_expired_count': certs_expired_count, 'certs_expiring_count': certs_expiring_count, 'certs_alert_total': certs_alert_total, + # Absences-in-last-7-days alert card (rendered only when > 0) + 'absences_recent_count': absences_recent_count, + 'seven_days_ago': seven_days_ago, # Empty on the home dashboard — modal opens clean (no pre-selected filters) 'selected_project_ids': [], 'selected_team_ids': [], @@ -1533,6 +1545,30 @@ def worker_detail(request, worker_id): # --- Active loans / advances --- active_loans = worker.loans.filter(active=True).order_by('-date') + # === ABSENCES TAB (Round D) === + # Year-to-date count of absences per reason, for the small chip-row + # panel at the top of the Absences tab on the worker detail page. + current_year = timezone.now().year + ytd_rows = ( + worker.absences + .filter(date__year=current_year) + .values('reason') + .annotate(total=Count('id')) + ) + absence_ytd_totals = {row['reason']: row['total'] for row in ytd_rows} + + # Recent absences for the table below the chip row. select_related is + # important so the template doesn't trigger N+1 on project / logged_by. + worker_absences = ( + worker.absences + .select_related('project', 'logged_by', 'payroll_adjustment') + .all()[:50] + ) + # Total absence count for the tab badge — worker_absences is sliced for + # display so |length would cap at 50. Match peer tabs (certs/warnings) + # that use a full .count() for their badges. + absence_total_count = worker.absences.count() + context = { 'worker': worker, 'projects_worked': projects_worked, @@ -1544,6 +1580,12 @@ def worker_detail(request, worker_id): 'certs': certs, 'warnings': warnings, 'active_loans': active_loans, + # Absences tab + 'absence_ytd_totals': absence_ytd_totals, + 'worker_absences': worker_absences, + 'absence_total_count': absence_total_count, + 'absence_reason_choices': Absence.REASON_CHOICES, + 'today': timezone.now().date(), } return render(request, 'core/workers/detail.html', context) diff --git a/static/css/custom.css b/static/css/custom.css index 269b5c9..a3f2873 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -2185,3 +2185,18 @@ th.sortable.sorted .sort-arrow { opacity: 1; } opacity: 0.75; font-weight: 600; } + +/* === Absence reason badges — shared between /absences/ list and worker detail tab === */ +/* Single source of truth — previously duplicated in absences/list.html. */ +/* Reuses the existing semantic badge palette so dark/light theme switching */ +/* works out of the box. Green-ish for "valid" leave (sick/family/annual), */ +/* neutral for unpaid/other, amber for IOD, purple-ish for the disciplinary */ +/* reasons (suspension, absconded). */ +.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } +.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } +.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } +.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; } +.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: var(--badge-overtime-fg, #000); } +.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); } +.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); } +.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }