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>
This commit is contained in:
parent
69c81a4092
commit
08f312f6e3
374
docs/plans/2026-05-14-worker-absences-design.md
Normal file
374
docs/plans/2026-05-14-worker-absences-design.md
Normal file
@ -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/<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.
|
||||
Loading…
x
Reference in New Issue
Block a user