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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
Profiled /payroll/ under Django Debug Toolbar and confirmed heavy N+1
patterns in the shared payroll_dashboard() code path (shared by all four
tabs). Main wins:
1. outstanding_project_costs loop + project_chart_data loop previously
fired one PayrollAdjustment SELECT per project (outstanding) and per
(project x 6 months) (chart) — ~42+7 = 49 round-trips on a 7-project
dataset. Replaced with 4 GROUP BY aggregate queries keyed by
project_id / (project_id, month), merged in Python.
2. Per-worker Loan.exists() and get_worker_active_team() checks inside
the workers_data loop — pre-computed into a set + dict once, up-front.
3. team_workers_map loop used `team.workers.filter(active=True)` which
bypasses the prefetch cache; switched to a Prefetch(to_attr=) that
returns already-filtered active workers, dropping 6 duplicate SELECTs.
4. Adjustments tab: reused `paginator.count` for the "Total" stat card
(was firing a second identical COUNT(*)) and reused existing
all_workers / all_teams querysets instead of re-querying for the
filter popovers.
5. Hoisted shared lookups (all_workers, active_projects_list, chart
date-window) so duplicate ordering-identical SELECTs from multiple
call sites collapse into a single evaluated queryset.
===== Quick-Wins Pass A - before/after query counts =====
/ 15q, no duplicates (healthy, no fix)
/payroll/?status=pending 157q (before) -> 26q (after), 0 dupes
/payroll/?status=history 157q -> 26q, 0 dupes
/payroll/?status=loans 158q -> 27q, 0 dupes
/payroll/?status=adjustments 168q -> 34q, 0 dupes
CSS cache-bust token (0c42cde) is still expected to be the biggest
user-felt improvement of this pass — custom.css now holds at
Cloudflare's edge for its full 4h TTL instead of being re-fetched
from the VM on every page load. The payroll-dashboard query-count
cut (~131 SQL round-trips trimmed per render) is a meaningful
admin-UX latency win on top of that, especially under MySQL over
the Flatlogic network.
WeasyPrint confirmed still lazy-imported.
Test suite: 68/68.
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>
Konrad's feedback on the shipped Adjustments tab: "this interface
layout is very ugly. And the selection dropdown menus text is a bit
large." Plus: the 'Show as' toggle sits too close to the filter bar.
Design doc: docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md
Changes:
1. All 5 filters become pill-popovers of identical shape
- Type / Workers / Teams: unchanged (already pills)
- Status: was <select> + <label>, now pill → popover with 3 radios
- Date: was inline inputs + preset links + '...' toggle, now pill →
popover with Single/Range mode toggle + picker(s) + presets + OK/Cancel
- Pill labels update to 'Status: Unpaid' / 'Date: 24 Apr 2026' /
'Date: 20 Apr – 26 Apr 2026' for at-a-glance state
- Apply + Clear pushed to right end via .adj-apply-group (margin-left: auto)
2. Popover density pass
- .adj-checkbox-list / .adj-radio-list font-size 0.8rem (~12.8px)
- .adj-cb-row padding trimmed to 0.15rem 0.25rem
- Checkbox visual size 0.9em
- Popover footer buttons 0.75rem font, 0.25rem 0.6rem padding
- Popover max-width 360px (was ~420px)
- 7-type popover drops from ~320px tall to ~240px
3. Spacing fix above 'Show as:' toggle
- .adj-groupby-toggle now has margin-top: 1rem + margin-bottom: 0.75rem
- Clear visual separation from the sticky filter bar
4. Filter-bar alignment
- align-items: center (was end, now all children are same height)
- Gap tightened to 0.5rem
Backend contract unchanged (query params identical). No test changes
(65/65 still pass). Committed popover JS uses the same
.adj-hidden-inputs pattern as the checkbox filters — Status + Date
each have their own commit/revert logic that rewrites their hidden
inputs on OK. XSS-safe throughout (replaceChildren() + textContent,
no innerHTML with user data).
Gated the generic checkbox-popover OK/Cancel handler to
['type', 'worker', 'team'] so the new Status/Date popovers aren't
accidentally re-committed via commitCheckboxes.
Konrad's feedback: the red × Delete button on each unpaid row was
redundant — the Edit Adjustment modal already has a Delete action
inside it, so users never need a second entry point.
Removed: the .adj-delete-btn button from _adjustment_row.html and its
now-dead DOMContentLoaded handler in payroll_dashboard.html (~15 lines
of JS). Unpaid rows now show Preview + Edit only.
Bulk-delete is unaffected: the floating action bar + per-row checkboxes
remain as the fast path for deleting many rows at once. Single-row
delete flows through the Edit modal's existing delete button.
65/65 tests still pass.
Two final-review follow-ups from the whole-feature code review:
1. Important: group-by was bucketing adj_page.object_list (the paginated
50-row slice), making 'By Type' group headers show misleading per-page
totals once filters returned >50 rows. Konrad's current data is under
the threshold, but the UI promised whole-filter totals.
Fix: group_by runs on the full filtered queryset (list(adjustments))
BEFORE pagination. Template already branches on adj_groups, so we now
additionally hide the pagination nav when grouped — the group headers
act as their own navigation and their counts/sums reflect the whole
filter not just one page.
2. Minor: Apply after picking 'By Worker' silently reset to Flat view
because the filter form had hidden inputs for sort/order but not
group_by. Added the missing <input type='hidden' name='group_by'>
so the toggle round-trips across Apply.
65/65 tests still pass (no test changes — the previous tests' fixtures
are all <50 rows so neither the bug nor the fix shows up there, but
both behaviours are now correct).
No-rows case now renders a proper card with two recovery paths:
Clear filters (href back to ?status=adjustments) and Add adjustment
(opens the existing #addAdjustmentModal — no new JS).
Sticky filter bar (Task 2 CSS) verified functional — no ancestor
clipping or overflow:hidden in the adjustments tab block.
4 sortable columns: Date, Worker, Amount, Status. Click cycles
desc -> asc -> desc. Click a different column -> resets to desc.
Keyboard Enter / Space also works (role=button + tabindex=0).
The sort/order state lives in hidden inputs inside the adjustments
filter form, so the JS just mutates those and .submit()s — the sort
then piggy-backs on the same GET the filter bar uses, and the URL
retains it across pagination. Backend sort_map (Task 3) already
whitelists the allowed columns, so no SQL-injection surface.
Arrow icons reflect state: fa-sort (inactive), fa-sort-down (desc),
fa-sort-up (asc). Active column gets .sorted class for stronger
arrow opacity (CSS already shipped in Task 2).
No backend changes, no new tests — the existing 65 tests cover the
sort contract from the URL.
Single by default (one <input> + '...' toggle reveals the second).
In single mode the JS mirrors From into the hidden To on every
change, so form submit sends adj_date_from=adj_date_to=X for an
exact-day filter on the backend (contract unchanged).
Four presets: Today (single), This week (Mon-Sun range), This month
(1st to last, range), Clear. Presets auto-switch mode so users see
what was populated.
On page load, range mode is inferred from the URL: if both dates
present AND differ -> range mode; else single mode. That way a
bookmarked range URL still shows both pickers.
No backend changes, no new tests — the 8 existing adjustments tests
already cover the from/to contract shape.
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.
Code-review follow-up on Task 6:
Task 4 gave the Adjustments table's 'select all' header checkbox the
id 'adjSelectAll' — but the Add-Adjustment modal already had an
<a id='adjSelectAll'> Select-All anchor (and a matching JS click
handler on line 1823). Duplicate IDs are invalid HTML, and
getElementById returns the first occurrence in DOM order — so the
modal's Select-All handler silently started binding to the table
checkbox instead of its intended anchor. Never reported because
neither element was automated-tested.
Rename the table checkbox id to #adjTableSelectAll and update Task 6's
bulk-select JS to match. The modal's handler now correctly binds to
its own anchor again.
62/62 tests still pass — behaviour is template-driven UI, no backend
change.
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>
Same bug as e088192 — I wrote a 4-line {# ... #} block in the Fix-A
prompt for the worker-name cell, forgetting Django's single-line
comment constraint. The implementer reproduced it faithfully and it
shipped. The comment text flooded every row's Worker column.
Fix: collapse the comment to a single-line {# #} on the row above
the <td>, same style as the other row comments.
Checkpoint-1 feedback from Konrad: the Choices.js chip pattern for
Type / Workers / Teams was visually intrusive once multiple options
were picked — the filter bar dominated the viewport.
Replacement: each filter is now a compact pill (like Feature 1's
inline-filter pills on the report page) that opens a popover with a
scrollable checkbox list, live-search, and Select All / Invert /
Clear action buttons. OK commits the pending state into hidden form
inputs; Cancel / Esc / click-outside revert. The existing Apply button
still submits the form normally.
Reuses Feature 1's .filter-pill / .filter-popover CSS vocabulary —
only new CSS is a scrollable checkbox-list rule and a pill-count
badge style. No new modals. Choices.js CDN stays loaded (other
tabs still use it).
Checkpoint-1 feedback from Konrad — three row actions on the Adjustments
tab were breaking his muscle memory vs the Pending tab:
1. Worker name used to navigate to /workers/<id>/. Now opens the Worker
Lookup modal using the existing .worker-lookup-link handler already
bound on the dashboard — zero new JS.
2. Eye icon on PAID rows used to navigate to /payroll/payslip/<pk>/. Now
opens the same #previewPayslipModal that unpaid rows use (via the
existing .preview-payslip-btn handler). The 'Paid #N' green badge in
the Status column still links to the historical payslip detail page,
so both entry points coexist.
3. Project name used to open the Profile tab of the project detail page;
now includes the #history URL fragment so the History tab is active.
Added a tiny DOMContentLoaded helper in projects/detail.html that
activates whatever tab the hash points to — generalised so any
future deep-link works (#history, #supervisors, #teams, #workers).
Checkpoint-1 bug: the row partial's docstring used a multi-line {# ... #}
block. Django's single-line comment syntax doesn't match across newlines,
so the opening {# and closing #} were treated as literal text and spilled
into every rendered row — flooding the table body with the raw comment.
Worse, the browser partially parsed the literal <tr> inside the comment
text as an HTML tag, breaking the table layout entirely.
Fix: moved the multi-line docstring into a {% comment %}...{% endcomment %}
block and compressed three other multi-line {# #} blocks to single lines.
Also tripped on a second foot-gun: you can't put literal {# or #} inside
a {% comment %} block — Django's tokenizer still sees them as a nested
comment marker. Removed the meta-note about "{# ... #} is single-line
only" from inside the comment block.
All 58 tests pass. Table renders correctly with all 10 columns + type
badges + row actions visible.
Three code-review fixes:
1. Pagination links were building ?status=...&page=2&page=3 on every
click because the template appended &page= onto an already-
serialised querystring. Added a reusable url_replace template tag
that replaces a single key (pre-empts Tasks 5 / 9 which also
need it) and piped the pagination hrefs through it. Added
rel=prev/next + aria-label on the <a> tags while we were here.
2. Filter-bar labels had no for= attribute, so screen readers
announced the native <select> with no name. Added id= on each
select/input and matching for= on each label. Also gave the
Select-all checkbox an aria-label (title= alone is not an
accessible name).
3. Row template's {% with team=adj.worker.teams.first %} issued a
fresh ORDER BY ... LIMIT 1 query per row despite the view's
prefetch_related('worker__teams'). Swapped to {% with
teams=adj.worker.teams.all %} which DOES use the prefetch cache,
bounding the Team column at 0 extra queries (was ~50 per page).
Reuses existing modals (#editAdjustmentModal, delete confirm flow) —
zero new JS for row actions. Choices.js lazy-inits only when the tab
is active. Stats row scoped to filter set. Subquery pattern on team
filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come in
Tasks 5/6/7.