125 Commits

Author SHA1 Message Date
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
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
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
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
97d8a69212 feat(adjustments): add |type_slug template filter for badge class naming 2026-04-23 14:54:50 +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
Konrad du Plessis
f6975bfb2f feat(report): 'Last Activity' column in All Time Projects table
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>
2026-04-23 13:30:56 +02:00
Konrad du Plessis
1d00a3a68f refactor(report): retire the Generate Report modal (Task 5)
Per the plan at docs/plans/2026-04-23-inline-filters-plan.md Task 5, the
now-redundant configuration modal goes away:

core/templates/core/_report_config_modal.html → deleted (160 lines)
core/templates/core/index.html:
  - Dashboard 'Generate Report' tile → plain link to
    /report/?from_month={% now 'Y-m' %}&to_month={% now 'Y-m' %} so the
    click lands on the report page with the current month pre-filled.
  - Modal {% include %} at EOF removed.
core/templates/core/report.html:
  - Both 'New Report' buttons (header + bottom action bar) deleted;
    comments updated to say the pills ARE the new-report interface.
  - {% include 'core/_report_config_modal.html' %} removed.
  - Stale 'Task 5 will delete...' comment on the Choices.js CDN block
    updated.

Konrad's exact ask (Checkpoint 1 feedback):
  'Does it make sense to have this popup window for reports? Don't you
  think clicking on generate report should just default to current month
  and open the report page where users can adjust report filters?'
  → Yes. The pills do exactly that, one click in.

Verification:
  grep -rn 'reportConfigModal\|_report_config_modal' core/ returns 0 hits.
  47/47 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:27:21 +02:00
Konrad du Plessis
c1937cd89d style(report): shrink '(optional)' hint and tooltip 'Single month select' on Until
Checkpoint-1 polish (Konrad):
  - 'From (optional)' — the parenthetical is now ~50% smaller (0.6rem)
    so the label's primary text dominates, matching his intent of a
    subordinate hint rather than a competing word.
  - 'Until' — adds a small info-circle icon with a Bootstrap tooltip
    reading 'Single month select'. Inline small-font text was my first
    attempt but wrapped to two lines inside the narrow column; the icon
    tooltip keeps the label tidy while the hint is one hover away.
    Bootstrap tooltip auto-init (base.html) handles the binding —
    matches CLAUDE.md's global tooltip pattern.

No functional change. 47/47 tests still green (no view code touched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:12:45 +02:00
Konrad du Plessis
3fa3cdcf35 style(report): swap date picker columns to 'From (optional) | Until'
Konrad's clarification on the Checkpoint-1 UX revision: the visual order
should follow English reading — "from X until Y" reads left-to-right, so
"From" belongs on the left and "Until" on the right. Previous commit
71f8558 placed Until on the left because it's the always-filled anchor,
but that fights the natural sentence order and was confusing.

Optionality is unchanged:
  - Until (right, always filled) = anchor month
  - From (left, optional) = blank means single-month report

No JS change needed — input IDs (popoverFromMonth / popoverToMonth) stay
the same; only column positions in the <div class="row"> were swapped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:07:39 +02:00
Konrad du Plessis
71f8558ff5 feat(report): Until-primary date picker + date-scoped project/team lists
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>
2026-04-23 12:25:45 +02:00
Konrad du Plessis
ffb3ef6800 refactor(report): auto-submit on OK + sticky footer + optional until-month
Checkpoint 1 UX feedback (Konrad, 2026-04-23) surfaced three friction points
that all traced back to the same over-engineered "multi-stage commit" model:

1. When Choices.js opened its dropdown, it covered the popover's OK button.
   User had to click in a thin strip "outside the multi-select but inside
   the dropdown pane" to close Choices.js before OK became reachable.

2. Changing only a project/team didn't light up the global Apply button
   (dirty-state diff bug on multi-selects), and even when it did, clicking
   Apply didn't actually update the report tables. Also the Apply button
   sat at the far right of the pill strip — easy to miss on desktop.

3. Single-month reports required changing BOTH From and To pickers; for a
   low-frequency admin tool, that's a tax on the most common flow.

Instead of patching three bugs, collapsed the entire pending/dirty/Apply
model. Each popover's OK now:
  - Rebuilds the URL from its OWN inputs only (keeping other filters intact)
  - Navigates → full SSR page reload → report re-renders
The user reads the result of their change immediately; there's no "did I
remember to click Apply?" step.

Side-effect wins:
  - 'dirty state', 'pending state', 'updateAllPillsDirty', 'revert...',
    cross-filter auto-removal, and the toast system all become unnecessary.
    Net -187 lines across template + CSS.
  - The bug from (2) self-disappears because there's no dirty-diff step.
  - Sticky popover footer (position: sticky; bottom: 0; z-index: 2) pins
    OK to the popover edge even when Choices.js expands — solves (1).
  - The To month picker is labelled "Until (optional)" with "Leave blank
    for a single month" hint. Blank on submit → to_month = from_month.
    Single-month URLs round-trip with a blank To input (so the form and
    the data agree).

Cross-filter preserved: on popover open, the OTHER pill's URL selection
still disables invalid dropdown options. Just no runtime auto-remove —
unnecessary because the next OK submits and the server takes over.

Tested in the browser via preview MCP:
  - All three pills open popovers on click
  - Range URL shows both month pickers filled
  - Single-month URL shows To blank
  - OK with blank To → navigates to from_month=X&to_month=X
  - Sticky footer keeps OK in viewport when Choices.js is open
  - 45/45 tests still pass (no backend contract change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:48:53 +02:00
Konrad du Plessis
5c4162d2eb fix(report): stop double-encoding project_team_pairs_json for pill cross-filter
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>
2026-04-23 10:20:58 +02:00
Konrad du Plessis
6d2c72f6d1 JS: pill-popover interactive module + Choices.js CDN in report.html
Main interactive layer for the inline-filters feature. Appends two
blocks to report.html (inside {% block content %}, before the final
{% endblock %}):

1. Choices.js CDN <link> + <script> (admin-only gated, SRI-hashed) —
   moved here because Task 5 will delete _report_config_modal.html,
   which previously loaded the CDN. Keeping this on the report page
   directly means the pills stay functional after modal retirement.

2. A scoped IIFE that wires up the three filter pills into an
   interactive, state-managed UI:
   - Click pill -> open popover (lazy-inits Choices.js on first open)
   - Click outside / Esc / other pill -> close
   - OK commits popover's local edits into pending state; dirty pills
     get the orange outline + pulsing dot; Apply button slides in
   - Cross-filter: picking projects auto-removes now-invalid teams
     with toast notice ("Removed Team X — no logs on selected
     projects"), and vice versa. Scope = entire history.
   - Apply -> rebuilds querystring from pending state + navigates
     (full page reload, same URL scheme as the retired modal)
   - Reset -> reverts all pills to URL-current values

XSS-safe throughout: textContent and createElement; no innerHTML with
user data. Matches the pattern in base.html's work-log-payroll modal
from the Work-Log Payroll feature.

Graceful fallback: if Choices.js CDN fails to load, the module bails
early with a console warning; native <select multiple> still works
inside the popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:56:13 +02:00
Konrad du Plessis
acbad1558e Template: interactive filter-pill markup + popover shells
Replaces the three static filter pills with clickable buttons and
inline popover shells below each one. Popovers remain hidden by
default (hidden attribute) — the JS module in Task 4 will wire up
open/close, dirty state, and Apply behaviour.

Structure per pill:
- .filter-pill-wrap (position-relative container)
  - <button class="filter-pill filter-pill--editable" data-filter="...">
    with chevron indicating clickability
  - <a class="filter-pill__x"> (existing × clear-filter link, preserved)
  - .filter-popover (the editable widget — date picker for the Date
    pill, Choices.js multi-select for Projects/Teams pills)

Apply + Reset buttons sit in .apply-filters-group at the right end,
initially hidden. A <div id="filter-toast-container"> is pre-placed
for the cross-filter auto-removal notices.

Three json_script blocks embed the data the JS needs:
- projectTeamPairs: (project_id, team_id) pairs for cross-filter
- urlSelectedProjectIds / urlSelectedTeamIds: current URL state for
  dirty diffing + reset

No visible behaviour change yet (no CSS, no JS). Page renders same
as before until Tasks 3-4 light it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:41:46 +02:00
Konrad du Plessis
06f2e71d87 Backend: add project_team_pairs_json context for inline-filter cross-filter
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>
2026-04-23 09:35:36 +02:00
Konrad du Plessis
a27da90c58 Report PDF: mirror the executive redesign (hero band + 4 chapters)
PDF template updated to match the new HTML structure: cover block
with static filter labels, hero KPI band (4 stacked 2x2), Chapter I
lifetime (Projects + Teams full-width, Projects now with Start /
Working Days / Avg-R-per-Working-Day columns), Chapter II selected
period (existing Total Paid Out hero + Loans/Advances pairs +
Labour Cost + Payments/Adjustments), Chapter III worker breakdown
(heading renamed), Chapter IV team x project pivot (new).

THIS YEAR section dropped per design doc section 3 (redundant with
All Time + Selected Period).

Same _build_report_context helper so HTML and PDF cannot drift in
data. All numbers identical. WeasyPrint-friendly: absolute units,
single-column body, no Font Awesome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:12:56 +02:00
Konrad du Plessis
fe85c9d7fd Report: Chapter IV — Team × Project Activity pivot
Final chapter of the executive redesign. Renders the
team_project_activity context as a pivot: rows=teams, columns=projects,
cell=COUNT(DISTINCT work-log dates). Zero cells show em-dashes in muted
grey (not '0') so non-zero cells stand out. Row totals, column totals,
and grand total on the bottom row.

Adds a tiny dictlookup template filter (format_tags.py) — Django
templates can't index a dict by a dynamic variable key, and the pivot
cell lookup is cells_by_project_id[col.id]. Defensive None + TypeError
guards so a malformed context can't 500 the page.

.table-total-row CSS: 2px top border + inset background for the footer
row so totals visually separate from the data rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:51:46 +02:00
Konrad du Plessis
89c42d25a3 Report: Chapter III heading + tabular-nums on worker breakdown table
Adds the numbered 'III' chapter heading above the existing Worker
Breakdown card (the widest table in the report). Promotes the table
to .report-numeric (tabular-nums) for perfect column alignment
across dynamic adjustment columns — Inter's tabular-nums variant
keeps the rand amounts pixel-aligned.

No data or structural changes to the breakdown itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:47:44 +02:00
Konrad du Plessis
68c9afd939 Report: Chapter I (lifetime context) + chapter numbering
Replaces the old narrow four-card All-Time/YTD row (dropped in Task 9)
with two wider cards under a numbered 'Chapter I - Lifetime Context'
heading. Projects card gains Start, Working Days, Total Cost, and
Avg R / Working Day columns per the design. Teams card keeps name +
total.

Adds .chapter-heading and .chapter-num CSS for the orange numbered
markers (I, II, III, IV) and .report-numeric class that applies
tabular-nums across the number columns of every report table.

Renames the existing 'Selected Period' heading to Chapter II.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:43:14 +02:00
Konrad du Plessis
9632214f99 Report: hero KPI band (4 cards) replacing All-Time/YTD row
Chapter 0 of the executive redesign: four large cards at the top
showing Paid This Period, Outstanding Now (live, stamped with the
generation time), FoxFitt Avg/Day, and FoxFitt Avg/Month.

Drops the old four-small-cards All-Time/YTD row (YTD specifically
documented as redundant per design doc section 3). All-Time detail
moves into Chapter I in the next task.

New .stat-card--hero variant uses Poppins 1.85rem for the number,
uppercase tracked labels, subtle tertiary sub-lines. tabular-nums
keeps the R-amounts pixel-aligned across cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:37:39 +02:00
Konrad du Plessis
ea481bfbf4 Report: filter-pill strip with × to clear individual filters
Three pills under the header: date range, project(s), team(s). Shows
comma-joined names when multi-valued (project_name in context is
already a comma-joined string from Task 6). × buttons on the project
and team pills remove just that filter via a rebuilt querystring;
the calendar pill has no × (date range is required).

Helper context keys query_string_without_project / _without_team do
the rebuild in the view via QueryDict.setlist so multi-value keys
are properly stripped (pop() only removes the first occurrence).

Pill CSS uses existing design tokens (--bg-inset, --accent,
--text-primary, --border-default, --text-tertiary, --bg-card-hover)
so dark and light themes work without overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:14:48 +02:00
Konrad du Plessis
702bba10ed Add SRI hashes to Choices.js CDN tags for consistency with Bootstrap
Code review (on 748c7c7) flagged that Bootstrap CDN tags in base.html
use integrity=sha384-... + crossorigin=anonymous, but the Choices.js
tags added in Task 7 did not. Since both are admin-only privileged
contexts and Bootstrap sets the precedent, Choices.js should match.

Hashes computed from cdn.jsdelivr.net/npm/choices.js@10.2.0 via
  curl ... | openssl dgst -sha384 -binary | openssl base64

No behavior change when the CDN is healthy; defense against a
compromised CDN serving altered bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:11:38 +02:00
Konrad du Plessis
748c7c79d7 Modal: multi-select projects and teams via Choices.js
Replaces the two single <select> elements in the report config modal
with <select multiple> enhanced by Choices.js (CDN 10.2.0, admin-only
gated, graceful fallback to native on CDN failure).

Removes the 'All Projects' / 'All Teams' placeholder option rows —
empty selection = all, matching Choices.js convention.

Persists selected values across submissions via two new context keys
(selected_project_ids, selected_team_ids) threaded through index() and
generate_report().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:05:00 +02:00
Konrad du Plessis
16d192d5fc Refactor _build_report_context signature to multi-value filters
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>
2026-04-22 22:53:16 +02:00
Konrad du Plessis
ea1e4bdbcb Enrich alltime_projects context with working_days + avg_per_working_day
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>
2026-04-22 22:45:08 +02:00
Konrad du Plessis
e8ba2c6745 Add _team_project_activity helper + 4 tests
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>
2026-04-22 22:38:53 +02:00
Konrad du Plessis
ccc44a8d51 Fix _current_outstanding_in_scope sort + tighten team-filter test
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>
2026-04-22 22:35:30 +02:00
Konrad du Plessis
82594faad7 Add _current_outstanding_in_scope helper + 3 tests
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>
2026-04-22 22:29:41 +02:00
Konrad du Plessis
e74f48f050 Add _company_cost_velocity helper + 3 tests
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>
2026-04-22 22:20:14 +02:00
Konrad du Plessis
6be6a09056 Extract _compute_outstanding helper from index() (refactor)
Pure refactor: the ~45 lines of outstanding-payment math inside index()
(computing unpaid_wages + pending_adj_add - pending_adj_sub, with a
per-project breakdown) move into a standalone _compute_outstanding()
helper. index() now calls it with no arguments for unchanged behaviour.
The helper accepts optional project_ids / team_ids for Task 3.

No tests changed; 28/28 still pass. Dashboard Outstanding Payments
card shows the same value before and after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:11:58 +02:00
Konrad du Plessis
0ceceebba4 Fix: supervisor picker hid regular active users (only admins showed)
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>
2026-04-22 19:52:29 +02:00