141 Commits

Author SHA1 Message Date
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
Konrad du Plessis
dcc0eebb7d fix(report): Choices.js dropdown inside filter popovers now visible + scrollable
Systematic-debugging: Konrad reported the project/team popovers showed
no options ('can not see any options') and wheel scroll fell through to
the page instead of scrolling the dropdown.

Root cause chain:
  1. The 0bbf2ca/ffb3ef6 CSS on .filter-popover had `overflow: hidden`
     (to hide anything past max-height) and the body had
     `overflow-y: auto; flex: 1 1 auto`.
  2. Choices.js renders its option list with the default
     `.choices__list--dropdown { position: absolute; }`.
  3. Absolutely-positioned elements do NOT contribute to an ancestor's
     scrollHeight, so the body's overflow-y: auto never created a scroll
     context — wheel events bubbled to the page.
  4. The dropdown extended past the popover's bottom edge and got clipped
     by the popover's overflow: hidden, so no options were visible.

Single-point fix:
  - Remove `overflow: hidden` from .filter-popover (it was only there to
    enforce the sticky footer, which the flex layout already does).
  - Scoped CSS override on .choices__list--dropdown inside .filter-popover
    to force `position: static` — dropdown now flows inline, the body
    grows to contain it, and the sticky footer pushes below naturally.
    The dropdown gets its own `max-height: 260px; overflow-y: auto` for
    long option lists, which gives a clean internal scroll.

Specificity gotcha: Choices.js's rule is
`.choices__list--dropdown, .choices__list[aria-expanded]` — the second
branch has class+attribute specificity (0,0,2,0) that TIES with a naive
two-class override, and since Choices.js's stylesheet loads after ours,
source order gave them the win. The fix is to mirror the selector list,
lifting our specificity to (0,0,2,1) on the aria-expanded branch, which
wins cleanly without `!important`. Inline comment in custom.css explains
this for future reference.

Scoping: the override is gated to `.filter-popover` descendants, so
Choices.js widgets elsewhere in the app (worker / team / project picker
on edit pages, payroll modals, etc.) keep their default absolute-
positioned dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:25:35 +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
0bbf2caae5 style(report): thicker border + deeper multi-layer shadow on filter popover
Konrad's Checkpoint-1 feedback: the popover 'kind of disappears against
the report page'. Previous 1px border + 0.28 opacity shadow read as
weak against the amber-tinted report cards.

Now: 2px accent-orange border + three-layer shadow (soft accent halo,
deep drop, near edge) so the popover reads as clearly detached. Light
theme gets its own shadow palette because white-on-white needs less
absolute darkness but more tinting to be visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:28:18 +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
b52ae47257 CSS: inline-filter pill-dropdown styling
Adds five new style components beneath the existing .filter-pill rules:

1. .filter-pill--editable   — pointer cursor, hover tint, chevron rotation
2. .filter-pill--dirty      — accent outline + pulsing orange dot
3. .filter-popover          — absolute-positioned dropdown with shadow;
                               on mobile (<576px) anchors to bottom of
                               viewport full-width
4. .apply-filters-group     — slide-in animation on the Apply/Reset buttons
5. .filter-toast-container  — fixed top-right stack for cross-filter
                               auto-remove notices; slide-in/out animations

All colours via existing design tokens (--accent, --bg-card,
--text-primary, --border-default, --bg-inset) so dark + light themes
work automatically. z-index layering (popover 1040, toast 1060) stays
below Bootstrap modals (1055+) to avoid stacking conflicts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:47:52 +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
124b3f61b6 Plan: Inline Filters (pill-as-dropdown) implementation
Task-by-task plan for the design at 30d0991. 6 tasks, 1 checkpoint
after Task 4 (all pill interactions demoable, modal still as fallback).

Tasks:
1. Backend: project_team_pairs_json context + 2 tests
2. Template: popover shells + Apply button + json_script embeds
3. CSS: pill-editable, pill-dirty, popover, apply-group, toast (~150 lines)
4. JS: pill-popover interactive module (~300 lines, scoped IIFE)
   --- CHECKPOINT 1 ---
5. Retire modal: delete _report_config_modal.html, update index + report
   templates, keep backend context keys (still used by pill markup)
6. QA + shipped note

Scope: ~480 LOC net added (not the ~330 estimated — JS came out larger
once written with proper state management + cross-filter). Tests grow
42 -> 44. One new CDN-loaded library? No — Choices.js already loaded
from Executive Report v2. Zero model changes, zero migrations.

Noted trade-off in Task 5: selected_project_ids / selected_team_ids
context keys were KEPT despite design doc suggesting removal — the
pill popovers still use them for pre-selection + URL-diff init. Only
the modal markup was retired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:31:49 +02:00
Konrad du Plessis
12edafa441 Design: Payroll Adjustments Tab on the payroll dashboard
Second brainstorm output of the day. New tab alongside Pending /
History / Loans & Advances (the URL pattern already established at
?status=pending|paid|loans — this slots in at ?status=adjustments).

Key decisions:
- Semantic badge palette: 5 colour categories mapped across 7 types.
  Loan/advance repayments get +15% saturation — same family, hotter
  signal for "money coming back" vs "money going out".
- Three multi-select filters (Type, Workers, Teams) via Choices.js.
  Teams cross-filter Workers using JSON pair map (mirrors Feature 1's
  project<->team pattern). Auto-remove invalid selections with toast.
- Single-date default with optional range toggle; presets for
  Today / This week / This month.
- Sticky filter bar; sortable columns (Date / Worker / Amount / Status).
- Group-by toggle: Flat / By Type / By Worker. Collapsible group
  headers show count + net sum per group (+R additive / -R deductive).
- Bulk action bar (floating) for multi-row delete on unpaid rows only.
  New endpoint POST /payroll/adjustments/bulk-delete/ filters
  payroll_record__isnull=True for safety.
- Inline row actions reuse existing modals: Preview (unpaid) /
  View Payslip (paid) / Edit + Delete (unpaid). Zero new modal code.
- Empty state, keyboard Esc, URL state for everything (bookmark-safe).

Scope: ~960 lines, ~12 tasks, 2 checkpoints. Uses existing
#addAdjustmentModal / #editAdjustmentModal / #payslipPreviewModal
already on payroll_dashboard.html — zero duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:26:01 +02:00
Konrad du Plessis
30d0991956 Design: inline filters on report page (+ cross-filter)
Brainstorm output — Konrad's Checkpoint-3 UX request, now spec'd.

Key decisions:
- Pill-as-dropdown: existing filter pills become clickable popovers
- Explicit Apply button; hidden when no pending changes
- Modal retired; dashboard 'Generate Report' becomes a plain link
- Bidirectional cross-filter: selecting a project hides teams that
  haven't worked on it (and vice versa). Strict behaviour with
  auto-removal of now-invalid selections + toast notice.
- URL contract unchanged; PDF download unchanged (still uses
  current querystring).

One new context key (project_team_pairs_json) serialises distinct
(project_id, team_id) pairs from WorkLog for client-side cross-filter.
~80 CSS lines for popover + dirty state + toast; ~150 JS lines for
one scoped module (createElement + textContent, XSS-safe).

Scope: 5-6 focused tasks, 1 checkpoint.
Next step: Feature 2 brainstorm (Payroll Adjustments Browser) before
handing both to writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:06:49 +02:00
Konrad du Plessis
3dab09cea3 Docs: mark Executive Report v2 as shipped (23 Apr 2026)
QA summary:
- 42/42 tests pass
- manage.py check clean
- No pending migrations
- Route sanity: /report/, /report/?project=1&project=2, /report/pdf/ all
  resolve (302 as anon, 200 as admin)
- PDF generation verified for populated and empty date ranges

Appends a "Shipped" block to the design doc that captures the final
QA state, the deferred items, and the notable design decisions made
during implementation. Konrad's inline-filter UX improvement (raised
during Checkpoint 3) is explicitly flagged for a future brainstorm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:18:48 +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
8ea8955b30 Fix Choices.js theme specificity — chain .choices + !important
The b0d3829 overrides lost the cascade battle: the Choices.js CDN CSS
loads AFTER custom.css (inside the modal partial near </body>), so the
CDN's same-specificity rules won by load order. Dropdown still showed
white background + light-grey text in dark mode.

Fix: chain the root `.choices` class to every override (specificity
0,2,0 → 0,3,0) and add !important to color + background + border
properties that Choices.js hardcodes most aggressively. Now the
theme tokens always win regardless of load order.

Visual effect: dropdown option hover state now matches the selected
"Month(s)" button aesthetic (--bg-card-hover subtle lift with
--text-primary text) per Konrad's feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:34:06 +02:00
Konrad du Plessis
b0d382987b Theme Choices.js to match app dark/light themes
Code review of Task 7 showed Choices.js shipping with white-bg +
light-grey-text defaults, which were unreadable in dark mode and
clashed with the app's premium aesthetic. The design doc section 10
scoped these overrides into the CSS work but they hadn't been written.

Adds ~70 lines to custom.css re-themeing every .choices__ selector to
use existing design tokens (--bg-card, --bg-inset, --text-primary,
--accent, --border-default, etc.). No hardcoded colours; both :root
and :root.light themes work automatically.

Key visual changes:
- Dropdown popup: dark-card background with shadow
- Options: primary text colour, accent hover highlight
- Selected chips: orange accent pill with white text
- Focus ring: 0.15rem accent glow on the input

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:23:38 +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
e2eb889a29 Plan: Executive Payroll Report v2 implementation
Task-by-task plan for the design committed at 27cdb46. 14 tasks with
4 hard-pause checkpoints at natural demo points:
  - After Task 6  (backend helpers done)
  - After Task 8  (multi-select modal + filter pills)
  - After Task 12 (full HTML layout — all 4 chapters)
  - After Task 14 (PDF mirrored + QA + shipped note)

Task 1 is a pure refactor (extract _compute_outstanding from index())
so later tasks can reuse the dashboard math with filters. Tasks 2-5
add the new helpers alongside existing code with failing-test-first
discipline. Task 6 switches the main helper to multi-value filters
(project_ids/team_ids) — existing behaviour preserved via backward-
compatible getlist. Tasks 7-12 restructure the HTML template into
Hero + 4 chapters. Task 13 mirrors in the PDF. Task 14 QAs and ships.

~11 new tests across 4 test classes; total grows from 28 to ~39.

One new dependency: Choices.js 10.2.0 via CDN, admin-only gated,
graceful fallback to native multi-select on CDN failure.

Follows the CLAUDE.md conventions: # === SECTION === comments,
plain-English docstrings, subquery-filter pattern for M2M filters,
single-batched push at the end, Co-Authored-By trailer on every
commit, never amend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:45:31 +02:00
Konrad du Plessis
27cdb46ec9 Design: Executive Payroll Report v2
Brainstorm output for rebuilding /report/ as an executive-grade dashboard.

Key decisions captured:
- Multi-select filters (Choices.js) with empty=all semantics
- Hero KPI band: Paid / Outstanding NOW / Avg R/day / Avg R/month
- Chapter I: Lifetime context with working-day denominator for avg cost
- Chapter II: Selected period (existing content, restructured)
- Chapter III: Worker breakdown (existing, restyled)
- Chapter IV: NEW team × project activity pivot

Current Outstanding reuses dashboard math (live, stamped with generation
time). Company cost velocity = lifetime cost / distinct work-log dates;
monthly = daily × 30.44.

No model changes. One new CDN dep (Choices.js). Target: ~650 LOC
including ~120 new tests. Four checkpoint pauses proposed for the
subsequent implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:38:26 +02:00
Konrad du Plessis
92036f7e4c Docs: update CLAUDE.md with session learnings
Five focused updates from the Apr 22-23 bug-fix + gitignore session:

1. Fix stale supervisor-picker queryset doc: it was showing the pre-fix
   Q(is_staff)|Q(is_superuser)|Q(groups__name='Work Logger') filter.
   Since commit 0ceceeb the queryset is just User.objects.filter(is_active=True).

2. Update "How to add a new supervisor" step 2: Work Logger group
   membership is no longer required for picker visibility — optional now.

3. Add "Schema name-drifts to remember" block near Key Models. Three
   recurring gotchas that burned four subagent tasks across two sessions:
   - PayrollAdjustment.description (not reason)
   - log.adjustments_by_work_log (not payrolladjustment_set)
   - log.overtime_amount (not log.overtime)

4. Add canonical test-command one-liner to the Commands section:
   USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2

5. Add "Django ORM gotcha" subsection documenting the M2M filter +
   values().annotate(Sum()) inflation bug and the id__in subquery fix
   pattern (refs commit f1e246c, ReportContextFilterInflationTests).

No code changes; no test impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:36:35 +02:00
Konrad du Plessis
88e68f5e36 Stop tracking staticfiles/ — it's a build artifact, not source
Problem: every time collectstatic ran on the VM, Flatlogic's web UI
detected the modified files in staticfiles/ and auto-committed them
with a generic "Ver XX.YY" message (e.g. "Ver 30.04 Fix reports and
add Supervisor"), pushing the result to gitea but not GitHub. Every
push of CSS/JS changes triggered a reconciliation dance. See the
"Ver 30.04" divergence resolved by commit e0d2c74 for the most recent
example — that was the 3rd or 4th recurrence of this exact pattern.

Fix:
1. Add staticfiles/ to .gitignore
2. Untrack all 627 currently-tracked files via `git rm -r --cached`
3. Document the change in CLAUDE.md (Project Structure, Static Assets,
   and a new "NOT tracked in git" subsection)

Deploy consequence: the NEXT pull on the VM will delete
staticfiles/ from the working tree (because git sees those files
removed from the tree). Gemini MUST run `collectstatic --noinput`
IMMEDIATELY after `git pull` to repopulate from source, then
restart the service. Brief window of 404s on static assets is
acceptable at this scale (seconds).

After this change: collectstatic output lives on the VM's filesystem
but outside git's view, so Flatlogic's UI has nothing to auto-commit.
The recurring divergence pattern is permanently eliminated.

No runtime code changes — all 28 tests still pass.

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