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>
16 KiB
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:
- No leave audit trail. When a worker disputes an Absconded classification later, there's no dated record of when they stopped showing up.
- 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.
- 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.
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.
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_querysetscoping.clean()validates end_date ≥ date; returns expanded(worker, date)tuples; flags unique-key conflicts (hard error) and WorkLog conflicts (passed back asconflict_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
- POST to
/absences/log/. Form validates. - No conflicts → create absences, fire payroll sync, redirect to
/absences/with success toast. - Conflicts exist → stash form data + conflict list in
request.session['absence_pending']. Redirect to/absences/log/confirm/. - 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. - POST
/absences/log/confirm/. Intransaction.atomic():- Remove flagged workers from their conflicting WorkLogs (M2M remove).
- Create the Absence rows.
- Fire
_sync_absence_payroll_adjustmentper row. - Clear
request.session['absence_pending'].
- Redirect to
/absences/with success toast.
Helper functions (core/views.py)
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:
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.pycore/templates/core/absences/log.htmlcore/templates/core/absences/log_confirm.htmlcore/templates/core/absences/list.htmlcore/templates/core/absences/edit.htmlcore/templates/core/absences/_quick_modal.html
Modify (10):
core/models.pycore/forms.pycore/views.pycore/urls.pycore/admin.pycore/templates/core/attendance_log.htmlcore/templates/core/workers/detail.htmlcore/templates/core/index.htmlcore/tests.pyCLAUDE.md
Estimated LOC: ~1400-1500 lines net. Comparable to Phase A.1 SiteReport (1255 lines).
End-to-end verification (manual)
python manage.py migrate— applies0014_add_absence/attendance/log/— log a worker present on a date, submit/absences/log/— same date + team + including that worker → submit → land on/absences/log/confirm/with yellow conflict warning- Tick "Remove from WorkLog" → Confirm → land on
/absences/with success toast /absences/— see new rows, try each filter (worker, team, project, reason, date range, paid)- Edit one absence, toggle Paid on → save → check
/payroll/?status=adjustments— Bonus row should exist with amount = daily_rate - Toggle Paid off → adjustment gone
/workers/<id>/→ Absences tab → YTD totals match rows/(admin home) → "X workers absent in last 7 days" alert card visible (if absences exist in the last 7 days)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.pycommand 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
- Permission queryset for supervisors. The exact reverse-accessor
path (
team__supervisor=uservsteam__project_supervisors=uservsassigned_projects__teams__workers=worker) needs verification againstcore/models.pyduring implementation. The brainstorm sketch is correct in spirit but the field names need checking. OneToOneFieldon_delete=SET_NULLsemantics. If the linked PayrollAdjustment is deleted directly (via the existing adjustments tab), the Absence'spayroll_adjustmentfield goes NULL butis_paidstays 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.- 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.