diff --git a/docs/plans/2026-05-14-worker-absences-design.md b/docs/plans/2026-05-14-worker-absences-design.md new file mode 100644 index 0000000..de36f60 --- /dev/null +++ b/docs/plans/2026-05-14-worker-absences-design.md @@ -0,0 +1,374 @@ +# Worker Absence Records — Design + +**Date:** 14 May 2026 +**Status:** Approved by Konrad on 14 May 2026; ready for implementation plan. +**Branch:** `ai-dev` (queued behind Phase A.1 SiteReport which is also on `ai-dev` but not yet deployed to production). + +## Goal (one sentence) + +Add a per-worker absence-tracking system so FoxFitt can record who was off +work, why, whether the day was paid, and surface year-to-date totals on +the worker profile — with a fast logging form for date ranges and a +quick-action button on the existing attendance form for "today only" +cases. + +## Why now + +Currently every absence is implicit — a worker not in any WorkLog for a +given day is just absent, with no record of why. This creates three +problems: + +1. **No leave audit trail.** When a worker disputes an Absconded + classification later, there's no dated record of when they stopped + showing up. +2. **No way to pay statutory leave.** SA BCEA mandates paid sick / family + responsibility / annual leave but the existing payroll only pays for + logged WorkLogs. To pay sick leave today, Konrad would manually add a + Bonus PayrollAdjustment and remember why — fragile. +3. **No leave overview per worker.** "How many sick days has Joe taken + this year?" requires going through every WorkLog and counting gaps. + +## Decisions locked in (from the brainstorm Q&A) + +| # | Question | Decision | +|---|----------|----------| +| 1 | Absence-reason taxonomy | 8 reasons: Sick, Family Responsibility, Absconded, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Other | +| 2 | Payroll effect | "Paid" checkbox on each absence, **unchecked by default for all reasons**. When ticked → auto-creates a Bonus PayrollAdjustment for that worker on that date, amount = worker.daily_rate. | +| 3 | Form placement | Standalone `/absences/log/` (primary, handles date ranges) **plus** an `✗ Mark absent` quick-action button next to each worker on `/attendance/log/` for the today-only case. | +| 4 | Conflict policy | Warn + allow + per-row "Remove from WorkLog" checkbox. Conflict shown on a separate confirm page, not as a re-render. Unique-per-day enforced at DB layer. | +| 5 | Permissions | Admin (all) + Supervisor (scoped to their team's workers). Mirror of attendance/history. | +| 6 | Aggregation level | Level 1 — YTD totals per reason on `/workers//`. No BCEA accrual logic. | +| 7 | Polish items | Dashboard alert card for last-7-days absences + Absences tab on `/workers//` + CSV export. **Skipped:** doctor's-note FileField upload. | + +## Data model + +One new model in `core/models.py`. Mirrors the shape of `WorkerWarning` +with absence-specific fields. + +```python +class Absence(models.Model): + """One worker × one date × one reason. The supervisor or admin + logged why a worker didn't show up. Optionally creates a paid + Bonus PayrollAdjustment when `is_paid` is True (e.g. SA legal + sick / family responsibility / annual leave at admin discretion). + """ + + REASON_CHOICES = [ + ('sick', 'Sick'), + ('family', 'Family Responsibility'), + ('annual', 'Annual Leave'), + ('unpaid', 'Personal / Unpaid Leave'), + ('iod', 'Injury on Duty'), + ('suspension', 'Suspension'), + ('absconded', 'Absconded'), + ('other', 'Other'), + ] + + worker = models.ForeignKey( + Worker, related_name='absences', on_delete=models.CASCADE, + ) + date = models.DateField(default=timezone.now) + reason = models.CharField(max_length=20, choices=REASON_CHOICES) + notes = models.TextField( + blank=True, + help_text='Free-form: "flu, doctor\'s note attached", "bus broke down", etc.', + ) + + # === PAYROLL LINK === + # When `is_paid` is True, save() creates a Bonus PayrollAdjustment + # for this worker on this date, equal to worker.daily_rate. Stored + # here so editing/deleting the Absence can propagate (mirrors how + # Advance Payment → Loan → Repayment cascade works in payroll today). + is_paid = models.BooleanField( + default=False, + help_text='Tick to pay the worker their daily rate for this day.', + ) + payroll_adjustment = models.OneToOneField( + 'PayrollAdjustment', + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='absence', + help_text='Auto-created when is_paid=True. Cleared if is_paid is unchecked.', + ) + + # === AUDIT === + logged_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='absences_logged', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [('worker', 'date')] + ordering = ['-date', '-created_at'] +``` + +**Migration:** `0014_add_absence.py` — auto-generated, pure CreateModel. + +## Forms (`core/forms.py`) + +### `AbsenceLogForm` + +For `/absences/log/`. Mirrors `AttendanceLogForm` for the date-range + +Sat/Sun toggle pattern. + +```python +class AbsenceLogForm(forms.ModelForm): + # --- date-range extras (verbatim from AttendanceLogForm) --- + end_date = forms.DateField(required=False, ...) + include_saturday = forms.BooleanField(required=False, initial=False) + include_sunday = forms.BooleanField(required=False, initial=False) + + # --- worker picker --- + team = forms.ModelChoiceField( + queryset=Team.objects.filter(active=True), required=False, + help_text='Optional — narrows the worker list to a team.', + ) + workers = forms.ModelMultipleChoiceField( + queryset=Worker.objects.filter(active=True), + widget=forms.CheckboxSelectMultiple, + ) + + class Meta: + model = Absence + fields = ['date', 'reason', 'is_paid', 'notes'] +``` + +- `__init__(self, *, user, **kwargs)` — pre-filters team/workers querysets + by `_absence_user_queryset` scoping. +- `clean()` validates end_date ≥ date; returns expanded `(worker, date)` + tuples; flags unique-key conflicts (hard error) and WorkLog conflicts + (passed back as `conflict_logs`). +- Does NOT save directly. View consumes the cleaned data. + +### `AbsenceQuickForm` + +For the modal on `/attendance/log/`. Minimal: just `reason`, `is_paid`, +`notes`. Worker + date come from the URL/POST. + +### `AbsenceEditForm` + +For `/absences//edit/`. Adds `date` + `worker` to QuickForm (admin +can correct). Triggers `_sync_absence_payroll_adjustment` on save. + +## Views & URLs + +| URL path | View | Method | Permission | +|---|---|---|---| +| `/absences/log/` | `absence_log` | GET, POST | admin OR supervisor | +| `/absences/log/confirm/` | `absence_log_confirm` | GET, POST | admin OR supervisor | +| `/absences/` | `absence_list` | GET | admin (all) / supervisor (scoped) | +| `/absences//edit/` | `absence_edit` | GET, POST | admin OR scope-matching supervisor | +| `/absences//delete/` | `absence_delete` | POST only | admin OR scope-matching supervisor | +| `/absences/export/` | `absence_export_csv` | GET | admin only | +| `/absences/quick/` | `mark_absent_quick` | POST only | admin OR supervisor | + +**Plus tiny tweak to `attendance_log` template**: ✗ button per worker +row + modal include + JS to populate hidden inputs on click. + +### Conflict-warning flow + +1. POST to `/absences/log/`. Form validates. +2. No conflicts → create absences, fire payroll sync, redirect to + `/absences/` with success toast. +3. Conflicts exist → stash form data + conflict list in + `request.session['absence_pending']`. Redirect to + `/absences/log/confirm/`. +4. GET `/absences/log/confirm/`. Reads session. If absent or expired, + redirect back to `/absences/log/`. Renders yellow warning page with + per-row "Remove from WorkLog" checkboxes. +5. POST `/absences/log/confirm/`. In `transaction.atomic()`: + - Remove flagged workers from their conflicting WorkLogs (M2M + remove). + - Create the Absence rows. + - Fire `_sync_absence_payroll_adjustment` per row. + - Clear `request.session['absence_pending']`. +6. Redirect to `/absences/` with success toast. + +### Helper functions (`core/views.py`) + +```python +def _absence_user_queryset(user): + """Absences visible to this user. Admin → all. Supervisor → + absences of workers in their supervised teams / assigned projects.""" + +def _sync_absence_payroll_adjustment(absence): + """Keep absence.payroll_adjustment in sync with absence.is_paid. + + - is_paid True + no adjustment → create Bonus = worker.daily_rate + - is_paid False + adjustment exists → delete the adjustment + - is_paid True + adjustment exists (admin edited amount) → leave it alone + - REFUSES if linked adjustment is already paid (PayrollRecord exists) + """ +``` + +## Templates + +### New (5) + +| Template | Purpose | +|---|---| +| `core/absences/log.html` | Mirror of `attendance_log.html` — date block + reason + paid checkbox + team filter + worker picker + notes | +| `core/absences/log_confirm.html` | Yellow conflict-warning page with per-row Remove-from-WorkLog checkboxes + Confirm/Back buttons | +| `core/absences/list.html` | Mirror of `work_history.html` — sticky filter bar + table with coloured reason badges + pagination | +| `core/absences/edit.html` | Two-column form — read-only worker+date with "change" link, editable reason/paid/notes, Save/Cancel/Delete buttons | +| `core/absences/_quick_modal.html` | Partial — Bootstrap modal with `AbsenceQuickForm` for inline use in attendance_log.html | + +### Modified (3) + +| Template | Change | +|---|---| +| `core/attendance_log.html` | ✗ "Mark absent" button per worker row + include `_quick_modal.html` + ~30 lines JS for populating hidden inputs | +| `core/workers/detail.html` | New "Absences" tab between Warnings and History — YTD totals chip panel + chronological table | +| `core/index.html` | Conditional alert card "X workers absent in last 7 days" (only renders when count > 0) | + +### Reason badge colour mapping + +Reuse existing semantic-badge tokens from `static/css/custom.css`. No new +CSS variables needed. + +| Reason | Badge style | Rationale | +|---|---|---| +| Sick | `--badge-bonus-bg` (green) | Legitimate, often paid | +| Family Responsibility | green family | Legally protected paid leave | +| Annual Leave | green family | Earned paid leave | +| Personal / Unpaid Leave | `--badge-neutral-bg` (grey) | Permitted but unpaid | +| Injury on Duty | `--badge-warning-bg` (amber) | Reportable, safety-relevant | +| Suspension | `--badge-deduction-bg` (rust/red) | Disciplinary | +| Absconded | red family | Disciplinary, unpaid | +| Other | grey | Neutral | + +## Permission model + +Mirrors attendance/history exactly: + +| Action | Admin | Supervisor (their scope) | Outsider | +|---|---|---|---| +| Log absence (any worker) | ✓ | ✗ | ✗ | +| Log absence (worker in own team / assigned project) | ✓ | ✓ | ✗ | +| View list, all rows | ✓ | ✗ | ✗ | +| View list, own scope | ✓ | ✓ | ✗ | +| Edit / delete absence | ✓ | scope-matched only | ✗ | +| Export CSV | ✓ | ✗ (could enable later if needed) | ✗ | + +`_absence_user_queryset(user)` is the single authority for "what +absences can this user see / touch". + +## Year-to-date totals panel (Q6 — Level 1) + +On `/workers//`, the new Absences tab shows a chip row at the top: + +> **Year-to-date (2026):** 4 Sick · 3 Family Resp · 0 Annual · 1 Absconded + +Implemented as a single aggregated query in `worker_detail`: + +```python +ytd_counts = ( + worker.absences + .filter(date__year=timezone.now().year) + .values('reason') + .annotate(total=Count('id')) +) +``` + +Zero-count reasons are skipped (clutters the chip row otherwise). + +## Tests (target: ~25 new tests, 85 → ~110 total) + +| Test class | What it covers | +|---|---| +| `AbsenceModelTests` | Defaults, unique-per-day at DB, ordering, nullable OneToOneField | +| `AbsenceFormTests` | Single date, range expansion, Sat/Sun toggles, end_date/` → Absences tab → YTD totals match rows +9. `/` (admin home) → "X workers absent in last 7 days" alert card visible (if absences exist in the last 7 days) +10. `python manage.py test core.tests -v 2` — expect ~110/110 passing + +## Out of scope for v1 (intentional cuts) + +- **Doctor's-note FileField upload** (Q7 — skipped). Easy 1-line add later. +- **BCEA accrual + "remaining days" logic** (Q6 Level 2). Defer until manual counting becomes pain. +- **Per-reason default for the Paid checkbox** (Konrad explicitly wanted off-by-default for all). +- **Bulk-import of historic absences.** Backfill = one-off `manage.py` command if/when needed. +- **Absences affecting WorkLog billing logic.** Payroll still calculates from WorkLogs only; the Bonus PayrollAdjustment is the "paid absence" mechanism. + +## Open questions resolved during the brainstorm + +None outstanding. All 7 questions answered. + +## Risks / sharp edges to watch during implementation + +1. **Permission queryset for supervisors.** The exact reverse-accessor + path (`team__supervisor=user` vs `team__project_supervisors=user` vs + `assigned_projects__teams__workers=worker`) needs verification + against `core/models.py` during implementation. The brainstorm + sketch is correct in spirit but the field names need checking. +2. **`OneToOneField` `on_delete=SET_NULL` semantics.** If the linked + PayrollAdjustment is deleted directly (via the existing adjustments + tab), the Absence's `payroll_adjustment` field goes NULL but + `is_paid` stays True. This is technically inconsistent. Two options + during implementation: (a) accept the drift and surface it in admin + ("This absence is marked paid but the adjustment was deleted — + re-sync?"), (b) add a signal on PayrollAdjustment.delete that clears + the linked Absence.is_paid. I'd lean (b). To be confirmed in the + plan. +3. **Atomic transaction nesting on confirm POST.** WorkLog M2M removals + + Absence creates + PayrollAdjustment creates all happen in one + atomic block. If any one fails, all roll back. Need to test the + failure path explicitly. + +## Branch & deploy plan + +Lives on `ai-dev` along with the un-deployed SiteReport Phase A.1. +Either deploy them together when ready, OR ship absences first (it's +strictly additive, doesn't depend on SiteReport) and SiteReport second. + +## Next step + +Invoke `superpowers:writing-plans` to produce the task-by-task +implementation plan with TDD steps, file paths, and commit boundaries. +Plan should target ~6-7 focused tasks with one mid-flight checkpoint +after the model + form layer is shippable but before the templates +land.