179 Commits

Author SHA1 Message Date
Konrad du Plessis
bfe4e4d616 docs(ui): design for payroll action-buttons pastel soft-fill
Four buttons at top of /payroll/ currently mix 3 treatments (outline
+ solid btn-primary one-off). Design swaps all 4 to a unified
.btn-action-soft base class with per-button colour modifiers
(Lookup=blue, Pay=amber, Add=green, Price=mauve). Reuses existing
--badge-*-bg tokens for the Add + Price buttons; adds 2 new token
pairs for Lookup + Pay. Removes the shadow-sm / btn-sm / fw-bold
one-offs — the new class handles sizing + weight.

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

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

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

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

Tests: 69/69.

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

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

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

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

Tests: 69/69.

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

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

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

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

Tests: 69/69.

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

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

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

Tests: 69/69.

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

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

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

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

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

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

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

Tests: 69/69.

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

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

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

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

Tests: 69/69.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:51:23 +02:00
Konrad du Plessis
8f495064c3 docs(perf): fix CLAUDE.md runbook step 3 causal chain
Final whole-impl review catch on the Perf Quick-Wins Pass. Step 3
said "the mtime of the collected copy under staticfiles/ stays the
same, so the token doesn't bump." That's backwards — the token is
read from static/css/custom.css (the SOURCE file), so editing the
source DOES bump the token and Cloudflare correctly misses on the
next request. What actually breaks is the VM's response to the miss:
Apache serves stale bytes from staticfiles/ because collectstatic
hasn't refreshed the collected copy. New URL, old bytes behind it.

Rewording makes the causal chain correct so future Gemini/Claude
debugging "CSS change deployed but old file still shows" reaches
the right conclusion (run collectstatic on the VM) via the right
reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:40:43 +02:00
Konrad du Plessis
167c8216fe fix(perf): Coalesce project FK in adjustment aggregates (dedupe)
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>
2026-04-24 01:30:25 +02:00
Konrad du Plessis
61c485ffcf perf(payroll): batch project-loop N+1s + quick-wins pass closing summary
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>
2026-04-24 01:16:37 +02:00
Konrad du Plessis
2731ac9ffd fix(dev): simplify Debug Toolbar wiring (review followups)
Three followups on 7075269:

- config/urls.py: drop dead try/except ImportError fallback.
  The settings.py gate already guarantees debug_toolbar is
  importable before we reach this line, so the except branch
  was unreachable and the re-import of include/path was
  redundant (both imported at top of file).

- config/settings.py: SHOW_TOOLBAR_CALLBACK now returns True
  unconditionally. The triple gate passed at settings-load time,
  so re-checking DEBUG and _IS_DEV inside the lambda was
  redundant. Comment corrected — the callback has nothing to
  do with "stale cached pages".

- requirements.txt: inline comment noting django-debug-toolbar
  is dev-only and gated.

No behavioural change. Tests: 68/68.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:55:44 +02:00
Konrad du Plessis
7075269a07 chore(dev): add Django Debug Toolbar (dev-only, DEBUG+USE_SQLITE gated)
Double-gated install: only loads when DEBUG=true AND USE_SQLITE=true,
never in prod. Lets us profile SQL query counts on the dashboard and
payroll pages before attacking N+1 hotspots.

requirements.txt adds django-debug-toolbar==6.0.0
config/settings.py conditionally appends to INSTALLED_APPS + MIDDLEWARE
config/urls.py conditionally includes __debug__ route

No behavioural change to production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:47:19 +02:00
Konrad du Plessis
0c42cde4ff fix(perf): CLAUDE.md runbook + drop dead var in cache-bust test
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>
2026-04-24 00:38:52 +02:00
Konrad du Plessis
16d4399c28 perf(cache): mtime-based CSS cache-bust token
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>
2026-04-24 00:31:05 +02:00
Konrad du Plessis
bcd0112687 docs(perf): task-by-task plan for Quick-Wins Pass A
Four tasks: mtime cache-bust token + tests; install & gate Django
Debug Toolbar dev-only; profile + fix N+1 on /; profile + fix N+1 on
/payroll/ (all four tabs) with before/after summary in the final
commit message.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:26:42 +02:00
Konrad du Plessis
d1490c4639 docs(perf): design for Quick-Wins Pass A
Short design covering four changes: mtime-based CSS cache-bust token,
Django Debug Toolbar (dev-only) for profiling, N+1 fixes on Dashboard
and Payroll pages, and a before/after measurement in the commit message.
Scope is deliberately tight — plan B (template splitting) and plan C
(full audit) are deferred until plan A evidence lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:23:48 +02:00
Konrad du Plessis
503eff67a0 docs(claude): update CLAUDE.md for session's features + newly-learnt gotchas
Audit revealed several stale / missing items:

1. Wrong CSS selector for light theme — said `:root.light`, actual is
   `[data-theme="light"]`. Task 2 of Adjustments caught this in the
   implementer's self-review; the doc didn't get updated. Now correct.

2. `_report_config_modal (partial)` removed from templates list — the
   file was deleted in commit 1d00a3a (retire modal).

3. `_adjustment_row.html` added to templates list — new partial, shared
   by flat + grouped views on the Adjustments tab.

4. `format_tags.py` now lists all 5 filters: money, money_abs, type_slug,
   url_replace, dictlookup (was just 'money').

5. New narrative paragraphs for:
   - Inline Filters on /report/ (pill popovers, cross-filter, JSON gotcha)
   - Adjustments tab (filter pills, badge palette, group-by, bulk delete)
   - _delete_adjustment_with_cascade helper (shared by single+bulk)
   - Pill-popover filter pattern (.adj-hidden-inputs + OK-rewrites-inputs)

6. Two new schema name-drifts: PayrollRecord.amount_paid (not total_amount
   / days_worked); Loan.principal_amount (not principal). Both bit an
   implementer this session when writing test fixtures.

7. Two new Coding Style rules in the top section:
   - Multi-line {# #} template comments are INVALID — use {% comment %}
     (bit us 4× in this session). With caveat that literal {# or #} can't
     appear inside a {% comment %} block either.
   - Duplicate id= attributes silently steal event handlers — grep before
     assigning (caught adjSelectAll collision between table header + modal).

Now 707 lines, 24 sections. Future sessions should have the context to
avoid the mistakes this session made.
2026-04-24 00:00:07 +02:00
Konrad du Plessis
6f66faf06a feat(adjustments): filter bar v2 — unify all 5 filters as pills + density pass
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.
2026-04-23 22:00:27 +02:00
Konrad du Plessis
620f433d06 docs(adjustments): design for filter-bar v2 (pill unification + density) 2026-04-23 21:54:13 +02:00
Konrad du Plessis
672c32cfb6 ux(adjustments): drop the per-row Delete button — Edit modal handles it
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.
2026-04-23 19:57:00 +02:00
Konrad du Plessis
3fe3e5aa01 fix(adjustments): group-by uses full filtered queryset + Apply keeps group mode
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).
2026-04-23 19:37:57 +02:00
Konrad du Plessis
269d86259a docs(adjustments): Shipped block on design doc + CLAUDE.md URL routes
Captures the 11-task implementation, 5 deviations (biggest: the
CP1 pivot from Choices.js chip-multiselect to popover-checkbox
filter UX after Konrad flagged the chip pattern as intrusive),
14 new adjustments-tab tests, and total code churn (~+1400 lines).

CLAUDE.md URL Routes table gains two rows so future sessions
surface /payroll/?status=adjustments and the bulk-delete endpoint.

Feature ready for final whole-feature code review + batched push.
2026-04-23 19:26:46 +02:00
Konrad du Plessis
9bb9ede300 polish(adjustments): empty-state card with recovery CTAs + sticky bar check
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.
2026-04-23 19:23:39 +02:00
Konrad du Plessis
7b71048376 feat(adjustments): sortable column headers with URL state
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.
2026-04-23 19:20:49 +02:00
Konrad du Plessis
c851b49dea feat(adjustments): date picker single/range toggle + preset quick-buttons
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.
2026-04-23 19:17:19 +02:00
Konrad du Plessis
6905703492 feat(adjustments): Team -> Workers cross-filter in the popover JS
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.
2026-04-23 19:13:10 +02:00
Konrad du Plessis
4c3e90f2a7 fix(adjustments): bulk-delete cascades through Loan + Overtime (critical)
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.
2026-04-23 19:06:54 +02:00
Konrad du Plessis
5f2e6d8c74 fix(adjustments): rename Select-All header checkbox id to avoid collision
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.
2026-04-23 18:57:43 +02:00
Konrad du Plessis
03f177e7d0 feat(adjustments): bulk-delete unpaid rows + floating action bar
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>
2026-04-23 18:51:54 +02:00
Konrad du Plessis
e5d06f91e5 polish(adjustments): rotate chevron on collapse + lock in magnitude ordering
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.
2026-04-23 18:42:19 +02:00
Konrad du Plessis
0862805623 feat(adjustments): group-by type / worker + collapsible headers
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>
2026-04-23 18:34:07 +02:00
Konrad du Plessis
4c1cdb6210 fix(adjustments): remove multi-line {# #} comment in worker cell
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.
2026-04-23 17:12:05 +02:00
Konrad du Plessis
4f15e4bd5f feat(adjustments): replace Choices.js chip-multiselect with popover-checkbox filters
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).
2026-04-23 17:07:50 +02:00
Konrad du Plessis
b59eb313c0 fix(adjustments): row actions use modals + project link goes to History tab
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).
2026-04-23 16:55:42 +02:00
Konrad du Plessis
e088192103 fix(adjustments): convert multi-line {# #} comments to {% comment %} blocks
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.
2026-04-23 16:19:38 +02:00
Konrad du Plessis
06b3315641 fix(adjustments): pagination URL + filter label accessibility + teams.first N+1
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).
2026-04-23 15:45:41 +02:00
Konrad du Plessis
b450bd3c39 feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions
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.
2026-04-23 15:34:09 +02:00
Konrad du Plessis
89f109afb4 test(adjustments): strengthen subquery + multi-filter tests
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.
2026-04-23 15:22:19 +02:00
Konrad du Plessis
10d381e2ae feat(adjustments): backend filter branch for ?status=adjustments
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).
2026-04-23 15:12:19 +02:00
Konrad du Plessis
a20a025d46 feat(adjustments): add semantic badge palette + sticky filter bar / group-header / bulk-bar styles 2026-04-23 15:00:53 +02:00
Konrad du Plessis
97d8a69212 feat(adjustments): add |type_slug template filter for badge class naming 2026-04-23 14:54:50 +02:00
Konrad du Plessis
cf82215511 docs(adjustments): add task-by-task implementation plan
11 tasks + 1 hard-pause checkpoint after Task 4. Targets ~960 LOC
across core/views.py, payroll_dashboard.html, _adjustment_row.html
(new), format_tags.py, custom.css, and tests.py.

Derived from docs/plans/2026-04-23-adjustments-tab-design.md (commit
12edafa) — execution plan for subagent-driven development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:52:06 +02:00
Konrad du Plessis
54080a3e0a docs(inline-filters): append Shipped 2026-04-23 block to design doc
Captures the 6 deviations from the original design (each with driving
feedback + commit SHA), the 5 non-design polish commits, and the test
delta (42 → 47 passing). Keeps the design doc as the first-read for
understanding the feature while preserving decision history from the
Checkpoint-1 iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:40:13 +02:00
Konrad du Plessis
c26d2e07d0 fix(report): auto-open Choices dropdown + make date hint readable
Two small Checkpoint-1 polish items from Konrad:

(1) 'The projects and team dropdowns open empty, and only after you
    click in the text box do the options appear.'

    Choices.js's default open state has the dropdown closed (is-active
    absent) — the user normally has to click the search input to reveal
    options. But a pill click clearly means 'show me the list,' so we
    now call `showDropdown(true)` on the Choices instance right after
    the popover opens. Deferred via setTimeout(0) so it runs AFTER any
    cross-filter destroy/recreate has settled in the same tick.

(2) 'In the date selection I accidentally saw there is text that is way
    too dark below the From date selector — "Leave blank for a single
    month".'

    The inline style was `opacity: 0.75` on top of Bootstrap's default
    `.form-text` colour (inherits --bs-secondary-color — very dark on
    our dark theme). Replaced with `color: var(--text-tertiary)` + full
    opacity so the hint is readable in both dark and light themes.
    This matches CLAUDE.md's convention of always using theme tokens
    for text that should sit in the 'hint' legibility band.

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