38686-vm/docs/plans/2026-05-14-worker-absences-design.md
Konrad du Plessis 08f312f6e3 docs(absences): worker-absence-records feature design
Captures the 7-Q brainstorm: 8 reasons (Sick / Family Resp /
Annual / Personal-Unpaid / IOD / Suspension / Absconded / Other),
"Paid" checkbox default-off that auto-creates a Bonus
PayrollAdjustment, standalone /absences/log/ form + ✗ quick-action
on the attendance form, separate-page conflict warning with
per-row Remove-from-WorkLog checkboxes, supervisor-scoped
permissions, Level-1 YTD totals on the worker detail page, +3
polish items (dashboard alert, Absences tab, CSV export).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:14:43 +02:00

375 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<id>/`. No BCEA accrual logic. |
| 7 | Polish items | Dashboard alert card for last-7-days absences + Absences tab on `/workers/<id>/` + 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/<id>/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/<id>/edit/` | `absence_edit` | GET, POST | admin OR scope-matching supervisor |
| `/absences/<id>/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/<id>/`, 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<date rejection, conflict detection, supervisor scoping |
| `AbsencePayrollSyncTests` | Paid Bonus created at daily_rate; toggle off deleted; refuses if adjustment is paid |
| `AbsenceLogViewTests` | GET 200, POST creates, POST with conflicts redirects to /confirm/, atomic on partial failure |
| `AbsenceConfirmViewTests` | Without session redirects back, POST processes WorkLog removals + Absence creates in one transaction |
| `AbsenceListViewTests` | Admin all, supervisor scoped, every filter |
| `AbsenceEditDeleteTests` | Edit propagation, delete cascade, refuses delete if adjustment paid, supervisor scope |
| `MarkAbsentQuickViewTests` | POST creates absence + redirects to attendance, missing data 400s |
| `AbsenceExportCSVTests` | CSV format correct, filters applied, supervisor 403s |
| `AbsenceYTDPanelTests` | `absence_ytd_totals` in worker_detail context, dashboard `absences_recent_count` is 7-day window |
## Files manifest
**Create (6):**
- `core/migrations/0014_add_absence.py`
- `core/templates/core/absences/log.html`
- `core/templates/core/absences/log_confirm.html`
- `core/templates/core/absences/list.html`
- `core/templates/core/absences/edit.html`
- `core/templates/core/absences/_quick_modal.html`
**Modify (10):**
- `core/models.py`
- `core/forms.py`
- `core/views.py`
- `core/urls.py`
- `core/admin.py`
- `core/templates/core/attendance_log.html`
- `core/templates/core/workers/detail.html`
- `core/templates/core/index.html`
- `core/tests.py`
- `CLAUDE.md`
**Estimated LOC:** ~1400-1500 lines net. Comparable to Phase A.1 SiteReport (1255 lines).
## End-to-end verification (manual)
1. `python manage.py migrate` applies `0014_add_absence`
2. `/attendance/log/` log a worker present on a date, submit
3. `/absences/log/` same date + team + including that worker submit land on `/absences/log/confirm/` with yellow conflict warning
4. Tick "Remove from WorkLog" Confirm land on `/absences/` with success toast
5. `/absences/` see new rows, try each filter (worker, team, project, reason, date range, paid)
6. Edit one absence, toggle Paid on save check `/payroll/?status=adjustments` Bonus row should exist with amount = daily_rate
7. Toggle Paid off adjustment gone
8. `/workers/<id>/` 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.