feat(absences): worker detail tab + dashboard alert + CLAUDE.md (Round D)

#5 from checkpoint feedback: /workers/<id>/ 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.
This commit is contained in:
Konrad du Plessis 2026-05-14 22:35:15 +02:00
parent 8c749f3f52
commit 9345dacfbf
7 changed files with 273 additions and 19 deletions

View File

@ -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/<work_log_id>/edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) |
| `/site-report/<work_log_id>/` | `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/<id>/edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment on is_paid toggle. |
| `/absences/<id>/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 |

View File

@ -139,7 +139,7 @@ has an inline delete form. CSV export button only shows for admin.
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td>
{% if a.is_paid %}
<i class="fas fa-check-circle" style="color: var(--badge-bonus-bg);"></i>
<i class="fas fa-check-circle text-success"></i>
{% if a.payroll_adjustment %}<small class="text-muted">({{ a.payroll_adjustment.amount|money }})</small>{% endif %}
{% else %}
<i class="far fa-circle text-muted"></i>
@ -184,22 +184,7 @@ has an inline delete form. CSV export button only shows for admin.
{% endif %}
</div>
{% 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 %}
<style>
.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; }
</style>
{# 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/<id>/. #}
{% endblock %}

View File

@ -96,6 +96,28 @@
</div>
</div>
<!-- Absences in last 7 days — shown ONLY when count > 0.
Same conditional pattern as the cert-expiry card below. Clicks through
to the Absences list filtered to the last 7 days. -->
{% if absences_recent_count %}
<div class="col-xl-3 col-md-6">
<a href="{% url 'absence_list' %}?date_from={{ seven_days_ago|date:'Y-m-d' }}" class="stat-card stat-card--danger h-100 p-3 d-block" style="text-decoration: none; color: inherit;">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Absences (last 7 days)</div>
<div class="stat-value" style="font-size: 1.5rem;">{{ absences_recent_count }}</div>
<div style="font-size: 0.75rem; margin-top: 0.35rem; color: var(--text-secondary);">
absent in last 7 days
</div>
</div>
<div class="stat-icon stat-icon--danger">
<i class="fas fa-user-clock"></i>
</div>
</div>
</a>
</div>
{% endif %}
<!-- Certifications Expiring — shown ONLY when count > 0
Clicking it goes to the Worker Batch Report which shows per-worker cert columns. -->
{% if certs_alert_total %}

View File

@ -48,6 +48,7 @@
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#certs" type="button">Certifications <span class="badge bg-secondary ms-1">{{ certs.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#warnings" type="button">Warnings <span class="badge bg-secondary ms-1">{{ warnings.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#absences" type="button"><i class="fas fa-user-clock me-1"></i>Absences <span class="badge bg-secondary ms-1">{{ absence_total_count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
</ul>
@ -236,6 +237,80 @@
</div>
</div>
<!-- === ABSENCES TAB === -->
{# 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.#}
<div class="tab-pane fade" id="absences">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-uppercase mb-0" style="font-size: 0.75rem; color: var(--text-secondary);">Year-to-date</h6>
<a href="{% url 'absence_log' %}?date={{ today|date:'Y-m-d' }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-plus me-1"></i>Log Absence
</a>
</div>
{# YTD chip row — one badge per reason that has at least one #}
{# absence this calendar year. Hidden entirely if zero. #}
<div class="d-flex flex-wrap gap-2 mb-3">
{% for key, label in absence_reason_choices %}
{% if absence_ytd_totals|dictlookup:key %}
<span class="badge badge-absence-{{ key }}">
{{ label }}: {{ absence_ytd_totals|dictlookup:key }}
</span>
{% endif %}
{% endfor %}
{% if not absence_ytd_totals %}
<small class="text-muted">No absences this year.</small>
{% endif %}
</div>
{% if worker_absences %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Date</th>
<th>Reason</th>
<th>Project</th>
<th>Paid?</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in worker_absences %}
<tr>
<td>{{ a.date|date:"d M Y" }}</td>
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
<td>{{ a.project.name|default:"—" }}</td>
<td>
{% if a.is_paid %}
<i class="fas fa-check-circle text-success"></i>
{% else %}
<i class="far fa-circle text-muted"></i>
{% endif %}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ a.notes|truncatechars:50 }}</td>
<td>
<a href="{% url 'absence_edit' a.id %}" class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="fas fa-pen"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-4 mb-0" style="color: var(--text-secondary);">
No absences recorded for this worker yet.
</p>
{% endif %}
</div>
</div>
</div>
<!-- === HISTORY TAB === -->
<div class="tab-pane fade" id="history">
<div class="row g-3">

View File

@ -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/<id>/ + 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/<id>/ 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')

View File

@ -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)

View File

@ -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; }