_process_single_payment wrote logs_amount + adj_amount straight into
PayrollRecord.amount_paid with no floor — a selection where deductions
beat earnings (easy via the split-payslip checkboxes: untick the work
logs, leave a big loan repayment ticked) recorded a NEGATIVE payment,
emailed a negative payslip to Spark Receipt, and deducted the loan.
Per Konrad's decision (12 Jun 2026): REFUSE such payments. New
DeductionsExceedEarningsError raised inside the atomic block (full
rollback — loan balance and adjustments untouched); process_payment
shows a clear error toast, batch_pay names the refused worker in its
summary. Exactly-zero net stays allowed (a repayment consuming the
whole wage legitimately settles a loan with a R 0.00 payslip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two payment-safety fixes in the Batch Pay modal JS:
1. Changing the team/loan filter force-checked every visible row,
silently re-selecting workers the admin had deliberately unticked
(untick a disputed worker -> change filter -> Confirm & Pay All pays
them anyway). Filters now only EXCLUDE (hidden rows untick); visible
rows keep the admin's manual choice, and Select All reflects the
real state instead of being forced on.
2. The batch-pay fetch() redirected to the dashboard on ANY HTTP
response — fetch only rejects on network failure, so a 500 (batch
died partway; each worker pays in its own transaction) looked like
success. Now checks resp.ok and tells the admin to verify the
History tab before retrying.
JS-only change; needs manual verification in the browser (no JS test
harness in this project).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_send_payslip_email caught email errors, queued a warning toast, then
re-raised unconditionally — but only batch_pay catches that re-raise.
The Pay button (process_payment) and the three Pay-Immediately paths in
add_adjustment called it bare, so an SMTP failure AFTER the payment
committed replaced the warning with a 500 error page (the 28 May 2026
incident behaviour — admin can't tell the payment saved, may pay twice).
Re-raise is now conditional on suppress_messages=True (the batch path,
which counts failures in its summary); interactive callers get the
warning toast as intended. Regression tests cover both sides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops core_sitereport via 0018_delete_sitereport. Knowledge preserved
in docs/plans/2026-05-17-site-report-removed-capture.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Also deletes AttendanceLogRedirectsToSiteReportTests (it asserted only
the removed redirect destination — a behavioural test, not a model
test). Suite 209 -> 208.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin Quick Actions tile -> /payroll/?action=pay-salary; the payroll
page auto-clicks the existing paySalaryBtn then strips the param.
Reuses all existing Pay-Salary machinery; param inert server-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When type=Salary: set pay-type filter to Managers-only, hide daily
rows, and untick any selected daily worker so a Salary can never
silently target a daily worker. Re-applied on the Pay-Salary open
path (the show.bs.modal reset clears it first). Pure JS; verified by
manual checklist; suite stays 207/207.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents a pre-checked quick-adjust worker from opening hidden behind a
stale 'Managers only'/'Daily only' filter. Display-only; no data impact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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.
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>
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.
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.
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.
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>
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.
- /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.
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.
#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.
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.
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>
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.
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.