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>
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>
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.
- /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>
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.
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.
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.
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>
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>
Spec-review catch on 61c485f: the batched GROUP BY aggregates for
unpaid-per-project and paid-per-project x month were running TWO
filtered queries and summing them in Python. Any adjustment with
BOTH project FK AND work_log.project set was double-counted.
Every Overtime adjustment fits that shape (price_overtime sets
both). So every unpaid Overtime was silently inflating the
outstanding-costs dashboard by its own amount, and every paid
Overtime inflated the Per-project-monthly-payroll stacked chart.
Fix: annotate Coalesce('project_id', 'work_log__project_id') so
each adjustment contributes to exactly one project (matches the
original Q(...) | Q(...) OR-filter semantics).
New regression test locks in the "count once" behaviour with an
Overtime adjustment that has both FKs set. Previously there was no
test covering the sum correctness of outstanding-costs - only
context-key presence.
Tests: 69/69. Query counts per tab: pending 24q / history 24q /
loans 25q / adjustments 32q (2 fewer per tab than 61c485f because
Coalesce folded two filtered queries into one).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on 16d4399:
- CLAUDE.md's "When CSS changes don't appear" diagnostic steps
were written for the old per-request token. Under mtime-based
caching, a stable ?v= number is the healthy expected state,
not a broken one. Rewrote steps 1 + 3 so someone debugging
a real production CSS issue gets the right advice.
- Dropped unused `original = cp._compute_cache_bust_token` line
in test_token_falls_back_if_file_missing - it misled readers
into thinking the function itself was patched. Added a one-
line comment clarifying the monkey-patch is path-only.
Tests: still 68/68.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
deployment_timestamp was int(time.time()) per-request, giving every
page load a new ?v=... query string on custom.css. Cloudflare treats
each unique URL as a new resource, so the CSS was fetched from the VM
on every page load — 64 KB over the wire per navigation.
Token now tied to static/css/custom.css mtime. The URL only changes
when the CSS actually changes, so Cloudflare can hold the file for
its full 4h TTL. Degraded-mode fallback preserves today's behaviour
if the file isn't on disk.
3 new CacheBustTokenTests; all 68 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Team(s) are selected via the Teams popover, the Workers popover
now only shows workers who belong to at least one of those teams.
URL-selected workers stay visible regardless (so the user can see
and untick them).
Backend adds one context key: team_worker_pairs_json — raw Python
list of {team_id, worker_id} dicts from Team.workers.through;
template renders via |json_script (safe, no double-encoding).
Frontend reads the JSON once, builds a team_id -> Set(worker_id)
index, and on every Workers-popover open (and on Teams-popover OK)
hides rows whose worker is out-of-team. display:none on the <label>
row is visually cleaner than disabling the checkbox alone.
Scope: entire roster (not date-range-scoped) — cross-filter is
about data possibility, not data in this period.
One new test locks in the pairs-context-key shape (asserts it's a
raw Python list of dicts, not a pre-serialised JSON string —
guards against the double-encoding regression from Feature 1).
65/65 tests pass.
Code-review found a data-integrity bug: the bulk-delete endpoint
bypassed the cascade logic that single-row delete_adjustment does
for 'New Loan', 'Advance Payment', and 'Overtime' types.
Without cascade, bulk-deleting a 'New Loan' adjustment would:
- Delete the PayrollAdjustment row
- LEAVE the linked Loan row orphaned in the DB (still shown in
loan reports, still affecting remaining_balance queries)
- LEAVE any scheduled unpaid Loan Repayment adjustments pointing
at the orphaned Loan (they would silently deduct from the
worker's next pay with no visible parent)
Bulk-deleting an 'Overtime' adjustment would leave the worker
stuck in work_log.priced_workers, making price_overtime() treat
them as already-priced even though the money is gone.
Fix: extracted _delete_adjustment_with_cascade(adj) helper that
captures the exact rules from the existing delete_adjustment view
— returns (ok, reason) so both callers can translate the outcome
into their own response shape. Both views now delegate to it.
bulk_delete_adjustments now loops over the selected rows, calls
the helper per-row, and returns JSON including skipped_reasons
(e.g. {'has_paid_repayments': 1} when a Loan with paid repayments
was refused). Also hardened the id-coercion to int so a garbled
POST payload can't crash the queryset with a ValueError.
Two new tests:
- test_bulk_delete_cascades_new_loan — loan row + unpaid repayment
must also be deleted
- test_bulk_delete_skips_loan_with_paid_repayments — refuses to
delete the loan but still processes other rows in the batch
64/64 tests pass (was 62). No API surface change visible to a user
who only uses the happy path — but the audit trail on Loans is
now safe even under bulk delete.
New POST /payroll/adjustments/bulk-delete/ endpoint takes a list of
adjustment_ids and DELETEs the ones that are still unpaid
(payroll_record__isnull=True at the DB level) — paid rows are silently
skipped, defensive against stale-UI race conditions. Admin-only;
supervisors get 403. Returns JSON {deleted, requested}.
Floating bar slides up from the bottom of the viewport when >=1 row
selected: shows count + Delete + Clear. Confirm dialog guards the
POST. On success, page reloads to reflect the new state.
CSRF via X-CSRFToken header from the csrftoken cookie (Django
middleware sets this). Two new tests lock in the 'only unpaid' +
'admin-only' contracts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-ups on Task 5:
1. Chevron rotation — Bootstrap 5 collapse sets aria-expanded="false" on
the toggle when collapsed; two CSS rules off that attribute rotate
the chevron -90deg with a 150ms transition. No JS needed.
2. test_group_by_type now asserts groups[0]['label'] == 'Bonus', locking
in the descending-|net_sum| ordering promise (|800| > |100|).
60/60 tests still pass.
Adds _group_adjustments helper that buckets a flat queryset by type or
by worker_id, with signed net_sum (+ for additive, - for deductive)
and descending-magnitude ordering so the biggest-impact bucket sits
at the top.
Template branches on adj_groups: grouped view renders one <tbody>
per group with a Bootstrap-collapse-driven header row, wrapping
include of _adjustment_row.html for the actual rows (no duplication).
Flat view is the default when group_by is empty.
By Type headers get a 4px left-border accent in the matching badge
colour so grouped rows visually echo the badges below them.
Attribute-selector based ([data-type=Bonus] etc.) so the
CSS stays self-descriptive without per-type class explosion.
Adds |money_abs template filter for signed render ('-R 100.00' in
the template becomes money_abs(-100) -> '100.00' after the caller
emits its own sign; avoids 'R -100.00' which reads wrong).
Two new tests lock in the bucket structure + net_sum signing for
both axes. Tests 58 -> 60. url_replace template tag already shipped
in the CP1 pagination fix - reused here for the toggle hrefs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The team-filter test was passing regardless of the subquery pattern
because each worker was on only one team — no cardinality to inflate.
Fixture now puts both workers on both teams so a naive
worker__teams__id__in filter would return 6 rows (2 teams × 3
adjustments). The type-filter test now passes BOTH Bonus AND Deduction
so it exercises the multi-select code path (not just a single value).
Both assertions use adj_total_count (.count() at queryset level) so
regressions blow up at aggregation rather than just the paginator page.
Type / worker / team / status / date filters, sort, stats, pagination.
Subquery pattern on the team filter avoids M2M JOIN inflation
(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter
come later (Tasks 5/6/7).
Konrad's Checkpoint-1 feedback:
'Inside the all time projects table, can we have a column with the
last transaction date for a project? It will make it easier to find
data for projects. It is nice to have the filter, but you can still
skip around looking for when the last transaction was.'
Added a 'last_activity' entry to each alltime_projects row in
_build_report_context — computed as max(WorkLog.date) grouped by
project name (respects the same project_ids/team_ids filters already
applied to all_time_logs). Rendered in both the on-screen report
(report.html) and the PDF (report_pdf.html) as a new 'Last Activity'
column sitting between 'Start' and 'Working Days'.
Existing ChapterOneEnrichmentTests extended with a last_activity
assertion locking in the 'most recent log date' semantics.
No other tests touched. 47/47 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Checkpoint 1 second-round UX feedback (Konrad, 2026-04-23):
(1) "The until option must be auto filled (and used for single month) and
the from date must be optional — this makes more sense and less clicks
if the user wants to eg check the last 3 months."
→ Inverted the month pickers. "Until" is now the always-filled anchor
(defaults to URL to_month, falling back to the current YYYY-MM when no
filter is set). "From (optional)" is the disclosure; blank = single
month (JS submits from_month = to_month). Visual order swapped so
Until sits on the left as the primary action. Matches the admin mental
model: "I want data ending now, maybe going back N months."
(2) "Is it possible to show only teams and projects that has transactions
within the selected dates — filter out teams and projects that has no
log for any of the dates chosen?"
→ The pill pickers AND the cross-filter (project_team_pairs_json) are
now scoped to the current date range. A team/project with zero logs in
the window doesn't clutter the lists. The (project_id, team_id) pair
map follows the same rule — cross-filter disables options that never
paired inside THIS window.
Guarantee: entries that are currently in the URL's ?project= / ?team=
selection are always unioned back in, so the user's own picks can
never disappear from the list even when they'd otherwise be out of
scope (e.g. picking a project, then narrowing the date range to a
period with no logs on that project).
Design-doc note at lines 108-112 of 2026-04-23-inline-filters-design.md
originally said "Scope = entire history" — Konrad's real-usage feedback
overrides that decision. Will be recorded in the Task 6 "Shipped" block.
Tests: two new ones lock in the behaviour —
- test_pickers_and_pairs_are_date_scoped: out-of-range project/team
absent from both the picker lists and the pair map
- test_url_selected_projects_survive_even_out_of_range: URL selection
unioned in regardless of date window
Plus existing 3 tests still green. 47/47 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 1 set context['project_team_pairs_json'] = json.dumps(pairs), then the
template rendered it with |json_script — which also calls json.dumps on the
value. Result was a JSON-encoded string-of-a-string in the <script
id="projectTeamPairs"> tag, so JSON.parse() returned a string (not a list)
and the pill-popover IIFE died on pairs.forEach(...). Symptom: all three
filter pills clickable but unresponsive.
Fix: pass the raw Python list; let |json_script own the serialisation (the
established pattern for team_workers_map_json and the other *_json keys on
the payroll dashboard).
Tests updated to read the raw list from resp.context. Added an end-to-end
regression test that extracts the rendered <script id="projectTeamPairs">
payload and asserts JSON.parse() would return a list (not a string) —
catches any future regression of this class even if the test suite and the
view drift apart.
Verified in the browser: all three pill popovers now open on click and
Choices.js lazy-initialises correctly for projects/teams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Serialises distinct (project_id, team_id) pairs from WorkLog as JSON on
the generate_report context. The upcoming pill-popover JS (Task 4 of the
inline-filters plan) uses this to hide teams that haven't worked on a
selected project (and vice versa) without any extra HTTP round-trips.
Scope: entire history (not the report date range) — cross-filter is about
data possibility, not data shown in this period. Filters out NULL
project or team (can't cross-filter on NULL).
2 tests cover: key is populated with correct pairs; NULL-team logs don't
leak into the pairs list.
No visible behaviour change — template doesn't consume the new key yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
project_id/team_id become project_ids/team_ids (list[int] or None).
Every internal filter uses the __in lookup; M2M filters use the
id__in subquery pattern documented in CLAUDE.md's Django ORM gotcha.
generate_report and generate_report_pdf switch to request.GET.getlist.
Old URL ?project=1 still works - getlist returns a single-element list.
Return dict gains six hero-KPI keys: current_outstanding, current_as_of,
company_avg_daily, company_avg_monthly, company_working_days,
team_project_activity - ready for the template restructure in Tasks 9-12.
Tests: 3 new multi-filter tests; existing inflation tests updated to the
new kwarg names. 42 total, all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chapter I of the executive report needs per-project working-day count and
avg rand per working day. Instead of modifying the shared _get_labour_costs
helper (used by other sections with different column sets), enrich the
output INSIDE _build_report_context: wrap the raw result and add
working_days (distinct work-log dates per project) and avg_per_working_day
(total_cost / working_days, null-safe).
Also attaches start_date from the Project model (may be None if not set).
1 test added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chapter IV pivot backend: for each (team, project) pair in the given
work-logs queryset, counts distinct work-log dates. Returns columns
(projects), rows (teams with cell dict), column totals, and grand total
ready for direct template rendering.
Logs with NULL team or NULL project are excluded (can't pivot on NULL).
Teams/projects with zero activity don't appear as rows/columns — keeps
the pivot tight.
Tests cover shape, cell counts, row+column+grand totals, and
zero-activity team omission.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tweaks from code review on 82594fa:
1. The sort `key=lambda r: -r['amount']` placed NEGATIVE amounts
(rare but possible: a project with only a deductive adjustment)
AHEAD of larger positive exposures. Swapped to
`key=lambda r: r['amount'], reverse=True` — same runtime, clearer
intent, correct for negatives.
2. test_team_filter_scopes_total only asserted the net total. A
partial scoping regression where the adjustment leaked but netted
to zero would have silently passed. Added two assertions that
by_project has exactly the expected 2 entries and R 500 never
appears in the amount list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hero KPI card 2 needs 'Outstanding NOW' scoped to the report's selected
projects/teams. This helper wraps _compute_outstanding, reshapes the
by_project dict into a sorted list, and exposes the net total for direct
rendering.
Tests cover unfiltered total, project-scoped total, and team-scoped
total (including the worker__teams subquery path for adjustments).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Computes company-wide avg daily and monthly labour cost for the
executive report's hero KPI band (cards 3 and 4). Denominator is
working days (distinct work-log dates), not calendar days — true
cost-of-a-productive-day metric per design section 2.
Monthly = daily * 30.44 (the 365.25/12 month-length approximation,
which keeps annualised totals correct on average).
Tests cover: empty DB returns zero, known values with assertAlmostEqual
for the 30.44 multiplication, and that multiple workers on one date
count as 1 working day (not N).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported: when creating a new team or project from the friendly UI
(/teams/new/ or /projects/new/), the Supervisor dropdown only lists
is_staff / is_superuser accounts. Users who should be eligible to
supervise (e.g. eendman, supervisor_smoke) are invisible in the
picker even though they are active.
Root cause:
core.forms._supervisor_user_queryset filtered to
is_active=True AND (is_staff OR is_superuser OR groups__name='Work Logger')
That was strictly more restrictive than the app's own permission
helper is_supervisor(user) in views.py, which grants supervisor
powers to ANYONE assigned to a team/project (via the team.supervisor
FK or project.supervisors M2M), regardless of group membership.
On Konrad's dev DB that excluded 2 of 6 active users from the picker
(one in a custom group, one in no group) even though both were valid
supervisor candidates by the permission model.
Fix:
Queryset now returns every active user. The act of assigning a user
to a team/project is what confers supervisor-ness downstream, so
the picker no longer needs a pre-registered allow-list. Inactive
users (is_active=False) remain excluded — the one hard guardrail.
Docstring rewritten to explain the new behavior and why. Stale comment
in TeamForm.__init__ updated to match (the old comment still described
the pre-fix Work-Logger-group requirement).
Tests: 4 new regression tests in SupervisorPickerQuerysetTests:
- regular active user is selectable (the core bug)
- user in an unrelated group is selectable
- inactive user is still excluded (guardrail)
- admin is still selectable (no regression for prior use case)
All 28 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported: when the generate-report page is filtered by BOTH project and
team, every amount in the "Worker Breakdown" and "Payments by Date"
tables blew up by ~100x. Example: Billy Baloyi R 5,400 (correct)
became R 604,800 (wrong, 112x) after selecting Wilkot + Civils One.
Root cause:
_build_report_context chained `records.filter(work_logs__project_id=X)
.distinct().filter(work_logs__team_id=Y).distinct()`. In Django's ORM
each chained M2M filter creates a SEPARATE JOIN alias on
core_payrollrecord_work_logs, so the SQL produces the cartesian product
of (matching-logs-for-project) x (matching-logs-for-team) rows per
PayrollRecord. A downstream `.values().annotate(Sum('amount_paid'))`
then summed across those duplicated rows - inflating every total by
N * M where N and M are the log counts per record.
Why total_paid_out looked correct: `.aggregate(Sum(...))` wraps the
query in a subquery when distinct() is in play, so it dedupes before
summing. `.values().annotate(Sum(...))` uses GROUP BY on the raw
joined rows and doesn't get that help.
Fix:
Replace chained M2M filters with id__in subquery filters:
records.filter(id__in=PayrollRecord.objects.filter(
work_logs__project_id=X).values('id'))
This keeps the outer queryset JOIN-free, so values().annotate(Sum())
aggregates over distinct records. Same pattern applied to the
adjustments team-filter (worker__teams M2M) for the adjustment
summary.
Tests: 5 new regression tests in ReportContextFilterInflationTests
covering project-only, team-only, both-filters, total_paid_out
invariant, and the adjustment summary path. All 24 tests pass
(19 existing + 5 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>