216 Commits

Author SHA1 Message Date
Konrad du Plessis
1d224bc01b fix(templates): convert 8 broken multi-line {# #} comments + clarify cryptic sublines
CLAUDE.md gotcha #1 strikes again — the dashboard audit pass added
7 multi-line {# ... #} comment blocks across index.html, report.html,
and pdf/report_pdf.html. All rendered as literal text on the live
pages (Konrad screenshotted them). Also caught an old one in
admin/base_site.html that was technically broken syntax but
non-rendering (outside any block). All 8 converted to
{% comment %}{% endcomment %}.

CLAUDE.md updated:
- Bumped the bit-us count (4 → confirmed 4 + 5 + 7 across three
  features). Added a grep-one-liner sanity check that finds broken
  multi-line {# blocks across all templates so future passes can
  spot-check before committing.

Cryptic hero-card sublines on /report/ clarified (Konrad asked
what they mean):
- "as of 08:13" → "Live total at 08:13 today · for <scope>" with
  hover tooltip explaining the snapshot semantics.
- "Company Avg / Working Day" / "/ Month" labels renamed to
  "Avg Labour Cost / Working Day" / "/ Month". Sublines simplified
  to "Lifetime average across all crews" / "Daily figure × 30.44
  days". Both gain hover tooltips that explain the math and the
  "current pay rates" basis.

Pure template + docs change. 173/173 tests still passing
(no test changes — these are cosmetic fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:23:20 +02:00
Konrad du Plessis
652168fe88 docs: capture dashboard/report audit pass landing
CLAUDE.md breadcrumb: '6 subsequent commits' → '13 subsequent
commits' with a one-paragraph summary of what's pending pull +
restart on production. Points at parked-work.md for the full
commit table.

parked-work.md:
- 'Pending pull-and-restart' table: added 18c75b2 (calendar
  month) and the 2e6b78d→c02edce audit-pass range as a single
  collapsed entry.
- 'Recently shipped' grew a detailed entry for the 18-finding
  audit pass at the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:15:53 +02:00
Konrad du Plessis
c02edce0b9 docs(CLAUDE.md): daily_rate semantics + dual-code-path note
Documents two related foot-guns that bit recent audits:

- `Worker.daily_rate` is computed LIVE from `monthly_salary`; it's
  never snapshotted onto a WorkLog row. So historical cost totals
  inflate retroactively when a worker gets a raise. The new
  "at current pay rates" subline on the report hero cards
  (commit 4186603) is the visible half of this convention. Future
  audits should read this note before deciding "this can't be right".

- Two code paths compute the same formula: Python property and a
  SQL `Sum(F('workers__monthly_salary') / Decimal('20'))`. They
  produce identical results in normal use but could drift by 1
  cent on edge-case rounding. `CompanyCostVelocitySQLAggregateTests`
  is the regression test that would catch a real divergence.

Findings 2 + 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:11:19 +02:00
Konrad du Plessis
8f81e5ab94 refactor(report): signed adjustment amounts + filter-attribution caveat
Three related changes to the executive payroll report:

1. Adjustment Summary table and Worker Breakdown table now render
   deductive types (Deductions, Loan Repayment, Advance Repayment)
   as "-R 500.00" in muted red. Before, they showed the same way as
   bonuses — which read as "everyone gets richer" when a deduction
   was actually shrinking net pay. New context keys:
   - `adjustment_totals[i]['sign']` and `['is_deductive']`
   - `active_adj_headers` (list of {label, is_deductive}) replaces
     the parallel `active_adj_labels`/`active_adj_types` lists for
     templates. The originals are still emitted for any external
     consumer.
   - `worker_breakdown[i]['adj_values']` now contains
     {'amount', 'is_deductive'} dicts instead of bare Decimals.
   Templates updated: report.html + pdf/report_pdf.html.

2. "Total Paid Out" hero card on /report/ now shows a small asterisk
   + tooltip when project/team filters are active, explaining that
   a PayrollRecord touching the filtered scope is summed at its
   FULL amount — not just the project-attributable portion. Cheap
   label approach; the proper per-project attribution would need
   proportional splitting across each record's work_logs (deferred).
   New context key `total_paid_filter_caveat: bool`.

3. (No code change — Finding 6 was already satisfied by commit 1's
   `outstanding_by_project_sorted` rewrite, but the regression test
   protects the sort order going forward.)

Findings 3, 4, 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:10:36 +02:00
Konrad du Plessis
4186603bcb ux(report,dashboard): clearer labels for paid/outstanding/avg
Pure-template label cleanups on /report/ — no math changes, just
clearer wording for the non-developer reader. Plus one consistency
fix on the payroll dashboard.

- "Outstanding Now" hero card now shows a "scoped to ..." subline
  when project/team filters are active (so it's not read as a
  company-wide figure when it's actually scoped). Finding 5.
- "Paid This Period" hero card subline adds "includes adjustments"
  to head off confusion vs the day-rate-only Labour Cost tables.
  Finding 10.
- "FoxFitt Avg / Day" + "FoxFitt Avg / Month" renamed to
  "Company Avg / Working Day" / "Company Avg / Month", with a
  subline that calls out the "at current pay rates" caveat
  (a worker's daily_rate is computed live from monthly_salary,
  so retroactive raises inflate historical totals). Findings 2 + 15.
- "Labour Cost by Project" + "Labour Cost by Team" tables: header
  renamed to "Day-Rate Cost" with a tooltip clarifying it excludes
  adjustments. Finding 10.
- Worker Breakdown table: footnote explaining that "Days" and
  "Total Paid" can disagree within a single period when a worker
  is paid for previous-period work. Finding 9.
- Payroll dashboard chart data: dropped the `worker__active=True`
  pre-filter on the per-worker breakdown queries so the SQL matches
  `recent_payments_total` (which has no active filter). The outer
  loop still iterates active workers only — this is a SQL-side
  consistency fix, not a behaviour change. Finding 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:04:55 +02:00
Konrad du Plessis
3ef6db71c9 refactor(report): drop dead year_projects context + SQL cost velocity
Two unrelated cleanups in `_build_report_context` and the helper next
to it.

- Removed `year_projects`, `year_teams`, and `current_year` from the
  report context dict. No template ever rendered them — they were
  added 2026-04 as part of an executive-report design that never
  shipped that section. Each render fired 2 extra GROUP BY queries
  for nothing.

- `_company_cost_velocity` no longer loops every (work_log × worker)
  pair in Python. Single SQL aggregate (`Sum(monthly_salary / 20)`)
  instead — one round-trip regardless of dataset size. Old behaviour
  loaded the entire WorkLog table + M2M into memory for the hero KPI
  card. Regression test (`test_sql_aggregate_matches_python_loop`)
  uses the old Python loop as the expected oracle.

Findings 14 + 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:01:48 +02:00
Konrad du Plessis
e797a71b94 fix(dashboard,report): timezone.localdate + off-by-one date windows
Two related foot-guns on the admin dashboard and payroll dashboard:

1. Every `timezone.now().date()` call returned the date in UTC, not
   in Africa/Johannesburg. Between 22:00 and midnight SAST that's the
   NEXT calendar day — so the "today" the dashboard thought it was
   could be ahead of what the user sees on the clock. Now uses
   `timezone.localdate()` which respects `settings.TIME_ZONE`.
   Same fix for `datetime.date.today()` calls — those used the
   server's system clock, which on the production VM is set to UTC.

2. "Absences (last 7 days)" and "Paid (Last 60 Days)" both subtracted
   the FULL window length and combined it with `>=`, producing N+1
   inclusive days. E.g. `today - timedelta(days=7)` with `date__gte`
   spans 8 calendar days, not 7. Now subtract N-1 so the windows are
   exactly N days. Regression test: DateWindowOffByOneTests.

Findings 12 + 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:59:12 +02:00
Konrad du Plessis
2e6b78d28a fix(dashboard): align outstanding totals + project-name dedupe
The home dashboard and payroll dashboard used to disagree on
"outstanding payments" because the home version included inactive
workers' unpaid wages while the payroll dashboard's per-worker loop
only iterated active workers. Symptom was the same field showing two
different R-amounts depending on which page you opened first.

Also fixes the Outstanding-by-Project card silently merging two
projects when they share a name (it was keyed by project_name).

- `_compute_outstanding` now defaults to active workers only.
  Pass `include_inactive_workers=True` to surface deactivated-worker
  liabilities (rare; usually means a forgotten payment).
- Output is keyed by project_id (with name as data) so two projects
  with identical names stay as separate rows.
- New `outstanding_by_project_sorted` list — pre-sorted by amount
  desc — replaces the dict iteration in templates.
- "Active Loans" card on the home dashboard renamed to
  "Active Loans & Advances" so the label matches its data (which
  already summed both loan_types).
- Regression tests: ComputeOutstandingActiveScopeTests +
  ComputeOutstandingProjectIdKeyingTests.

Findings 1, 7/17, 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:55:49 +02:00
Konrad du Plessis
18c75b2bce fix(dashboard): 'Paid This Month' actually uses calendar month
The dashboard card labeled 'Paid This Month' was summing the
last 60 days of PayrollRecords — identical to the payroll
dashboard's 'Paid (60D)' card. Misleading at best, wrong at
worst when explaining the dashboard to a non-developer.

Now filters by date__year + date__month (current calendar month
only). Added 3 regression tests: excludes 45-day-old payment,
includes 1st-of-month payment, returns 0 cleanly when nothing
paid yet this month.

Found during Konrad's 15 May audit of dashboard numbers.
2026-05-15 01:46:47 +02:00
Konrad du Plessis
9bd0e8541d docs: capture absence polish-pass landing + refresh deploy counter
CLAUDE.md breadcrumb: 'subsequent UX polish' → '6 subsequent
commits' so the next session can see at a glance how much is
pending pull-and-restart on production.

parked-work.md:
- 'Small polish follow-ups' section: 7 items cleared on 15 May;
  section header retained as a landing pad for future cleanups.
- 'Pending pull-and-restart' table: added rows for 70fa085
  (day-name in modal) and d1d3e15 (polish pass) so the deploy
  checklist is current.
- 'Recently shipped' grew two new entries at the top: the polish
  pass and the day-name modal change.
2026-05-15 01:11:13 +02:00
Konrad du Plessis
d1d3e15444 chore(absences): 7 polish follow-ups from code review
Small cleanups tracked in docs/plans/parked-work.md:

1. Delete dead AbsenceQuickForm class — Round C replaced the per-row
   ✗ modal paradigm with the "Submit + Log Absences" button, but the
   form class never got wired up. No view, URL, template, or test
   ever referenced it.
2. Single-query team_workers_map via shared _build_team_workers_map
   helper. Previously fired one SELECT per team because .filter(
   active=True) on a prefetched M2M bypasses the prefetch cache.
   Now uses Prefetch(to_attr='active_workers_cached'). Both
   attendance_log() and absence_log() use the same helper.
3. absence_list permission check now uses _user_can_log_absences
   instead of duplicating the same `is_admin OR supervised_teams`
   logic inline.
4. Drop misleading var(--badge-neutral-bg, …) wrapper in custom.css —
   the variable isn't declared so the fallback always wins. Use the
   hex directly.
5. conflicting_worklogs() N+1 → single query: was firing one SELECT
   per (worker, date) pair (25 queries on a 5×5 form). Now 2 queries
   total via .filter(date__in=…, workers__in=…) + Python-side pair
   set check.
6. Extract _apply_absence_filters helper — absence_list and
   absence_export_csv were duplicating the same 7-param filter block
   (with a TODO comment to factor it out). Now structurally enforced
   in one place; list view keeps the raw param read-back for
   template-context dropdown preselection.
7. Replace style="color: var(--badge-bonus-bg)" with class="text-success"
   on the paid-check icon in site_report_detail.html — same WCAG
   contrast bug we fixed on the absence templates (background colour
   used as foreground).

All 157 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:09:44 +02:00
Konrad du Plessis
70fa085886 ux(history): show day name in Work Log Payroll modal header
date in the modal header was "2026-05-15" — now reads
"Friday, 15 May 2026". Server-side strftime on the already-loaded
log.date — zero DB / compute overhead. Touches only the modal's
date field; other date fields in the JSON (paid_date,
pay_period_*) still use the ISO format because they don't render
into a human-facing header.
2026-05-15 00:57:38 +02:00
Konrad du Plessis
bde6f24bb1 docs: refresh CLAUDE.md + parked-work for 15 May session wins
CLAUDE.md changes:
- 'What's mid-flight' breadcrumb updated: SiteReport/Absences
  migrations are LIVE on prod; only the 4 latest UX commits await
  a pull-and-restart (no migration / collectstatic needed).
- URL Routes table entries for /workers/ and /history/ now document
  the new ?team= filter (and team=none for unassigned / no-team cases).
- Worker Management UI inline description mentions the team filter
  + Absences tab on the worker detail page.
- Two new Coding Style gotchas captured: (1) Bootstrap dropdowns
  inside .card elements get clipped by sibling cards — fix is to
  lift the wrapping card with position:relative + z-index;
  (2) JS reading from data-worker-id was unreliable on production —
  read input[name="workers"][value] directly (the attendance-form
  pattern that's been working for years).

parked-work.md changes:
- Pending-deploy section rewritten: the BIG deploy (migrations +
  collectstatic) is DONE; only 4 small commits await a pull +
  service restart.
- 'Recently shipped' grew two new entries: the team filters on
  /workers/ + /history/, and the two absences UX polish fixes.
- Updated timestamp to 15 May 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:50:47 +02:00
Konrad du Plessis
398a5b21ab feat(history): add Team filter to /history/ page (and CSV export)
Mirrors the team filter just added to /workers/. WorkLog.team is a
nullable FK, so the filter accepts:
- empty   → all logs (default)
- digit   → logs tagged with that team
- 'none'  → logs with no team set (ad-hoc attendance)

Filter row reflowed to col-md-3 col-lg-2 so all four selects fit on
a single row on wide screens; mobile stacks them. CSV export link
now passes &team=… through. Supervisors only see teams they
supervise in the dropdown.

4 regression tests covering filter narrowing, no-team match,
empty=show-all, and filter_params round-trip for the List/Calendar
toggle links.
2026-05-15 00:47:15 +02:00
Konrad du Plessis
4b57cffb77 feat(workers): add team filter to /workers/ page
New ?team=<id> URL param narrows the worker list to that team's
members via the Team.workers M2M. ?team=none filters to workers
not assigned to any team. Default (empty) still shows all
matching workers across all teams.

UI: new "Team" dropdown in the filter row, between Search and
Status. Lists active teams alphabetically. Layout reflowed to
col-md-4 / col-md-3 / col-md-3 / col-md-2.

Konrad's checkpoint feedback: "in the worker page - can i have a
filter for teams so i can easely see who is in what team".

4 regression tests covering no-filter, by-team, no-team, and
dropdown options.
2026-05-15 00:34:35 +02:00
Konrad du Plessis
02c6d4da74 fix(absences): lift filter card stacking context so Reasons dropdown wins
The Reasons multi-checkbox dropdown was rendering BEHIND the table
rows even with z-index: 1050 applied. Root cause: the filter card
and the table card are sibling .card elements, both creating their
own stacking contexts. The dropdown's z-index was being measured
inside the filter card's local stacking context, but the table card
(next sibling in document order) sat on top of the whole filter
card in the page's stacking order.

Fix: set position: relative + z-index: 10 on the wrapping <form
class="card mb-3"> so the entire filter card lifts above the table
card globally. The dropdown's z-index: 1050 inside it now resolves
correctly.

Pure template change — no behaviour change, no test change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:25:51 +02:00
Konrad du Plessis
4368e53d95 fix(absences): team filter reads worker ID from <input> value, not data-attr
Konrad reported that selecting a team on /absences/log/ hid ALL
workers, not just non-team. Root cause: the JS read row.dataset.workerId
to filter, which depends on how Django renders choice_value for
ModelMultipleChoiceField iteration — not reliable. Switched to read
the actual <input name='workers'> value attribute, matching the
attendance_log's proven pattern. Same UX intent (hide non-team
workers); more robust implementation.

Also uses an O(1) object lookup instead of array.indexOf, and adds
defensive fallback for both string and numeric team-id keys.
2026-05-15 00:08:09 +02:00
Konrad du Plessis
7d4d7b1f8b docs: park the production deploy step + rotate parked-work queue
Worker Absences feature shipped (bf6f0a5..27fe05e), so it's no
longer the active queue item. Promoted the pending production
deploy (run /run-migrate/, collectstatic, restart service) to the
top of parked-work.md — production /history/ is 500ing until those
migrations run. CLAUDE.md breadcrumb updated to flag this as the
next operator action when a fresh session starts.

Also captured the small polish follow-ups from the absences code
reviews (AbsenceQuickForm dead code, N+1 in team_workers_map,
duplicated CSV filter block, etc.) so they don't get lost in a
future janitorial pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:43:32 +02:00
Konrad du Plessis
27fe05e3b6 fix(absences): dropdown z-index + clearer Confirm Absences copy
- /absences/ Reasons multi-checkbox dropdown: z-index 1050 so it
  renders above the table rows (was hiding the bottom 4 options).
- /absences/log/confirm/: action-oriented copy, pre-checked
  'remove from work log' (the common case), explicit Cancel button.
  Was confusing: 'Also remove from WorkLog' didn't read as the
  natural fix for the conflict. New language explains both branches
  in plain English. +1 regression test for the new copy.
2026-05-14 23:32:45 +02:00
Konrad du Plessis
a6cf766394 fix(absences): pre-push polish — admin sync + bulk-delete cascade + supervisor menu
Three small fixes from the final review:
- AbsenceAdmin.save_model() now runs _sync_absence_payroll_adjustment
  so toggling is_paid via /admin/ updates the linked Bonus consistently
  with the friendly UI.
- _delete_adjustment_with_cascade clears absence.is_paid when deleting
  a Bonus linked to an Absence — closes the state-drift window after
  bulk-delete from /payroll/?status=adjustments.
- base.html — Resources dropdown 'Absences' entry now shows for
  supervisors as well as staff (was staff-only). View-layer permission
  helpers (_absence_user_queryset, _user_can_log_absences) already
  enforce the real access boundary; this just makes the menu honest.
2 regression tests.
2026-05-14 23:04:12 +02:00
Konrad du Plessis
9345dacfbf feat(absences): worker detail tab + dashboard alert + CLAUDE.md (Round D)
#5 from checkpoint feedback: /workers/<id>/ now has an Absences tab
showing YTD totals (chip row) + 50 most-recent absences (table).
Admin dashboard adds a conditional 'X absent in last 7 days' alert
card (only renders when count > 0; links to filtered /absences/).
CLAUDE.md gets a new Absence model entry + URL routes + dedicated
'Absence-to-PayrollAdjustment cascade' section. Reason-badge CSS
moved to static/css/custom.css as single source of truth. 4 new tests.
2026-05-14 22:43:44 +02:00
Konrad du Plessis
8c749f3f52 feat(absences): 'Submit + Log Absences' button on attendance form
After logging attendance, admins can jump straight to /absences/log/
with the date, team, and project pre-filled — no need to re-pick them.
Default Submit button keeps the existing SiteReport flow unchanged.
4 new tests covering both submit paths and URL-param prefill.
2026-05-14 22:27:37 +02:00
Konrad du Plessis
32972276b5 feat(absences): add optional project FK on Absence
Migration 0015 adds Project FK (SET_NULL, nullable) to Absence.
When is_paid=True, the auto-Bonus PayrollAdjustment inherits the
project for cost-attribution. Form + admin + list + edit + log
templates expose the field. List view filter now uses
absence.project_id directly (was indirect via worker__work_logs).
5 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:11:22 +02:00
Konrad du Plessis
2ae9f34058 feat(absences): team filter + multi-reason filter + dashboard quick action
Three UX wins from checkpoint feedback:
- Team selector on /absences/log/ now hides non-team workers (matches /attendance/log/ behavior).
- /absences/ reason filter accepts multiple values (?reason=sick&reason=annual). Multi-checkbox dropdown UI.
- Dashboard Quick Actions: added Log Absence card with fa-user-clock icon.
1 new regression test for multi-reason filtering.
2026-05-14 21:44:47 +02:00
Konrad du Plessis
ea94c46cb6 fix(absences): multi-line {# #} comments rendering as text + add Resources menu entry
CLAUDE.md gotcha #5: multi-line {# ... #} blocks render as literal text
in Django templates. Converted to {% comment %} blocks in edit.html
and list.html (also scanned log.html / log_confirm.html for safety).

Adds an 'Absences' entry to the Resources dropdown in base.html so the
feature is discoverable from the topbar.
2026-05-14 21:13:53 +02:00
Konrad du Plessis
37268801a1 feat(absences): list + edit + delete + CSV export
/absences/ filtered list with pagination + reason badges;
/absences/<id>/edit/ syncs adjustment on save; /absences/<id>/delete/
cascades unpaid adjustment, refuses if paid; /absences/export/
admin-only CSV. 10 tests.
2026-05-14 20:54:50 +02:00
Konrad du Plessis
b5833f675d feat(absences): log + confirm views + templates + URLs
/absences/log/ accepts form; no-conflict path creates absences
atomically; conflict path stashes pending data in session and
redirects to /absences/log/confirm/ (yellow warning + per-row
'Remove from WorkLog' checkboxes). Confirm POST runs atomic
transaction: remove flagged workers from WorkLogs, create
Absences, sync payroll adjustments. 10 tests.
2026-05-14 20:26:31 +02:00
Konrad du Plessis
8f2d3e9dfe feat(absences): AbsenceLogForm + AbsenceQuickForm + AbsenceEditForm
Three forms covering the three entry points: standalone date-range form
(/absences/log/), quick-action modal (/attendance/log/), and edit one
existing record. Log form expands (worker, date) pairs respecting
Sat/Sun toggles, validates uniqueness, surfaces WorkLog conflicts as a
non-blocking warning via conflicting_worklogs(). 6 tests.
2026-05-14 20:04:34 +02:00
Konrad du Plessis
90c0e57659 feat(absences): _absence_user_queryset + _sync_absence_payroll_adjustment
Two helpers covering the recurring 'which absences can this user see'
and 'sync is_paid with the linked Bonus PayrollAdjustment' patterns.
The sync helper refuses to delete an already-paid adjustment — caller
surfaces this to the user. Mirrors _delete_adjustment_with_cascade
semantics. 8 tests.
2026-05-14 19:45:37 +02:00
Konrad du Plessis
bf6f0a5c74 feat(absences): Absence model + migration + admin registration
Per-worker dated records with 8 reason choices (Sick/Family/Annual/
Personal-Unpaid/IOD/Suspension/Absconded/Other), is_paid flag, optional
OneToOne to PayrollAdjustment for the auto-Bonus path, audit fields.
Unique-per-day at DB layer. 4 model tests.
2026-05-14 19:22:06 +02:00
Konrad du Plessis
f146af0e35 docs(absences): task-by-task implementation plan
7 tasks + 1 mid-flight checkpoint after Task 5 (CRUD complete).
Each task is TDD: failing test → minimal impl → verify pass →
commit. Target ~38 new tests, ~1700 LOC. Skipped doctor's note
upload (Q7 decision). Risks flagged: OneToOneField SET_NULL
drift, atomic-failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:04:43 +02:00
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
Konrad du Plessis
69c81a4092 docs: add parked-work index for session handoffs
Captures the deferred work (Phase A.2 manual JournalEntry UI,
Phase B Letterly webhook) and queues the next brainstorm topic
(worker absence records). Adds an 8-line breadcrumb at the top
of CLAUDE.md so a fresh session sees it in the first 20 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:30:29 +02:00
Konrad du Plessis
864ae722c4 feat(site-report): structured site progress logging — Phase A.1
Companion to attendance: capture WHAT was done on site each day,
alongside WHO worked. Optional 1:1 with WorkLog. Mobile-first form
auto-redirected from /attendance/log/ on success (with a Skip link).

Why this design (vs. extending WorkLog or per-project templates):

- Hybrid schema. Stable + queryable fields are real columns
  (`weather`, `temperature_min`, `temperature_max`, `notes`,
  `created_by`, `created_at`, `updated_at`). The METRICS that change
  per project / over time live in a single JSONField with shape
  `{counts: {key: int}, checks: {key: bool}}` — driven by
  `core/site_report_schema.py`. Adding a new metric is a one-line
  edit to that file, NO migration required. Old reports without the
  new key just render as 0 / unchecked.

- Two-step flow. Attendance form is unchanged; on successful POST
  the supervisor lands on `/site-report/<work_log_id>/edit/` for the
  most-recently-created log. They can fill in progress details
  (~30 sec on a phone) or click "Skip" to home. WorkLogs without a
  SiteReport are completely valid historic rows.

- Permission scope mirrors WorkLog access. Anyone who can see the
  parent log (admin / log's supervisor / project's supervisors) can
  see + edit its SiteReport. Wraps the existing pattern from
  `work_history()` in a small helper `_can_access_site_report()`.

What ships:

  Models:
    - SiteReport (1:1 → WorkLog, weather choices, IntegerField temps,
      JSONField metrics defaulting to {})
    - Migration 0013_add_site_report (pure CreateModel, no schema
      changes to existing tables)

  Schema:
    - core/site_report_schema.py (NEW) — single source of truth for
      the metric list. Currently 7 counts + 4 checks per Konrad's
      v1 spec. Helpers: get_count_keys, get_check_keys, label_for,
      empty_metrics.

  Form:
    - SiteReportForm (in core/forms.py) — ModelForm with the four
      stable fields PLUS dynamic IntegerField/BooleanField per
      metric in __init__. save() serializes both halves into the
      JSON blob. clean() validates min ≤ max temperature.

  Views:
    - site_report_edit — create-or-update; stamps created_by on
      first save; preserves it on subsequent admin edits
    - site_report_detail — read-only display; 404 when no report
    - attendance_log redirect updated to two-step flow
    - _can_access_site_report — shared permission helper

  URLs:
    - /site-report/<work_log_id>/edit/  (name: site_report_edit)
    - /site-report/<work_log_id>/       (name: site_report_detail)

  Templates:
    - site_report_edit.html — mobile-first stack of inputs, weather
      as a chunky icon-button row (☀️ ☁️ 🌧️ ⛈️ 🥵 🥶 💨), counts in a
      2-col grid, checks as toggle switches, Notes textarea, Skip
      + Save buttons. Iterates pre-built (metric, bound_field)
      pairs from the view to avoid needing a new template filter.
    - site_report_detail.html — counts as accent-coloured value
      cards, checks as a check-list, weather + temp + notes + edit
      link.
    - work_history.html — added a small clipboard icon next to
      each row's date: filled (linked to detail) when a report
      exists, muted outline (linked to edit) when not. Click is
      event.stopPropagation()-ed so the row's payroll-modal
      handler doesn't also fire.

  Performance:
    - work_history queryset adds .select_related('site_report') so
      the new template indicator doesn't introduce an N+1.

  Admin:
    - SiteReport registered with raw_id_fields on work_log +
      created_by, list filters on weather + project + date.

  Tests (16 new, full suite 85/85):
    - SiteReportModelTests — defaults, 1:1 reverse accessor,
      arbitrary-key JSON round-trip
    - SiteReportFormTests — dynamic field generation, save
      serialisation, temp validation, instance pre-fill
    - SiteReportEditViewTests — admin GET/POST, project
      supervisor allowed, outsider supervisor 403, created_by
      preserved on subsequent admin edits
    - SiteReportDetailViewTests — 404 when absent, displays data
      when present
    - AttendanceLogRedirectsToSiteReportTests — confirms the
      two-step flow

  CLAUDE.md updates:
    - SiteReport added to "Key Models" with shape + reverse-accessor note
    - New "SiteReport metric schema" section near "UI-vs-DB
      naming drift" — explains the JSON-column-with-Python-source
      pattern, when it's safe, what NOT to do (rename a key with
      data), and where the keys appear across the codebase
    - URL Routes table gets the two new endpoints

What's NOT in this commit (deferred per the brainstorm plan):
  - JournalEntry model + manual web-entry UI (Phase A.2 — depends
    on Konrad's Q7 answer about Vi/recipient field)
  - Letterly inbound webhook (Phase B — integrations branch only,
    depends on Q5 sample payload)
  - Photos on site reports (Q9, defaulted to "future")
  - Per-project metric templates (Q4, defaulted to "same set for all v1")

Reference plan: ~/.claude/plans/prancy-painting-brook.md (local).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 02:29:33 +02:00
Konrad du Plessis
3da039b74e Revert "feat(webhooks): outbound payslip webhook → Make.com / Zapier / n8n"
This reverts commit a52d841c00ad642942dd6de7bf54373ad9ea62d6.
2026-04-24 13:03:12 +02:00
Konrad du Plessis
a52d841c00 feat(webhooks): outbound payslip webhook → Make.com / Zapier / n8n
First integration: when a PayrollRecord is created, the app POSTs a
JSON summary to WEBHOOK_PAYSLIP_URL (env var) if set. Unset = feature
OFF; no network call, no behaviour change. Typical destination: a
Make.com / Zapier / n8n Catch-Hook URL that fans the event out to
Google Sheets / Airtable / Slack / etc. — no more Python required.

This is the highest-leverage starter integration per the research plan
at ~/.claude/plans/prancy-painting-brook.md (Section B). One webhook
sender unlocks 5000+ downstream destinations via visual workflow UIs,
and the pattern (post_save signal + env-var gate + try/except) becomes
the template for future event types.

Implementation:
  - core/signals.py (new) — post_save receiver on PayrollRecord;
    fires only on created=True; short-circuits when env var empty;
    swallows all network errors with a WARNING log so payslip save
    is never blocked
  - core/apps.py — ready() imports signals for dispatcher registration
  - config/settings.py — reads WEBHOOK_PAYSLIP_URL env var (default "")
  - requirements.txt — adds requests>=2.32.0 (de facto Python HTTP lib,
    no prior outbound-HTTP code in the codebase)
  - CLAUDE.md — documents the env var + the non-fatal failure contract
    + points at PayslipWebhookTests for the behavioural spec

Payload shape: event, payslip_id, worker_id, worker_name, amount_paid
(as string for Decimal safety), payslip_date (ISO), work_log_count,
adjustment_count, admin_url. No unbounded text fields; no secrets.

Tests (4 new, PayslipWebhookTests):
  - fires when configured with right payload
  - no-op when env var unset
  - swallows ConnectionError without breaking PayrollRecord.save()
  - does NOT refire on subsequent .save() of an existing record
Full suite: 73/73.

Risks + rollback: trivial. Revert the commit, no data impact. Make.com
handles its own retries; if the webhook is down we just miss events
until it comes back.

Out of scope for v1 (deferred): other event types (adjustment.created,
loan.issued), HMAC signing, in-app retry queue, inbound webhooks, AI
integrations, public read-only API. All are on the roadmap in the plan
doc; each follows the same signal-based pattern and is cheap to add.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:39:01 +02:00
Konrad du Plessis
6c6ade9a45 ux(ui): pastel soft-fill payroll action buttons
The 4 action buttons at the top of /payroll/ were previously a mix
of btn-outline-info / btn-primary / btn-outline-success /
btn-outline-warning — three different treatments, with a lone solid
btn-primary (Batch Pay) pulling the eye disproportionately. Konrad
asked for a more uniform + pastel look.

New .btn-action-soft base class with per-button colour modifiers:
  - Worker Lookup → soft blue (new --btn-action-lookup tokens)
  - Batch Pay    → soft amber (new --btn-action-pay tokens;
                   slightly deeper saturation to preserve its
                   "primary" role without breaking the uniform look)
  - Add Adjustment → reuses --badge-bonus-* (green, "adding money"
                     semantic matches Bonus)
  - Price Overtime → reuses --badge-overtime-* (mauve, same colour
                     as the Overtime badge on the Adjustments tab —
                     so the button matches the data it acts on)

All 4 now share:
  - No border, solid pastel fill, contrasting text
  - Same height, padding, border-radius (0.5rem)
  - Subtle box-shadow lift on hover (filter: brightness)
  - 1px press-down on active
  - Accessible focus-visible outline in --accent
  - Icons inherit text colour

Net CSS change: 4 new tokens (2 per theme × 2 themes) + 5 new
classes. Removes shadow-sm, btn-sm, btn-md-normal, fw-bold
one-offs — all handled by the base class.

docs/design-tokens.md updated to record the new token pairs.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:10:14 +02:00
Konrad du Plessis
bfe4e4d616 docs(ui): design for payroll action-buttons pastel soft-fill
Four buttons at top of /payroll/ currently mix 3 treatments (outline
+ solid btn-primary one-off). Design swaps all 4 to a unified
.btn-action-soft base class with per-button colour modifiers
(Lookup=blue, Pay=amber, Add=green, Price=mauve). Reuses existing
--badge-*-bg tokens for the Add + Price buttons; adds 2 new token
pairs for Lookup + Pay. Removes the shadow-sm / btn-sm / fw-bold
one-offs — the new class handles sizing + weight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:07:37 +02:00
Konrad du Plessis
aafa6df189 ux(colors): apply semantic palette to Loans tab + Active Loans card
Konrad caught that /payroll/?status=loans was still using Bootstrap
defaults (bg-primary for Loan, bg-info for Advance) while the other
three tabs had moved to the semantic palette. The Preview-payslip
modal's Active Loans card had the same inconsistency in its JS-built
badge.

- Added .advance-flag-badge as a sibling to .loan-flag-badge; both
  just reference the existing --badge-loan-* / --badge-advance-*
  tokens so no new colours introduced.
- /payroll/?status=loans row badge: bg-primary/bg-info → loan-flag-
  badge/advance-flag-badge.
- Worker-lookup / Preview-payslip modal JS: same swap on the badge
  className.

Loan-family items now wear the same amber/blue colour pair on every
tab + modal they appear on. Transactional status (Active/Paid Off)
stays on Bootstrap greens/yellows — they're lifecycle, not type.

docs/design-tokens.md updated to record the new class + every place
the --badge-loan-* / --badge-advance-* tokens now appear.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:52:02 +02:00
Konrad du Plessis
f159a9f6f2 ux(labels): close remaining Adjustments-tab display gaps
Final whole-impl review on bce2619 caught two user-facing surfaces
still showing DB values instead of display labels:

1. By-Type group headers - _group_adjustments() used adj.type as
   both the visible label AND the CSS data-type attribute. Split
   into group.label (short display, for visible text) and
   group.type_key (raw DB value, for the [data-type="X"] CSS
   border-left selector).

2. Type filter popover checkboxes - adj_type_choices was a flat
   list of DB values, so checkbox labels read "New Loan" /
   "Advance Payment" / "Advance Repayment". Replaced with
   PayrollAdjustment.TYPE_CHOICES (already a (db_value,
   display_label) tuple list), and updated the template loop to
   unpack both - label in <span>, DB value in the input value=.

Both surfaces now show Loan / Advance / Advance Repaid while
preserving the canonical DB values for CSS selectors + filter
form submissions.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:29:25 +02:00
Konrad du Plessis
bce2619a71 fix(css): move display:flex from <tr> to <td> on adj-group-header
Root cause of Konrad's narrow-wrap screenshot: display:flex was set
on .adj-group-header (a <tr>), which causes the browser to remove
the row from table layout. A flex-mode <tr> ignores colspan and
shrinks to intrinsic content width — which is why a row with
colspan=10 ended up rendering at ~80-100px and wrapping the meta
text into a 5-char column.

Moved display:flex, align-items, gap, and padding onto the single
<td> child. The td is a normal block box and flexes correctly,
putting icon + label + meta in a horizontal row with the meta
pushed to the right via margin-left:auto (now working since its
parent is a real flex container).

Also added white-space:nowrap on .adj-group-meta so the meta never
wraps mid-phrase even if a narrow viewport squeezes the cell.

Inline comment documents the <tr> vs <td> distinction so future
sessions don't re-introduce the bug.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:13:00 +02:00
Konrad du Plessis
e932b3c3a7 ux(colors): unify badge colours across all payroll tabs
Replaces the 4-branch Bootstrap-state conditional on the Pending
and History tabs with the semantic .badge-type-{{ adj.type|type_slug }}
palette that the Adjustments tab has been using. Now "Loan" badges
are the same colour in every tab instead of Pending=yellow /
Adjustments=amber.

Also recolours the Pending-tab "Loan" worker flag to the same amber
(.loan-flag-badge class). "Overdue" flag stays red - it's an urgency
signal, not a type signal, and we deliberately keep transactional
state colours (Bootstrap bg-success/bg-warning/bg-danger) separate
from the type palette so a green badge can only mean "Bonus" and
never ambiguously "Paid".

Threads 'additive_types' (list(ADDITIVE_TYPES)) into the base
payroll_dashboard context so the +/- sign logic works on Pending
and History too (was previously only set in the Adjustments-tab
branch).

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:09:10 +02:00
Konrad du Plessis
f70342f825 ux(admin): use display label in PayrollAdjustment admin list column
Code-review follow-up on 1cf1304. /admin/core/payrolladjustment/
was still showing raw DB values (New Loan / Advance Payment /
Advance Repayment) in the Type list column because list_display
was the bare field name 'type', which Django renders via
str(obj.type).

Added a @admin.display method that returns obj.get_type_display()
and referenced it in list_display instead. Column header stays
'Type' and the column is still sortable by the underlying field.

list_filter kept on 'type' (DB value) - filter sidebar correctness
doesn't require the display label, and filtering works off the
canonical stored value.

Closes the last known "users see shorter labels everywhere" gap
from the Path A rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:03:57 +02:00
Konrad du Plessis
1cf13048c2 ux(labels): extend display labels to AJAX-sourced modal renders
Closes the Task 3 design-goal gap: two user-facing modals (work-log
payroll preview in base.html, split-payslip preview in
payroll_dashboard.html) render adjustment types via JS reading AJAX
JSON. After Task 3's TYPE_CHOICES rename they were still showing
the old long labels because the backend endpoints
(work_log_payroll_ajax, preview_payslip) only emitted adj.type (DB
value), not the display label.

Added a 'type_label' field to the JSON payloads alongside the
existing 'type' field. JS at both render sites now reads
`adj.type_label || adj.type` — with the fallback so any stale
client-side JSON degrades gracefully to the DB value rather than
rendering blank.

Path A still holds: adj.type in JSON stays the DB value for any
identifier purposes; the new type_label is additive.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:58:05 +02:00
Konrad du Plessis
c1d9014fe1 ux(labels): shorter adjustment type labels (display-only rename)
Path A rename - DB values untouched, only TYPE_CHOICES display
labels change:
  'New Loan'          -> shown as 'Loan'
  'Advance Payment'   -> shown as 'Advance'
  'Advance Repayment' -> shown as 'Advance Repaid'

Templates that render the type as visible text switched from
{{ adj.type }} to {{ adj.get_type_display }}. Data attributes and
CSS class slugs keep the raw DB value (identifiers, not labels).

Zero data migration. Zero changes to ADDITIVE_TYPES / DEDUCTIVE_TYPES
constants, hardcoded string comparisons, CSS class names, test
fixtures, or any other code that references the canonical DB value.
Every historic PayrollAdjustment row keeps type='New Loan' /
'Advance Payment' / 'Advance Repayment' as stored.

Django's makemigrations generated a no-op AlterField migration to
record the choices-metadata change.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:49:26 +02:00
Konrad du Plessis
e51a2f6d1d docs(claude): UI-vs-DB naming drift note (pre-rename)
Adds a new CLAUDE.md section documenting the display/DB gap that
Path A of the UX Polish Pass creates: user sees 'Loan' / 'Advance'
/ 'Advance Repaid' while DB stores 'New Loan' / 'Advance Payment'
/ 'Advance Repayment'. Includes a lookup table, the rule for when
to use which (DB for logic, display for templates), and the failure
symptom so future Claude sessions don't chase ghost filters.

Ships BEFORE the rename so the doc is searchable from minute one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:43:04 +02:00
Konrad du Plessis
0a4b12108e docs(tokens): add canonical design-tokens reference
New doc covering the semantic colour palette: every badge token, its
hex values in both themes, its CSS class, and where it's used across
the app. Categorises tokens into "type-of-adjustment" (custom semantic
palette) vs "transactional state" (Bootstrap defaults) and explains
why the two must not share colours.

Intended to be the single source of truth for UI colour decisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:38:53 +02:00
Konrad du Plessis
84e9d247be docs(ux): task-by-task plan for UX Polish Pass
Five tasks: (1) docs/design-tokens.md as the canonical colour
reference; (2) CLAUDE.md UI-vs-DB naming-drift note (ships BEFORE
the rename so it's searchable from minute one); (3) display-only
TYPE_CHOICES rename + auto-migration + template visible-text swap
to get_type_display; (4) badge colour unification on Pending +
History tabs + loan-flag recolor; (5) CSS root-cause fix for the
group-summary narrow-wrap bug (move display:flex from <tr> to <td>).

Execute via subagent-driven-development. Auto mode — no mid-execution
checkpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:36:51 +02:00
Konrad du Plessis
9aba9b8fb8 docs(ux): design for UX Polish Pass
Four UX asks bundled in one pass:

1+2. Display-only rename of adjustment types: 'New Loan' (DB) →
     'Loan' (UI), 'Advance Payment' → 'Advance', 'Advance Repayment'
     → 'Advance Repaid'. DB values preserved forever — zero data
     migration, zero formula / constant / CSS / test changes.
3. Unify badge colours across all payroll tabs using the existing
   .badge-type-* semantic palette. Recolour Pending "With loans"
   flag to match the Loan type colour.
4. Fix CSS bug in .adj-group-meta (margin-left:auto doesn't work
   in a <td> — make the td a flex container).

Plus: new docs/design-tokens.md as the canonical colour reference,
and a crucial CLAUDE.md section documenting the UI-vs-DB naming
drift so future Claude sessions don't chase ghosts when writing
formulas / filters / ORM queries that reference the display label
instead of the DB value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:32:23 +02:00
Konrad du Plessis
b43892f712 docs(claude): capture session's new patterns + gotchas
Three additions from this session's work:

1. Django ORM gotcha — PayrollAdjustment project double-attribution.
   Documents the Coalesce pattern that solved the Apr 2026 perf-pass
   double-count bug on Overtime adjustments.

2. Payroll dashboard query-count baselines — target ranges for /
   and the four /payroll/ tabs after the perf pass, plus the
   "spotting a regression" heuristic (>50% jump = N+1 reintroduced).

3. Profiling locally — Django Debug Toolbar — what it is, how it's
   triple-gated, how to use it for N+1 hunting. Flags that the
   package is already in requirements.txt so future sessions don't
   need to install it.

Net: +35 lines, three new sections, no deletions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:51:23 +02:00