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

16 KiB
Raw Permalink Blame History

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.

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

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.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.