Compare commits

...

43 Commits

Author SHA1 Message Date
Konrad du Plessis
6f66faf06a feat(adjustments): filter bar v2 — unify all 5 filters as pills + density pass
Konrad's feedback on the shipped Adjustments tab: "this interface
layout is very ugly. And the selection dropdown menus text is a bit
large." Plus: the 'Show as' toggle sits too close to the filter bar.

Design doc: docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md

Changes:

1. All 5 filters become pill-popovers of identical shape
   - Type / Workers / Teams: unchanged (already pills)
   - Status: was <select> + <label>, now pill → popover with 3 radios
   - Date: was inline inputs + preset links + '...' toggle, now pill →
     popover with Single/Range mode toggle + picker(s) + presets + OK/Cancel
   - Pill labels update to 'Status: Unpaid' / 'Date: 24 Apr 2026' /
     'Date: 20 Apr – 26 Apr 2026' for at-a-glance state
   - Apply + Clear pushed to right end via .adj-apply-group (margin-left: auto)

2. Popover density pass
   - .adj-checkbox-list / .adj-radio-list font-size 0.8rem (~12.8px)
   - .adj-cb-row padding trimmed to 0.15rem 0.25rem
   - Checkbox visual size 0.9em
   - Popover footer buttons 0.75rem font, 0.25rem 0.6rem padding
   - Popover max-width 360px (was ~420px)
   - 7-type popover drops from ~320px tall to ~240px

3. Spacing fix above 'Show as:' toggle
   - .adj-groupby-toggle now has margin-top: 1rem + margin-bottom: 0.75rem
   - Clear visual separation from the sticky filter bar

4. Filter-bar alignment
   - align-items: center (was end, now all children are same height)
   - Gap tightened to 0.5rem

Backend contract unchanged (query params identical). No test changes
(65/65 still pass). Committed popover JS uses the same
.adj-hidden-inputs pattern as the checkbox filters — Status + Date
each have their own commit/revert logic that rewrites their hidden
inputs on OK. XSS-safe throughout (replaceChildren() + textContent,
no innerHTML with user data).

Gated the generic checkbox-popover OK/Cancel handler to
['type', 'worker', 'team'] so the new Status/Date popovers aren't
accidentally re-committed via commitCheckboxes.
2026-04-23 22:00:27 +02:00
Konrad du Plessis
620f433d06 docs(adjustments): design for filter-bar v2 (pill unification + density) 2026-04-23 21:54:13 +02:00
Konrad du Plessis
672c32cfb6 ux(adjustments): drop the per-row Delete button — Edit modal handles it
Konrad's feedback: the red × Delete button on each unpaid row was
redundant — the Edit Adjustment modal already has a Delete action
inside it, so users never need a second entry point.

Removed: the .adj-delete-btn button from _adjustment_row.html and its
now-dead DOMContentLoaded handler in payroll_dashboard.html (~15 lines
of JS). Unpaid rows now show Preview + Edit only.

Bulk-delete is unaffected: the floating action bar + per-row checkboxes
remain as the fast path for deleting many rows at once. Single-row
delete flows through the Edit modal's existing delete button.

65/65 tests still pass.
2026-04-23 19:57:00 +02:00
Konrad du Plessis
3fe3e5aa01 fix(adjustments): group-by uses full filtered queryset + Apply keeps group mode
Two final-review follow-ups from the whole-feature code review:

1. Important: group-by was bucketing adj_page.object_list (the paginated
   50-row slice), making 'By Type' group headers show misleading per-page
   totals once filters returned >50 rows. Konrad's current data is under
   the threshold, but the UI promised whole-filter totals.

   Fix: group_by runs on the full filtered queryset (list(adjustments))
   BEFORE pagination. Template already branches on adj_groups, so we now
   additionally hide the pagination nav when grouped — the group headers
   act as their own navigation and their counts/sums reflect the whole
   filter not just one page.

2. Minor: Apply after picking 'By Worker' silently reset to Flat view
   because the filter form had hidden inputs for sort/order but not
   group_by. Added the missing <input type='hidden' name='group_by'>
   so the toggle round-trips across Apply.

65/65 tests still pass (no test changes — the previous tests' fixtures
are all <50 rows so neither the bug nor the fix shows up there, but
both behaviours are now correct).
2026-04-23 19:37:57 +02:00
Konrad du Plessis
269d86259a docs(adjustments): Shipped block on design doc + CLAUDE.md URL routes
Captures the 11-task implementation, 5 deviations (biggest: the
CP1 pivot from Choices.js chip-multiselect to popover-checkbox
filter UX after Konrad flagged the chip pattern as intrusive),
14 new adjustments-tab tests, and total code churn (~+1400 lines).

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

Feature ready for final whole-feature code review + batched push.
2026-04-23 19:26:46 +02:00
Konrad du Plessis
9bb9ede300 polish(adjustments): empty-state card with recovery CTAs + sticky bar check
No-rows case now renders a proper card with two recovery paths:
Clear filters (href back to ?status=adjustments) and Add adjustment
(opens the existing #addAdjustmentModal — no new JS).

Sticky filter bar (Task 2 CSS) verified functional — no ancestor
clipping or overflow:hidden in the adjustments tab block.
2026-04-23 19:23:39 +02:00
Konrad du Plessis
7b71048376 feat(adjustments): sortable column headers with URL state
4 sortable columns: Date, Worker, Amount, Status. Click cycles
desc -> asc -> desc. Click a different column -> resets to desc.
Keyboard Enter / Space also works (role=button + tabindex=0).

The sort/order state lives in hidden inputs inside the adjustments
filter form, so the JS just mutates those and .submit()s — the sort
then piggy-backs on the same GET the filter bar uses, and the URL
retains it across pagination. Backend sort_map (Task 3) already
whitelists the allowed columns, so no SQL-injection surface.

Arrow icons reflect state: fa-sort (inactive), fa-sort-down (desc),
fa-sort-up (asc). Active column gets .sorted class for stronger
arrow opacity (CSS already shipped in Task 2).

No backend changes, no new tests — the existing 65 tests cover the
sort contract from the URL.
2026-04-23 19:20:49 +02:00
Konrad du Plessis
c851b49dea feat(adjustments): date picker single/range toggle + preset quick-buttons
Single by default (one <input> + '...' toggle reveals the second).
In single mode the JS mirrors From into the hidden To on every
change, so form submit sends adj_date_from=adj_date_to=X for an
exact-day filter on the backend (contract unchanged).

Four presets: Today (single), This week (Mon-Sun range), This month
(1st to last, range), Clear. Presets auto-switch mode so users see
what was populated.

On page load, range mode is inferred from the URL: if both dates
present AND differ -> range mode; else single mode. That way a
bookmarked range URL still shows both pickers.

No backend changes, no new tests — the 8 existing adjustments tests
already cover the from/to contract shape.
2026-04-23 19:17:19 +02:00
Konrad du Plessis
6905703492 feat(adjustments): Team -> Workers cross-filter in the popover JS
When Team(s) are selected via the Teams popover, the Workers popover
now only shows workers who belong to at least one of those teams.
URL-selected workers stay visible regardless (so the user can see
and untick them).

Backend adds one context key: team_worker_pairs_json — raw Python
list of {team_id, worker_id} dicts from Team.workers.through;
template renders via |json_script (safe, no double-encoding).

Frontend reads the JSON once, builds a team_id -> Set(worker_id)
index, and on every Workers-popover open (and on Teams-popover OK)
hides rows whose worker is out-of-team. display:none on the <label>
row is visually cleaner than disabling the checkbox alone.

Scope: entire roster (not date-range-scoped) — cross-filter is
about data possibility, not data in this period.

One new test locks in the pairs-context-key shape (asserts it's a
raw Python list of dicts, not a pre-serialised JSON string —
guards against the double-encoding regression from Feature 1).
65/65 tests pass.
2026-04-23 19:13:10 +02:00
Konrad du Plessis
4c3e90f2a7 fix(adjustments): bulk-delete cascades through Loan + Overtime (critical)
Code-review found a data-integrity bug: the bulk-delete endpoint
bypassed the cascade logic that single-row delete_adjustment does
for 'New Loan', 'Advance Payment', and 'Overtime' types.

Without cascade, bulk-deleting a 'New Loan' adjustment would:
  - Delete the PayrollAdjustment row
  - LEAVE the linked Loan row orphaned in the DB (still shown in
    loan reports, still affecting remaining_balance queries)
  - LEAVE any scheduled unpaid Loan Repayment adjustments pointing
    at the orphaned Loan (they would silently deduct from the
    worker's next pay with no visible parent)

Bulk-deleting an 'Overtime' adjustment would leave the worker
stuck in work_log.priced_workers, making price_overtime() treat
them as already-priced even though the money is gone.

Fix: extracted _delete_adjustment_with_cascade(adj) helper that
captures the exact rules from the existing delete_adjustment view
— returns (ok, reason) so both callers can translate the outcome
into their own response shape. Both views now delegate to it.

bulk_delete_adjustments now loops over the selected rows, calls
the helper per-row, and returns JSON including skipped_reasons
(e.g. {'has_paid_repayments': 1} when a Loan with paid repayments
was refused). Also hardened the id-coercion to int so a garbled
POST payload can't crash the queryset with a ValueError.

Two new tests:
  - test_bulk_delete_cascades_new_loan — loan row + unpaid repayment
    must also be deleted
  - test_bulk_delete_skips_loan_with_paid_repayments — refuses to
    delete the loan but still processes other rows in the batch

64/64 tests pass (was 62). No API surface change visible to a user
who only uses the happy path — but the audit trail on Loans is
now safe even under bulk delete.
2026-04-23 19:06:54 +02:00
Konrad du Plessis
5f2e6d8c74 fix(adjustments): rename Select-All header checkbox id to avoid collision
Code-review follow-up on Task 6:

Task 4 gave the Adjustments table's 'select all' header checkbox the
id 'adjSelectAll' — but the Add-Adjustment modal already had an
<a id='adjSelectAll'> Select-All anchor (and a matching JS click
handler on line 1823). Duplicate IDs are invalid HTML, and
getElementById returns the first occurrence in DOM order — so the
modal's Select-All handler silently started binding to the table
checkbox instead of its intended anchor. Never reported because
neither element was automated-tested.

Rename the table checkbox id to #adjTableSelectAll and update Task 6's
bulk-select JS to match. The modal's handler now correctly binds to
its own anchor again.

62/62 tests still pass — behaviour is template-driven UI, no backend
change.
2026-04-23 18:57:43 +02:00
Konrad du Plessis
03f177e7d0 feat(adjustments): bulk-delete unpaid rows + floating action bar
New POST /payroll/adjustments/bulk-delete/ endpoint takes a list of
adjustment_ids and DELETEs the ones that are still unpaid
(payroll_record__isnull=True at the DB level) — paid rows are silently
skipped, defensive against stale-UI race conditions. Admin-only;
supervisors get 403. Returns JSON {deleted, requested}.

Floating bar slides up from the bottom of the viewport when >=1 row
selected: shows count + Delete + Clear. Confirm dialog guards the
POST. On success, page reloads to reflect the new state.

CSRF via X-CSRFToken header from the csrftoken cookie (Django
middleware sets this). Two new tests lock in the 'only unpaid' +
'admin-only' contracts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:51:54 +02:00
Konrad du Plessis
e5d06f91e5 polish(adjustments): rotate chevron on collapse + lock in magnitude ordering
Code-review follow-ups on Task 5:

1. Chevron rotation — Bootstrap 5 collapse sets aria-expanded="false" on
   the toggle when collapsed; two CSS rules off that attribute rotate
   the chevron -90deg with a 150ms transition. No JS needed.

2. test_group_by_type now asserts groups[0]['label'] == 'Bonus', locking
   in the descending-|net_sum| ordering promise (|800| > |100|).

60/60 tests still pass.
2026-04-23 18:42:19 +02:00
Konrad du Plessis
0862805623 feat(adjustments): group-by type / worker + collapsible headers
Adds _group_adjustments helper that buckets a flat queryset by type or
by worker_id, with signed net_sum (+ for additive, - for deductive)
and descending-magnitude ordering so the biggest-impact bucket sits
at the top.

Template branches on adj_groups: grouped view renders one <tbody>
per group with a Bootstrap-collapse-driven header row, wrapping
include of _adjustment_row.html for the actual rows (no duplication).
Flat view is the default when group_by is empty.

By Type headers get a 4px left-border accent in the matching badge
colour so grouped rows visually echo the badges below them.
Attribute-selector based ([data-type=Bonus] etc.) so the
CSS stays self-descriptive without per-type class explosion.

Adds |money_abs template filter for signed render ('-R 100.00' in
the template becomes money_abs(-100) -> '100.00' after the caller
emits its own sign; avoids 'R -100.00' which reads wrong).

Two new tests lock in the bucket structure + net_sum signing for
both axes. Tests 58 -> 60. url_replace template tag already shipped
in the CP1 pagination fix - reused here for the toggle hrefs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:34:07 +02:00
Konrad du Plessis
4c1cdb6210 fix(adjustments): remove multi-line {# #} comment in worker cell
Same bug as e088192 — I wrote a 4-line {# ... #} block in the Fix-A
prompt for the worker-name cell, forgetting Django's single-line
comment constraint. The implementer reproduced it faithfully and it
shipped. The comment text flooded every row's Worker column.

Fix: collapse the comment to a single-line {# #} on the row above
the <td>, same style as the other row comments.
2026-04-23 17:12:05 +02:00
Konrad du Plessis
4f15e4bd5f feat(adjustments): replace Choices.js chip-multiselect with popover-checkbox filters
Checkpoint-1 feedback from Konrad: the Choices.js chip pattern for
Type / Workers / Teams was visually intrusive once multiple options
were picked — the filter bar dominated the viewport.

Replacement: each filter is now a compact pill (like Feature 1's
inline-filter pills on the report page) that opens a popover with a
scrollable checkbox list, live-search, and Select All / Invert /
Clear action buttons. OK commits the pending state into hidden form
inputs; Cancel / Esc / click-outside revert. The existing Apply button
still submits the form normally.

Reuses Feature 1's .filter-pill / .filter-popover CSS vocabulary —
only new CSS is a scrollable checkbox-list rule and a pill-count
badge style. No new modals. Choices.js CDN stays loaded (other
tabs still use it).
2026-04-23 17:07:50 +02:00
Konrad du Plessis
b59eb313c0 fix(adjustments): row actions use modals + project link goes to History tab
Checkpoint-1 feedback from Konrad — three row actions on the Adjustments
tab were breaking his muscle memory vs the Pending tab:

1. Worker name used to navigate to /workers/<id>/. Now opens the Worker
   Lookup modal using the existing .worker-lookup-link handler already
   bound on the dashboard — zero new JS.

2. Eye icon on PAID rows used to navigate to /payroll/payslip/<pk>/. Now
   opens the same #previewPayslipModal that unpaid rows use (via the
   existing .preview-payslip-btn handler). The 'Paid #N' green badge in
   the Status column still links to the historical payslip detail page,
   so both entry points coexist.

3. Project name used to open the Profile tab of the project detail page;
   now includes the #history URL fragment so the History tab is active.
   Added a tiny DOMContentLoaded helper in projects/detail.html that
   activates whatever tab the hash points to — generalised so any
   future deep-link works (#history, #supervisors, #teams, #workers).
2026-04-23 16:55:42 +02:00
Konrad du Plessis
e088192103 fix(adjustments): convert multi-line {# #} comments to {% comment %} blocks
Checkpoint-1 bug: the row partial's docstring used a multi-line {# ... #}
block. Django's single-line comment syntax doesn't match across newlines,
so the opening {# and closing #} were treated as literal text and spilled
into every rendered row — flooding the table body with the raw comment.
Worse, the browser partially parsed the literal <tr> inside the comment
text as an HTML tag, breaking the table layout entirely.

Fix: moved the multi-line docstring into a {% comment %}...{% endcomment %}
block and compressed three other multi-line {# #} blocks to single lines.

Also tripped on a second foot-gun: you can't put literal {# or #} inside
a {% comment %} block — Django's tokenizer still sees them as a nested
comment marker. Removed the meta-note about "{# ... #} is single-line
only" from inside the comment block.

All 58 tests pass. Table renders correctly with all 10 columns + type
badges + row actions visible.
2026-04-23 16:19:38 +02:00
Konrad du Plessis
06b3315641 fix(adjustments): pagination URL + filter label accessibility + teams.first N+1
Three code-review fixes:

1. Pagination links were building ?status=...&page=2&page=3 on every
   click because the template appended &page= onto an already-
   serialised querystring. Added a reusable url_replace template tag
   that replaces a single key (pre-empts Tasks 5 / 9 which also
   need it) and piped the pagination hrefs through it. Added
   rel=prev/next + aria-label on the <a> tags while we were here.

2. Filter-bar labels had no for= attribute, so screen readers
   announced the native <select> with no name. Added id= on each
   select/input and matching for= on each label. Also gave the
   Select-all checkbox an aria-label (title= alone is not an
   accessible name).

3. Row template's {% with team=adj.worker.teams.first %} issued a
   fresh ORDER BY ... LIMIT 1 query per row despite the view's
   prefetch_related('worker__teams'). Swapped to {% with
   teams=adj.worker.teams.all %} which DOES use the prefetch cache,
   bounding the Team column at 0 extra queries (was ~50 per page).
2026-04-23 15:45:41 +02:00
Konrad du Plessis
b450bd3c39 feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions
Reuses existing modals (#editAdjustmentModal, delete confirm flow) —
zero new JS for row actions. Choices.js lazy-inits only when the tab
is active. Stats row scoped to filter set. Subquery pattern on team
filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come in
Tasks 5/6/7.
2026-04-23 15:34:09 +02:00
Konrad du Plessis
89f109afb4 test(adjustments): strengthen subquery + multi-filter tests
The team-filter test was passing regardless of the subquery pattern
because each worker was on only one team — no cardinality to inflate.
Fixture now puts both workers on both teams so a naive
worker__teams__id__in filter would return 6 rows (2 teams × 3
adjustments). The type-filter test now passes BOTH Bonus AND Deduction
so it exercises the multi-select code path (not just a single value).

Both assertions use adj_total_count (.count() at queryset level) so
regressions blow up at aggregation rather than just the paginator page.
2026-04-23 15:22:19 +02:00
Konrad du Plessis
10d381e2ae feat(adjustments): backend filter branch for ?status=adjustments
Type / worker / team / status / date filters, sort, stats, pagination.
Subquery pattern on the team filter avoids M2M JOIN inflation
(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter
come later (Tasks 5/6/7).
2026-04-23 15:12:19 +02:00
Konrad du Plessis
a20a025d46 feat(adjustments): add semantic badge palette + sticky filter bar / group-header / bulk-bar styles 2026-04-23 15:00:53 +02:00
Konrad du Plessis
97d8a69212 feat(adjustments): add |type_slug template filter for badge class naming 2026-04-23 14:54:50 +02:00
Konrad du Plessis
cf82215511 docs(adjustments): add task-by-task implementation plan
11 tasks + 1 hard-pause checkpoint after Task 4. Targets ~960 LOC
across core/views.py, payroll_dashboard.html, _adjustment_row.html
(new), format_tags.py, custom.css, and tests.py.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:35:20 +02:00
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
18 changed files with 7499 additions and 232 deletions

View File

@ -179,6 +179,8 @@ USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: project batch report as CSV download |
| `/toggle/<model>/<id>/` | `toggle_active` | Admin: AJAX toggle active status |
| `/payroll/` | `payroll_dashboard` | Admin: pending payments, loans, charts |
| `/payroll/?status=adjustments` | `payroll_dashboard` | Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by type/worker; bulk-delete unpaid; row actions open existing modals) |
| `/payroll/adjustments/bulk-delete/` | `bulk_delete_adjustments` | Admin: POST-only; delete multiple unpaid adjustments in one shot via fetch() with X-CSRFToken cookie |
| `/payroll/pay/<worker_id>/` | `process_payment` | Admin: process payment (atomic) |
| `/payroll/price-overtime/` | `price_overtime` | Admin: AJAX price unpriced OT entries |
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |

View File

@ -0,0 +1,130 @@
{# === _adjustment_row.html — row partial for the Adjustments tab === #}
{% comment %}
Single table row used by BOTH the flat Adjustments view and (later) the grouped view.
Context:
- adj a PayrollAdjustment instance
- additive_types list of type labels that are additive (used to decide
whether the amount should be prefixed with + or - in the display)
Row actions differ by paid status:
- Paid -> single View Payslip icon button
- Unpaid -> three buttons: Preview, Edit, Delete
(these reuse the existing modals on the dashboard -- no new JS)
{% endcomment %}
{% load format_tags %}
<tr data-adj-id="{{ adj.id }}" class="{% if adj.payroll_record %}adj-row-paid{% else %}adj-row-unpaid{% endif %}">
{# --- Bulk-select checkbox --- #}
{# Paid rows show a disabled checkbox so the column stays aligned; #}
{# only unpaid rows can be bulk-selected for deletion (feature comes in Task 6). #}
<td class="bulk-select-cell">
{% if adj.payroll_record %}
<input type="checkbox" class="form-check-input" disabled title="Paid rows cannot be bulk-deleted">
{% else %}
<input type="checkbox" class="form-check-input adj-bulk-checkbox" value="{{ adj.id }}">
{% endif %}
</td>
{# --- Date --- #}
<td>{{ adj.date|date:"d M Y" }}</td>
{# --- Worker name (class="worker-lookup-link" opens the Worker Lookup modal) --- #}
<td>
<a href="#" class="worker-lookup-link text-decoration-none"
data-worker-id="{{ adj.worker.id }}">{{ adj.worker.name }}</a>
</td>
{# --- Type badge (colour comes from the .badge-type-<slug> CSS class) --- #}
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>
{# --- Amount (sign reflects additive vs deductive) --- #}
<td class="text-end" style="font-variant-numeric: tabular-nums;">
{% if adj.type in additive_types %}
<span style="color: var(--text-primary);">+R {{ adj.amount|money }}</span>
{% else %}
<span style="color: var(--text-primary);">&minus;R {{ adj.amount|money }}</span>
{% endif %}
</td>
{# --- Project (clickable if present, dash if missing) --- #}
<td>
{% if adj.project %}
{# Link lands on the History tab of the project detail page — the #}
{# most useful landing for a user who clicked a historical #}
{# adjustment. Tab activation is driven by the #history fragment #}
{# via a small helper in projects/detail.html. #}
<a href="{% url 'project_detail' adj.project.id %}#history"
class="text-decoration-none">{{ adj.project.name }}</a>
{% else %}<span class="text-muted">&mdash;</span>{% endif %}
</td>
{# --- Team (worker's first team, if any — many workers are unteamed) --- #}
{# Uses `teams.all` (NOT `teams.first`) because the view's #}
{# .prefetch_related('worker__teams') populates `_prefetched_objects_cache` #}
{# for `.all()` calls — `.first()` would ignore the cache and fire a #}
{# fresh `ORDER BY ... LIMIT 1` SQL query per row (up to ~50 per page). #}
<td>
{% with teams=adj.worker.teams.all %}
{% if teams %}{{ teams.0.name }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}
{% endwith %}
</td>
{# --- Description (truncated; full text shown in a hover tooltip) --- #}
<td>
{% if adj.description %}
<span title="{{ adj.description }}" data-bs-toggle="tooltip">
{{ adj.description|truncatechars:40 }}
</span>
{% else %}<span class="text-muted">&mdash;</span>{% endif %}
</td>
{# --- Status: Paid #N (links to the payslip) or Unpaid badge --- #}
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}" class="badge bg-success text-decoration-none">
Paid #{{ adj.payroll_record.id }}
</a>
{% else %}
<span class="badge bg-warning text-dark">Unpaid</span>
{% endif %}
</td>
{# --- Row actions (eye + pen + x for unpaid; eye only for paid) --- #}
<td class="text-end">
{% if adj.payroll_record %}
{# Eye icon on paid rows opens the same Payslip Preview modal used on #}
{# the Pending tab (instead of navigating to the payslip detail page). #}
{# The "Paid #N" badge in the Status column still links to the #}
{# historical payslip for users who want to jump to the PDF view. #}
<button type="button"
class="btn btn-sm btn-outline-info preview-payslip-btn"
data-worker-id="{{ adj.worker.id }}"
data-worker-name="{{ adj.worker.name }}"
title="Preview payslip" data-bs-toggle="tooltip">
<i class="fas fa-eye"></i>
</button>
{% else %}
{# UNPAID row: Preview + Edit only. Single-row delete happens #}
{# inside the Edit Adjustment modal; bulk delete uses the row #}
{# checkboxes + floating action bar (shared entry point). #}
<button type="button"
class="btn btn-sm btn-outline-info preview-payslip-btn"
data-worker-id="{{ adj.worker.id }}"
data-worker-name="{{ adj.worker.name }}"
title="Preview payslip" data-bs-toggle="tooltip">
<i class="fas fa-eye"></i>
</button>
<button type="button"
class="btn btn-sm btn-outline-primary adjustment-badge"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description|default:'' }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}"
title="Edit" data-bs-toggle="tooltip">
<i class="fas fa-pen"></i>
</button>
{% endif %}
</td>
</tr>

View File

@ -1,160 +0,0 @@
{% comment %}
=== REPORT CONFIGURATION MODAL (shared partial) ===
Renders the "Generate Report" modal and its month-vs-custom-dates
toggle script. Included by both the Dashboard (index.html) and the
Report page (report.html) so users can launch a new report from
either place without duplicating the modal HTML or the JS.
Requires in the parent template context:
- `projects` (queryset of Project, for the project dropdown)
- `teams` (queryset of Team, for the team dropdown)
If those are missing, the dropdowns simply show "All Projects" /
"All Teams" — no crash.
{% endcomment %}
<div class="modal fade" id="reportConfigModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Generate Report</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="get" action="{% url 'generate_report' %}" id="reportForm">
<div class="modal-body">
<div class="row g-3">
<!-- Date Mode Toggle -->
<div class="col-12">
<label class="form-label fw-semibold">Date Selection</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="date_mode" id="modeMonth" value="month" checked>
<label class="btn btn-outline-secondary" for="modeMonth">
<i class="fas fa-calendar-alt me-1"></i>Month(s)
</label>
<input type="radio" class="btn-check" name="date_mode" id="modeCustom" value="custom">
<label class="btn btn-outline-secondary" for="modeCustom">
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</label>
</div>
</div>
<!-- Month Range Picker (shown by default) -->
<div class="col-6" id="fromMonthGroup">
<label class="form-label fw-semibold">From</label>
<input type="month" name="from_month" class="form-control" id="reportFromMonth">
</div>
<div class="col-6" id="toMonthGroup">
<label class="form-label fw-semibold">To</label>
<input type="month" name="to_month" class="form-control" id="reportToMonth">
</div>
<!-- Custom Date Range (hidden by default) -->
<div class="col-6 d-none" id="startDateGroup">
<label class="form-label fw-semibold">Start Date</label>
<input type="date" name="start_date" class="form-control" id="reportStartDate">
</div>
<div class="col-6 d-none" id="endDateGroup">
<label class="form-label fw-semibold">End Date</label>
<input type="date" name="end_date" class="form-control" id="reportEndDate">
</div>
<!-- Project Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
<select name="project" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
{% for p in projects %}
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<!-- Team Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
<select name="team" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
{% for t in teams %}
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-accent"><i class="fas fa-chart-bar me-1"></i>Generate</button>
</div>
</form>
</div>
</div>
</div>
<!--
=== REPORT MODAL — toggle month range vs custom dates ===
Defaults both month pickers to the current month on page load so
clicking "Generate" without changing anything produces a
current-month report. Guarded by `if (!modeMonth)` so it's a no-op
on pages that don't include the modal.
-->
<script>
(function() {
var modeMonth = document.getElementById('modeMonth');
if (!modeMonth) return; // modal not on this page — skip
var modeCustom = document.getElementById('modeCustom');
var fromMonthGroup = document.getElementById('fromMonthGroup');
var toMonthGroup = document.getElementById('toMonthGroup');
var startGroup = document.getElementById('startDateGroup');
var endGroup = document.getElementById('endDateGroup');
var fromMonth = document.getElementById('reportFromMonth');
var toMonth = document.getElementById('reportToMonth');
// Default both month pickers to current month
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
if (fromMonth) fromMonth.value = curMonth;
if (toMonth) toMonth.value = curMonth;
function toggleMode() {
if (modeMonth.checked) {
fromMonthGroup.classList.remove('d-none');
toMonthGroup.classList.remove('d-none');
startGroup.classList.add('d-none');
endGroup.classList.add('d-none');
} else {
fromMonthGroup.classList.add('d-none');
toMonthGroup.classList.add('d-none');
startGroup.classList.remove('d-none');
endGroup.classList.remove('d-none');
}
}
modeMonth.addEventListener('change', toggleMode);
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
})();
</script>
{# === CHOICES.JS — multi-select enhancement (admin-only) === #}
{# Loaded CDN-only; falls back to native <select multiple> if the CDN fails. #}
{% if user.is_staff or user.is_superuser %}
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
crossorigin="anonymous"
defer></script>
<script>
(function() {
document.addEventListener('DOMContentLoaded', function() {
if (typeof Choices === 'undefined') return; // graceful fallback
document.querySelectorAll('.report-multi').forEach(function(el) {
new Choices(el, {
removeItemButton: true,
shouldSort: false,
placeholder: true,
placeholderValue: el.getAttribute('data-placeholder') || '',
});
});
});
})();
</script>
{% endif %}

View File

@ -184,7 +184,11 @@
<i class="fas fa-receipt"></i>
<span>New Receipt</span>
</a>
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
{# === GENERATE REPORT — PLAIN LINK === #}
{# No modal: lands on /report/ with the current month pre-filled; #}
{# inline pill filters on the page handle any further tweaking. #}
<a href="{% url 'generate_report' %}?from_month={% now 'Y-m' %}&to_month={% now 'Y-m' %}"
class="quick-action">
<i class="fas fa-file-alt"></i>
<span>Generate Report</span>
</a>
@ -599,9 +603,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
</script>
<!-- === REPORT CONFIGURATION MODAL === -->
<!-- Extracted to a shared partial so the report page can use the same
modal without duplicating the HTML or the toggle script. -->
{% include 'core/_report_config_modal.html' %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -565,6 +565,7 @@
<tr>
<th>Project</th>
<th>Start</th>
<th>Last Activity</th>
<th class="r">Working Days</th>
<th class="r">Total Cost</th>
<th class="r">Avg R / Working Day</th>
@ -573,6 +574,7 @@
<tr>
<td class="name">{{ item.project }}</td>
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="r">{% if item.working_days %}{{ item.working_days }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="total">R&nbsp;{{ item.total|money }}</td>
<td class="r">{% if item.working_days %}R&nbsp;{{ item.avg_per_working_day|money }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>

View File

@ -181,4 +181,22 @@
</div>
</div>
<script>
// === Activate tab from URL hash ===
// When the page loads with #history / #profile / #supervisors / #teams /
// #workers in the URL, open that tab automatically. This lets other
// pages link directly to a specific tab — e.g. the Adjustments row
// partial links to /projects/<id>/#history so a user clicking a project
// name from an adjustment lands on the relevant history view.
document.addEventListener('DOMContentLoaded', function() {
if (!window.location.hash) return;
var target = document.querySelector(
'[data-bs-toggle="tab"][data-bs-target="' + window.location.hash + '"]'
);
if (target) {
new bootstrap.Tab(target).show();
}
});
</script>
{% endblock %}

View File

@ -17,10 +17,8 @@
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<!-- New Report: opens the same config modal as the Dashboard -->
<button type="button" class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
{# "New Report" removed — the inline filter pills below ARE the #}
{# new-report interface now. Date/project/team pills edit in-place. #}
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
<i class="fas fa-download me-1"></i>Download PDF
</a>
@ -30,25 +28,167 @@
</div>
</div>
{# === FILTER PILLS === #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
<span class="filter-pill">
<i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}
</span>
<span class="filter-pill">
<i class="fas fa-folder me-1"></i>{{ project_name }}
{# === FILTER PILLS (interactive — pill-as-dropdown) === #}
{# Each pill is a clickable button that opens an inline popover with the #}
{# relevant editor. The Apply button appears only when at least one pill #}
{# has uncommitted changes. See the JS module lower in this file. #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none" id="filter-pill-strip">
{# --- Date pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-date"
data-filter="date"
aria-expanded="false"
aria-controls="popover-date">
<i class="fas fa-calendar me-1"></i>
<span class="filter-pill__label">{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="popover-date" role="dialog" aria-label="Edit date range" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Date Selection</label>
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeMonth" value="month" checked>
<label class="btn btn-outline-secondary btn-sm" for="popDateModeMonth">
<i class="fas fa-calendar-alt me-1"></i>Month(s)
</label>
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeCustom" value="custom">
<label class="btn btn-outline-secondary btn-sm" for="popDateModeCustom">
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</label>
</div>
{# --- Month-mode pickers --- #}
{# Visual order follows English reading: "From (optional) ... Until". #}
{# "Until" is the anchor — always filled, defaults to the URL's #}
{# to_month or the current YYYY-MM when there's no filter. #}
{# "From" is optional — blank means a single-month report and the #}
{# JS submits from_month = to_month. #}
<div class="row g-2" id="popoverMonthFields">
<div class="col-6">
<label class="form-label small">
From
<span class="text-muted" style="font-size: 0.6rem; letter-spacing: 0;">(optional)</span>
</label>
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
<div class="form-text small"
style="font-size: 0.72rem; color: var(--text-tertiary);">
Leave blank for a single month
</div>
</div>
<div class="col-6">
{# Tooltip on the info icon avoids the inline hint #}
{# wrapping to two lines inside a narrow column. #}
{# Global Bootstrap tooltip initialiser (base.html) #}
{# picks up data-bs-toggle="tooltip" automatically. #}
<label class="form-label small">
Until
<i class="fas fa-info-circle ms-1"
data-bs-toggle="tooltip"
title="Single month select"
style="color: var(--text-tertiary); font-size: 0.85em; cursor: help;"></i>
</label>
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
</div>
</div>
<div class="row g-2 d-none" id="popoverCustomFields">
<div class="col-6">
<label class="form-label small">Start</label>
<input type="date" id="popoverStartDate" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">End</label>
<input type="date" id="popoverEndDate" class="form-control form-control-sm">
</div>
</div>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Projects pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-projects"
data-filter="projects"
aria-expanded="false"
aria-controls="popover-projects">
<i class="fas fa-folder me-1"></i>
<span class="filter-pill__label">{{ project_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">&times;</a>
<a href="?{{ query_string_without_project|default:query_string }}"
class="filter-pill__x"
aria-label="Clear project filter"
title="Clear project filter">&times;</a>
{% endif %}
</span>
<span class="filter-pill">
<i class="fas fa-users me-1"></i>{{ team_name }}
<div class="filter-popover" id="popover-projects" role="dialog" aria-label="Edit projects" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Projects</label>
<select id="popoverProjects" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
{% for p in projects %}
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Teams pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-teams"
data-filter="teams"
aria-expanded="false"
aria-controls="popover-teams">
<i class="fas fa-users me-1"></i>
<span class="filter-pill__label">{{ team_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">&times;</a>
<a href="?{{ query_string_without_team|default:query_string }}"
class="filter-pill__x"
aria-label="Clear team filter"
title="Clear team filter">&times;</a>
{% endif %}
</span>
<div class="filter-popover" id="popover-teams" role="dialog" aria-label="Edit teams" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Teams</label>
<select id="popoverTeams" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
{% for t in teams %}
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# No global Apply button — each popover's OK commits + reloads directly. #}
</div>
{# --- Cross-filter data for the JS module --- #}
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
{# --- Expose current URL filter state so the cross-filter can disable #}
{# dropdown options that are invalid given the OTHER pill's selection. #}
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
<!-- === PRINT HEADER === -->
<div class="d-none d-print-block mb-4">
<h2 class="text-center fw-bold mb-1">FoxFitt Construction &mdash; Payroll Report</h2>
@ -106,6 +246,7 @@
<tr>
<th>Project</th>
<th>Start</th>
<th>Last Activity</th>
<th class="text-end">Working Days</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Avg R / Working Day</th>
@ -116,6 +257,7 @@
<tr>
<td class="fw-medium">{{ item.project }}</td>
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
<td class="text-end">{{ item.working_days|default:"—" }}</td>
<td class="text-end fw-semibold">R {{ item.total|money }}</td>
<td class="text-end">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
@ -384,18 +526,326 @@
<div class="d-flex justify-content-between align-items-center d-print-none">
<a href="{% url 'home' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back to Dashboard</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent"><i class="fas fa-download me-1"></i>Download PDF</a>
</div>
</div>
</div>
<!-- === REPORT CONFIGURATION MODAL ===
Shared partial — same modal the Dashboard uses, so clicking
"New Report" here opens the familiar config screen without
navigating away. -->
{% include 'core/_report_config_modal.html' %}
{# === CHOICES.JS CDN — admin-only === #}
{# The pill popovers enhance their <select multiple> elements with #}
{# Choices.js. Falls back to a native multi-select if the CDN fails. #}
{% if user.is_staff or user.is_superuser %}
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
crossorigin="anonymous"
defer></script>
{% endif %}
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
{# Scoped IIFE; runs once on DOMContentLoaded. #}
{# #}
{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #}
{# (keeping other filters intact) and navigates → full SSR page reload. #}
{# Cancel just closes the popover. No "dirty" state, no global Apply. #}
{# #}
{# XSS-safe: textContent only; we never write user strings via innerHTML. #}
{% if user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Bail gracefully if Choices.js failed to load ---
if (typeof Choices === 'undefined') {
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
return;
}
// === CONTEXT from json_script tags ===
var pairsEl = document.getElementById('projectTeamPairs');
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
function toIntArr(s) {
return (JSON.parse(s || '[]') || [])
.map(function(v) { return parseInt(v, 10); })
.filter(function(n) { return !isNaN(n); });
}
// URL state used only for cross-filter option-disabling on popover open.
// (No pending-state diffing because OK always submits immediately.)
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
// --- Cross-filter lookup indices ---
var projectToTeams = {}, teamToProjects = {};
pairs.forEach(function(pair) {
var pid = pair.project_id, tid = pair.team_id;
if (!projectToTeams[pid]) projectToTeams[pid] = new Set();
if (!teamToProjects[tid]) teamToProjects[tid] = new Set();
projectToTeams[pid].add(tid);
teamToProjects[tid].add(pid);
});
// --- Choices.js instances (lazy init on first open) ---
var projectsChoices = null, teamsChoices = null;
// --- DOM refs ---
var pills = {
date: document.getElementById('filter-pill-date'),
projects: document.getElementById('filter-pill-projects'),
teams: document.getElementById('filter-pill-teams'),
};
var popovers = {
date: document.getElementById('popover-date'),
projects: document.getElementById('popover-projects'),
teams: document.getElementById('popover-teams'),
};
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// === INITIALISE DATE INPUTS from URL ===
var qs = new URLSearchParams(window.location.search);
var urlFromMonth = qs.get('from_month') || '';
var urlToMonth = qs.get('to_month') || '';
var urlStartDate = qs.get('start_date') || '';
var urlEndDate = qs.get('end_date') || '';
// "Until" is the anchor — always filled. Falls back to current month
// (YYYY-MM) when the URL has no to_month (e.g. no filters yet).
function currentYearMonth() {
var d = new Date();
var m = String(d.getMonth() + 1).padStart(2, '0');
return d.getFullYear() + '-' + m;
}
toMonthInput.value = urlToMonth || currentYearMonth();
// "From" is optional: blank signals single-month mode. Range URLs
// (from != to) fill it; single-month URLs (from == to) leave it blank.
fromMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlFromMonth;
startDateInput.value = urlStartDate;
endDateInput.value = urlEndDate;
var initialDateMode = (urlStartDate || urlEndDate) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
// === POPOVER OPEN / CLOSE ===
function closeAllPopovers(except) {
Object.keys(popovers).forEach(function(key) {
if (key !== except) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
}
});
}
function openPopover(key) {
closeAllPopovers(key);
popovers[key].hidden = false;
pills[key].setAttribute('aria-expanded', 'true');
// Lazy-init Choices.js the first time each multi-select is shown
if (key === 'projects' && !projectsChoices) {
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (key === 'teams' && !teamsChoices) {
teamsChoices = new Choices(document.getElementById('popoverTeams'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
// Cross-filter: disable options that are invalid given the OTHER pill's
// current URL selection. (e.g., open Teams with Project X in URL → hide
// teams that never logged on Project X.)
if (key === 'projects') applyCrossFilter('projects');
if (key === 'teams') applyCrossFilter('teams');
// Auto-open the Choices.js dropdown so options are visible
// immediately — the pill click means "show me the list." Without
// this, the user has to click the search input first, which feels
// like an extra step. Deferred one tick so it runs AFTER any
// cross-filter destroy/recreate above has settled.
if (key === 'projects' && projectsChoices) {
setTimeout(function() { projectsChoices.showDropdown(true); }, 0);
}
if (key === 'teams' && teamsChoices) {
setTimeout(function() { teamsChoices.showDropdown(true); }, 0);
}
}
// --- Pill click: toggle popover ---
Object.keys(pills).forEach(function(key) {
pills[key].addEventListener('click', function(ev) {
ev.stopPropagation();
var isOpen = !popovers[key].hidden;
if (isOpen) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
} else {
openPopover(key);
}
});
});
// Click outside the pill group closes all popovers
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
});
// Esc closes popovers
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllPopovers();
});
// === POPOVER OK / CANCEL HANDLERS ===
// OK submits immediately via navigation. Cancel just closes the popover;
// any Choices.js changes the user made are reset to URL state so the next
// open starts fresh.
document.querySelectorAll('.filter-popover').forEach(function(pop) {
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') submitDateFilter();
else if (pop.id === 'popover-projects') submitProjectsFilter();
else if (pop.id === 'popover-teams') submitTeamsFilter();
// Navigation happens inside submit functions — nothing else to do.
});
cancelBtn.addEventListener('click', function() {
// Reset Choices.js widgets to URL state in case the user had
// selected something. Date inputs reset on the next open via
// URL reload (no in-page state to revert otherwise).
if (pop.id === 'popover-projects' && projectsChoices) {
rebuildChoicesSelection(projectsChoices, urlProjects);
}
if (pop.id === 'popover-teams' && teamsChoices) {
rebuildChoicesSelection(teamsChoices, urlTeams);
}
closeAllPopovers();
});
});
// === DATE MODE TOGGLE inside the date popover ===
document.getElementById('popDateModeMonth').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.remove('d-none');
document.getElementById('popoverCustomFields').classList.add('d-none');
});
document.getElementById('popDateModeCustom').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.add('d-none');
document.getElementById('popoverCustomFields').classList.remove('d-none');
});
// === SUBMIT HANDLERS ===
// Each rebuilds the URL using the current popover's inputs (keeping the
// other filters intact) and navigates → full SSR page reload. Matches
// the original modal's contract; the report re-renders server-side.
function submitDateFilter() {
var params = new URLSearchParams(window.location.search);
// Clear all date-family params (current + legacy modal-form params)
params.delete('from_month');
params.delete('to_month');
params.delete('start_date');
params.delete('end_date');
params.delete('date_mode');
params.delete('search_terms');
var isCustom = document.getElementById('popDateModeCustom').checked;
if (isCustom) {
if (startDateInput.value) params.set('start_date', startDateInput.value);
if (endDateInput.value) params.set('end_date', endDateInput.value);
} else {
// Month mode: "Until" is the anchor (always required; defaults
// to current month in the picker); "From" is optional (blank =
// single-month, so use Until for both ends).
var to = toMonthInput.value || currentYearMonth();
var from = fromMonthInput.value || to;
if (from) params.set('from_month', from);
if (to) params.set('to_month', to);
}
navigateTo(params);
}
function submitProjectsFilter() {
if (!projectsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('project');
projectsChoices.getValue(true).forEach(function(id) {
params.append('project', id);
});
navigateTo(params);
}
function submitTeamsFilter() {
if (!teamsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('team');
teamsChoices.getValue(true).forEach(function(id) {
params.append('team', id);
});
navigateTo(params);
}
function navigateTo(params) {
window.location = window.location.pathname + '?' + params.toString();
}
// === CROSS-FILTER ===
// Read-time only: when a popover opens, disable options that are invalid
// given the OTHER pill's current URL selection. Since each OK submits to
// URL directly, we don't need runtime auto-removal or pending-state sync.
function applyCrossFilter(justOpened) {
if (justOpened === 'projects' && projectsChoices) {
if (urlTeams.length === 0) return; // no constraint
var validPids = new Set();
urlTeams.forEach(function(tid) {
if (teamToProjects[tid]) {
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
}
});
var sel = document.getElementById('popoverProjects');
Array.from(sel.options).forEach(function(opt) {
var pid = parseInt(opt.value, 10);
// Always leave currently-URL-selected items enabled so they
// remain visible as removable chips — user can unpick them.
opt.disabled = !validPids.has(pid) && !urlProjects.includes(pid);
});
projectsChoices.destroy();
projectsChoices = new Choices(sel, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (justOpened === 'teams' && teamsChoices) {
if (urlProjects.length === 0) return;
var validTids = new Set();
urlProjects.forEach(function(pid) {
if (projectToTeams[pid]) {
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
}
});
var selT = document.getElementById('popoverTeams');
Array.from(selT.options).forEach(function(opt) {
var tid = parseInt(opt.value, 10);
opt.disabled = !validTids.has(tid) && !urlTeams.includes(tid);
});
teamsChoices.destroy();
teamsChoices = new Choices(selT, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
}
// --- Helper: rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
}
});
</script>
{% endif %}
{% endblock %}

View File

@ -27,6 +27,22 @@ def money(value):
return formatted.replace(",", " ")
# === type_slug filter ===
# Converts adjustment-type labels like "Advance Payment" to
# CSS-class-friendly slugs like "advance-payment". Used by the Adjustments
# tab to pick the right colour badge class per row.
@register.filter
def type_slug(value):
"""Return a hyphen-separated lowercase version of `value`.
Used in templates: `<span class="badge-type-{{ adj.type|type_slug }}">`.
Returns '' for None / empty no class generated, no crash.
"""
if not value:
return ''
return value.lower().replace(' ', '-')
@register.filter
def dictlookup(d, key):
"""Look up a dict value by a dynamic key.
@ -42,3 +58,50 @@ def dictlookup(d, key):
return d.get(key)
except (AttributeError, TypeError):
return None
# === url_replace tag ===
# Returns the current request's querystring with one key replaced/removed.
# Used by filter toggles and pagination links so they don't accumulate
# repeated keys (e.g. ?page=2&page=3) on each click. Pass value='' (or None)
# to drop the key entirely; pass a real value to set/replace it.
@register.simple_tag
def url_replace(request, key, value):
"""Clone the current GET querystring, set/remove one key, and return it encoded.
Plain-English: Django templates can't easily rebuild a querystring with
one value swapped out. This tag does it hand it the request, the key
you want to change, and the new value (or empty string to drop it), and
you get back a ready-to-paste `?...` string. Prevents the `?page=2&page=3`
stacking bug that happens if you just append `&page=X` to the raw
`request.GET.urlencode`.
"""
qd = request.GET.copy()
if value == '' or value is None:
qd.pop(key, None)
else:
qd[key] = str(value)
return qd.urlencode()
# === money_abs filter ===
# Formats the ABSOLUTE value of a Decimal in ZAR style. Callers handle
# the sign explicitly via template logic (see the Adjustments-tab group
# header which renders "+R 800.00" for a positive net_sum and
# "-R 100.00" for a negative one). Pairs with the existing `money`
# filter and avoids rendering "R -100.00" which reads as a minus
# squished against the R.
@register.filter
def money_abs(value):
"""Format the absolute value of a number in ZAR money style.
Plain-English: same as `money`, but always returns the positive
version of the number. The caller is responsible for emitting its
own `+` or `-` sign in the template. Useful when the sign needs to
appear to the LEFT of the `R` (e.g. `+R 800.00`) rather than
attached to the number (which would render `R -800.00` and read
confusingly).
"""
if value is None:
return ''
return money(abs(value))

View File

@ -9,7 +9,7 @@ from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment, Loan
from core.views import _build_work_log_payroll_context, _build_report_context
@ -692,6 +692,12 @@ class ChapterOneEnrichmentTests(TestCase):
self.assertEqual(by_name['C1']['working_days'], 4)
self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))
# last_activity = the most recent WorkLog.date (4th of March here)
self.assertEqual(
by_name['C1']['last_activity'], datetime.date(2026, 3, 4),
'alltime_projects rows should expose the most-recent log date '
'so the report can show "Last Activity" per project'
)
# =============================================================================
@ -749,3 +755,485 @@ class ReportMultiFilterTests(TestCase):
self.assertEqual(len(ctx['worker_breakdown']), 1)
# All three records are for the same worker, R 100 each = R 300
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))
# =============================================================================
# === TESTS FOR INLINE FILTERS (Report Page) ===
# Pill-as-dropdown + cross-filter feature. Most behaviour is template/JS;
# the only backend surface is the project_team_pairs_json context key that
# powers the client-side Team<->Project cross-filter.
# =============================================================================
class InlineFiltersPairsContextTests(TestCase):
"""Report view must serialise distinct (project_id, team_id) pairs for
the pill-popover cross-filter JS."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin-if', password='pass', is_staff=True
)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
self.w = Worker.objects.create(
name='W', id_number='W1', monthly_salary=Decimal('4000')
)
# Log t1 on p1, t2 on p2 — so pairs should be [(p1,t1), (p2,t2)]
for proj, team in [(self.p1, self.t1), (self.p2, self.t2)]:
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=proj, team=team, supervisor=self.admin,
)
log.workers.add(self.w)
def test_pairs_context_key_populated(self):
# The context value is a raw Python list of dicts; Django's
# |json_script filter handles the single JSON serialisation at
# template render time (no double-encoding).
self.client.login(username='admin-if', password='pass')
url = reverse('generate_report')
resp = self.client.get(url + '?from_month=2026-03&to_month=2026-04')
self.assertEqual(resp.status_code, 200)
pairs = resp.context['project_team_pairs_json']
# Each entry has both project_id and team_id
for p in pairs:
self.assertIn('project_id', p)
self.assertIn('team_id', p)
# Expected pairs (as tuples for set comparison)
pair_set = {(p['project_id'], p['team_id']) for p in pairs}
self.assertIn((self.p1.id, self.t1.id), pair_set)
self.assertIn((self.p2.id, self.t2.id), pair_set)
def test_pairs_excludes_null_project_or_team(self):
"""Logs with null project or null team should not appear in pairs."""
# Add a log with team=None
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 2),
project=self.p1, team=None, supervisor=self.admin,
)
log.workers.add(self.w)
self.client.login(username='admin-if', password='pass')
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
pairs = resp.context['project_team_pairs_json']
# No pair should have team_id=None
self.assertTrue(all(p['team_id'] is not None for p in pairs))
def test_pairs_renders_as_valid_json_in_template(self):
"""End-to-end: the rendered HTML must contain a single, valid JSON
array inside the <script id="projectTeamPairs"> tag NOT a
JSON-encoded string (which was the bug that broke all pill
interactions before the context key was changed from
`json.dumps(pairs)` to raw `pairs`)."""
import json as _json
import re
self.client.login(username='admin-if', password='pass')
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
html = resp.content.decode('utf-8')
# Extract the JSON payload inside <script id="projectTeamPairs">...</script>
match = re.search(
r'<script id="projectTeamPairs"[^>]*>(.*?)</script>',
html, re.DOTALL
)
self.assertIsNotNone(match, 'projectTeamPairs <script> tag missing')
payload = match.group(1).strip()
# Must parse to a LIST, not a string.
parsed = _json.loads(payload)
self.assertIsInstance(parsed, list,
"Double-encoded JSON regression: browser's JSON.parse "
"would return a string here, killing pairs.forEach() in the "
"pill-popover JS. See 2026-04-23 bugfix.")
# And the list members must be dicts with project_id + team_id
for entry in parsed:
self.assertIsInstance(entry, dict)
self.assertIn('project_id', entry)
self.assertIn('team_id', entry)
def test_pickers_and_pairs_are_date_scoped(self):
"""Checkpoint-1 refinement: projects/teams lists + the pair map
include only entries with WorkLog activity INSIDE the selected
date range NOT entire-history entries. Entries that are in the
URL's `?project=` or `?team=` selection are always preserved,
though, so the user's pick can never vanish."""
# Add a third project/team that ONLY worked outside the report window
out_project = Project.objects.create(name='P-out')
out_team = Team.objects.create(name='T-out', supervisor=self.admin)
out_log = WorkLog.objects.create(
date=datetime.date(2026, 1, 15), # outside March window
project=out_project, team=out_team, supervisor=self.admin,
)
out_log.workers.add(self.w)
self.client.login(username='admin-if', password='pass')
# Request only March 2026 — Jan logs should be excluded
resp = self.client.get(
reverse('generate_report') + '?from_month=2026-03&to_month=2026-03'
)
self.assertEqual(resp.status_code, 200)
# Picker lists: out-of-range project + team should NOT appear
project_ids = {p.id for p in resp.context['projects']}
team_ids = {t.id for t in resp.context['teams']}
self.assertIn(self.p1.id, project_ids)
self.assertIn(self.p2.id, project_ids)
self.assertNotIn(out_project.id, project_ids,
'Out-of-range project must not appear in the date-scoped list')
self.assertIn(self.t1.id, team_ids)
self.assertIn(self.t2.id, team_ids)
self.assertNotIn(out_team.id, team_ids,
'Out-of-range team must not appear in the date-scoped list')
# Pair map: must also be date-scoped
pair_set = {(p['project_id'], p['team_id'])
for p in resp.context['project_team_pairs_json']}
self.assertNotIn((out_project.id, out_team.id), pair_set,
'Out-of-range pair must not appear in the cross-filter map')
def test_url_selected_projects_survive_even_out_of_range(self):
"""A project explicitly in the URL's ?project= selection must
remain in the picker list even if it has no logs in the current
date range otherwise the user couldn't see (or deselect) their
own pick."""
out_project = Project.objects.create(name='P-out')
# Never logs anything in any date range
self.client.login(username='admin-if', password='pass')
resp = self.client.get(
reverse('generate_report')
+ '?from_month=2026-03&to_month=2026-03'
+ f'&project={out_project.id}'
)
self.assertEqual(resp.status_code, 200)
project_ids = {p.id for p in resp.context['projects']}
self.assertIn(out_project.id, project_ids,
'URL-selected project must survive the date-scope filter')
# =============================================================================
# === TESTS FOR |type_slug FILTER ===
# Used by Adjustments tab to build CSS class names from type labels.
# =============================================================================
class TypeSlugFilterTests(TestCase):
"""format_tags.type_slug converts adjustment-type labels to slugs."""
def test_spaces_become_hyphens_and_lowercased(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug('Advance Payment'), 'advance-payment')
self.assertEqual(type_slug('New Loan'), 'new-loan')
self.assertEqual(type_slug('Bonus'), 'bonus')
def test_empty_or_none_returns_empty_string(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug(''), '')
self.assertEqual(type_slug(None), '')
def test_idempotent_on_already_slugged(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug('bonus'), 'bonus')
# =============================================================================
# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) ===
# Covers the new tab's backend: filters, sort, stats, pagination.
# Each test creates its own fresh fixture via setUp.
# NOTE: PayrollRecord only accepts worker/date/amount_paid (see core/models.py).
# The plan spec used days_worked/total_amount — those do NOT exist. Adapted.
# =============================================================================
class AdjustmentsTabTests(TestCase):
"""New Adjustments tab on /payroll/?status=adjustments."""
def setUp(self):
self.admin = User.objects.create_user(
username='adj-admin', password='pass', is_staff=True, is_superuser=True
)
self.sup = User.objects.create_user(
username='adj-sup', password='pass'
)
self.w1 = Worker.objects.create(
name='Alice', id_number='A1', monthly_salary=Decimal('4000')
)
self.w2 = Worker.objects.create(
name='Bob', id_number='B1', monthly_salary=Decimal('4000')
)
# Two teams, BOTH workers in BOTH teams, so the naive M2M JOIN
# multiplies rows by team count. Exercises the subquery fix.
self.team = Team.objects.create(name='Alpha', supervisor=self.admin)
self.team2 = Team.objects.create(name='Beta', supervisor=self.admin)
self.team.workers.add(self.w1, self.w2)
self.team2.workers.add(self.w1, self.w2)
self.proj = Project.objects.create(name='Site X')
# 3 unpaid adjustments — 1 bonus Alice, 1 bonus Bob, 1 deduction Alice
self.a1 = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Bonus',
amount=Decimal('500'), date=datetime.date(2026, 4, 10),
description='April bonus',
)
self.a2 = PayrollAdjustment.objects.create(
worker=self.w2, project=self.proj, type='Bonus',
amount=Decimal('300'), date=datetime.date(2026, 4, 11),
description='Project milestone',
)
self.a3 = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Deduction',
amount=Decimal('100'), date=datetime.date(2026, 3, 28),
description='Missing tool',
)
self.url = reverse('payroll_dashboard') + '?status=adjustments'
def _login_admin(self):
self.client.login(username='adj-admin', password='pass')
def test_admin_sees_adjustments_tab(self):
self._login_admin()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.context['active_tab'], 'adjustments')
# All 3 fixture adjustments should be in the listing
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
def test_supervisor_forbidden(self):
self.client.login(username='adj-sup', password='pass')
resp = self.client.get(self.url)
# Existing payroll_dashboard pattern: non-admin is redirected home
self.assertEqual(resp.status_code, 302)
def test_type_multi_filter(self):
"""?type=Bonus&type=Deduction returns the UNION (3 rows: 2 bonuses + 1
deduction), not the intersection."""
self._login_admin()
resp = self.client.get(self.url + '&type=Bonus&type=Deduction')
self.assertEqual(resp.context['adj_total_count'], 3)
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertEqual(ids, {self.a1.id, self.a2.id, self.a3.id})
def test_worker_multi_filter(self):
self._login_admin()
resp = self.client.get(self.url + f'&worker={self.w1.id}')
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertIn(self.a1.id, ids)
self.assertNotIn(self.a2.id, ids)
self.assertIn(self.a3.id, ids)
def test_team_filter_uses_subquery_no_inflation(self):
"""Filtering by team must NOT multiply rows. With 2 teams x 2 workers x 3
adjustments, a naive worker__teams__id__in filter would return 6 inflated
rows; the subquery pattern returns the true 3. See CLAUDE.md ORM gotcha."""
self._login_admin()
resp = self.client.get(
self.url + f'&team={self.team.id}&team={self.team2.id}'
)
# .count() at the queryset level would blow up under inflation —
# asserting it guards against regressions more strictly than checking
# the paginator's object_list length.
self.assertEqual(resp.context['adj_total_count'], 3)
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
def test_status_filter_unpaid(self):
self._login_admin()
# Mark a1 as paid — PayrollRecord fields are worker/date/amount_paid
pr = PayrollRecord.objects.create(
worker=self.w1, date=datetime.date(2026, 4, 15),
amount_paid=Decimal('4000'),
)
self.a1.payroll_record = pr
self.a1.save()
resp = self.client.get(self.url + '&adj_status=unpaid')
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertNotIn(self.a1.id, ids)
self.assertIn(self.a2.id, ids)
self.assertIn(self.a3.id, ids)
def test_date_range_filter(self):
self._login_admin()
# March 1 to March 31 -> only a3 (dated 28 Mar)
resp = self.client.get(
self.url + '&adj_date_from=2026-03-01&adj_date_to=2026-03-31'
)
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertEqual(ids, {self.a3.id})
def test_stats_scoped_to_filtered_set(self):
self._login_admin()
resp = self.client.get(self.url + '&type=Bonus')
# 2 bonuses, 0 paid, total R 800 additive, 0 deductive
self.assertEqual(resp.context['adj_total_count'], 2)
self.assertEqual(resp.context['adj_unpaid_count'], 2)
self.assertEqual(resp.context['adj_additive_sum'], Decimal('800.00'))
self.assertEqual(resp.context['adj_deductive_sum'], Decimal('0.00'))
def test_group_by_type(self):
self._login_admin()
resp = self.client.get(self.url + '&group_by=type')
groups = resp.context['adj_groups']
self.assertIsNotNone(groups)
labels = {g['label'] for g in groups}
self.assertEqual(labels, {'Bonus', 'Deduction'})
bonus_group = next(g for g in groups if g['label'] == 'Bonus')
self.assertEqual(bonus_group['count'], 2)
self.assertEqual(bonus_group['net_sum'], Decimal('800.00')) # +R 500 + +R 300
deduction_group = next(g for g in groups if g['label'] == 'Deduction')
self.assertEqual(deduction_group['net_sum'], Decimal('-100.00'))
# Groups must be ordered by descending |net_sum| — biggest impact
# first. |800| > |100| so Bonus must come before Deduction.
self.assertEqual(groups[0]['label'], 'Bonus')
def test_group_by_worker(self):
self._login_admin()
resp = self.client.get(self.url + '&group_by=worker')
groups = resp.context['adj_groups']
self.assertIsNotNone(groups)
labels = {g['label'] for g in groups}
self.assertEqual(labels, {'Alice', 'Bob'})
alice = next(g for g in groups if g['label'] == 'Alice')
# Alice: +R 500 bonus + (-R 100) deduction = +R 400 net
self.assertEqual(alice['count'], 2)
self.assertEqual(alice['net_sum'], Decimal('400.00'))
def test_bulk_delete_only_affects_unpaid(self):
"""POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
deletes ONLY the unpaid rows. Paid rows are untouched (payroll
history is immutable)."""
self._login_admin()
# Pay a1 (leave a2, a3 unpaid)
pr = PayrollRecord.objects.create(
worker=self.w1, date=datetime.date(2026, 4, 15),
amount_paid=Decimal('4000'),
)
self.a1.payroll_record = pr
self.a1.save()
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [self.a1.id, self.a2.id, self.a3.id]},
)
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body['deleted'], 2)
self.assertEqual(body['requested'], 3)
# a1 survives (paid), a2 + a3 gone
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a3.id).exists())
def test_bulk_delete_requires_admin(self):
"""Non-admin supervisors cannot bulk-delete adjustments."""
self.client.login(username='adj-sup', password='pass')
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [self.a1.id]},
)
self.assertEqual(resp.status_code, 403)
# a1 still present
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
def test_bulk_delete_cascades_new_loan(self):
"""Bulk-deleting a 'New Loan' adjustment must also delete its
linked Loan row AND any still-unpaid Loan Repayment adjustments
same cascade as the single-row delete_adjustment view. Without
this, the bulk endpoint would orphan Loan rows and leave pending
repayments in place."""
# Create a Loan + New Loan adjustment + unpaid repayment
loan = Loan.objects.create(
worker=self.w1,
principal_amount=Decimal('1000'),
remaining_balance=Decimal('1000'),
date=datetime.date(2026, 4, 1),
loan_type='loan',
)
new_loan_adj = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='New Loan',
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
description='Test loan', loan=loan,
)
unpaid_repayment = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Loan Repayment',
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
description='First repayment', loan=loan,
)
self._login_admin()
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [new_loan_adj.id]},
)
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body['deleted'], 1)
# New Loan adjustment gone
self.assertFalse(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
# Linked Loan row gone (cascade)
self.assertFalse(Loan.objects.filter(id=loan.id).exists())
# Unpaid repayment gone (cascade)
self.assertFalse(PayrollAdjustment.objects.filter(id=unpaid_repayment.id).exists())
def test_bulk_delete_skips_loan_with_paid_repayments(self):
"""If a 'New Loan' has any paid repayments, bulk-delete must
refuse to delete it (would lose audit trail). Other rows in the
batch are unaffected."""
loan = Loan.objects.create(
worker=self.w1,
principal_amount=Decimal('1000'),
remaining_balance=Decimal('500'),
date=datetime.date(2026, 4, 1),
loan_type='loan',
)
new_loan_adj = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='New Loan',
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
description='Test loan', loan=loan,
)
# One PAID repayment
pr = PayrollRecord.objects.create(
worker=self.w1, date=datetime.date(2026, 5, 1),
amount_paid=Decimal('500'),
)
PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Loan Repayment',
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
description='Paid', loan=loan, payroll_record=pr,
)
self._login_admin()
# Send the New Loan plus a2 (unpaid Bonus) — expect only a2 to delete
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [new_loan_adj.id, self.a2.id]},
)
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body['deleted'], 1) # only a2
self.assertEqual(body['requested'], 2)
self.assertEqual(body['skipped_reasons'], {'has_paid_repayments': 1})
# New Loan survives, Loan survives, a2 gone
self.assertTrue(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
self.assertTrue(Loan.objects.filter(id=loan.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
def test_team_worker_pairs_json_context_key(self):
"""Cross-filter map is a raw Python list of {team_id, worker_id}
dicts. Django's |json_script filter handles serialisation at
template render time (no double-encoding see the 2026-04-23
inline-filters regression test)."""
self._login_admin()
resp = self.client.get(self.url)
pairs = resp.context['team_worker_pairs_json']
self.assertIsInstance(pairs, list)
for entry in pairs:
self.assertIn('team_id', entry)
self.assertIn('worker_id', entry)
# Our fixture: two teams (Alpha + Beta) with both workers in each
pair_set = {(p['team_id'], p['worker_id']) for p in pairs}
self.assertIn((self.team.id, self.w1.id), pair_set)
self.assertIn((self.team.id, self.w2.id), pair_set)
self.assertIn((self.team2.id, self.w1.id), pair_set)
self.assertIn((self.team2.id, self.w2.id), pair_set)

View File

@ -54,6 +54,9 @@ urlpatterns = [
# Delete an unpaid adjustment
path('payroll/adjustment/<int:adj_id>/delete/', views.delete_adjustment, name='delete_adjustment'),
# Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),
# Preview a worker's payslip (AJAX — returns JSON)
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),

View File

@ -17,6 +17,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_POST
from django.urls import reverse
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
@ -2138,6 +2139,15 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
start_dates = dict(
Project.objects.values_list('name', 'start_date')
)
# Lookup the most recent WorkLog.date for each project (for the new
# "Last Activity" column — helps Konrad spot which projects are dormant
# without having to scroll through date pickers).
last_activity = dict(
all_time_logs.filter(project__isnull=False)
.values('project__name')
.annotate(last=Max('date'))
.values_list('project__name', 'last')
)
alltime_projects = []
for row in alltime_projects_raw:
name = row['project']
@ -2149,6 +2159,7 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
'worker_days': row['worker_days'],
'total': total,
'start_date': start_dates.get(name), # may be None
'last_activity': last_activity.get(name), # may be None
'working_days': wdays,
'avg_per_working_day': avg,
})
@ -2393,13 +2404,56 @@ def generate_report(request):
return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')
# Pass projects and teams so the "New Report" modal's dropdowns can
# populate (same lists the Dashboard modal uses)
context['projects'] = Project.objects.all().order_by('name')
context['teams'] = Team.objects.all().order_by('name')
# For the modal's <select multiple> pre-selection: stringify the IDs so
# the template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
# comparison works (Django templates compare strings to strings).
# === Date-scoped pickers + cross-filter ===
# Admin UX decision (Konrad, 2026-04-23 Checkpoint 1 feedback):
# The project/team pills should only show entries that actually have
# WorkLog activity within the currently-selected date range. Same for
# the (project_id, team_id) pair map that powers the cross-filter.
# Rationale: "show me teams I'm actually looking at right now," not
# "every team that ever existed."
#
# Guarantee: a project or team that's currently in the URL selection
# MUST remain in the list — even if it has no logs in this window —
# so the user can always see and deselect their own picks.
logs_in_range = WorkLog.objects.filter(
date__gte=start_date, date__lte=end_date,
)
project_ids_in_range = set(
logs_in_range.values_list('project_id', flat=True).distinct()
)
team_ids_in_range = set(
logs_in_range.values_list('team_id', flat=True).distinct()
)
# Logs without a project/team contribute a None — drop it
project_ids_in_range.discard(None)
team_ids_in_range.discard(None)
# Union with the user's URL selections so picks never vanish
selected_p_int = {int(x) for x in (project_ids or [])}
selected_t_int = {int(x) for x in (team_ids or [])}
project_ids_to_show = project_ids_in_range | selected_p_int
team_ids_to_show = team_ids_in_range | selected_t_int
# Cross-filter pair map, scoped to the same date range
# (raw Python list — |json_script in the template handles serialisation)
pairs = list(
logs_in_range
.filter(project__isnull=False, team__isnull=False)
.values('project_id', 'team_id')
.distinct()
)
context['project_team_pairs_json'] = pairs
# Picker lists (only projects/teams with activity in this window,
# union'd with current URL selection)
context['projects'] = (
Project.objects.filter(id__in=project_ids_to_show).order_by('name')
)
context['teams'] = (
Team.objects.filter(id__in=team_ids_to_show).order_by('name')
)
# Template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
# comparison needs strings on both sides.
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
@ -2480,6 +2534,54 @@ def toggle_active(request, model_name, item_id):
return JsonResponse({'error': 'Item not found'}, status=404)
# =============================================================================
# === ADJUSTMENT GROUPING HELPER ===
# Used by the Adjustments tab's By Type / By Worker render path.
# Plain-English: takes a flat list of PayrollAdjustment rows and regroups
# them into buckets keyed by adjustment type or by worker. The result is
# a list of group-dicts the template can iterate, each carrying a label,
# CSS-friendly slug, the list of rows in the bucket, a count, and the
# net signed sum of amounts (additives count +, deductives count -).
# =============================================================================
def _group_adjustments(adjustments, group_by):
"""Regroup a flat list/queryset of PayrollAdjustment into buckets.
`group_by` is 'type' or 'worker'. Returns a list of dicts:
{'label', 'slug', 'rows', 'count', 'net_sum'}
Ordered by descending magnitude of net_sum so the highest-impact
bucket sits at the top of the view (big groups first).
"""
from collections import defaultdict
buckets = defaultdict(list)
for adj in adjustments:
key = adj.type if group_by == 'type' else adj.worker_id
buckets[key].append(adj)
groups = []
for key, rows in buckets.items():
if group_by == 'type':
label = key
slug = key.lower().replace(' ', '-')
else: # worker
label = rows[0].worker.name
slug = f'worker-{key}'
net_sum = sum(
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
for r in rows
)
groups.append({
'label': label,
'slug': slug,
'rows': rows,
'count': len(rows),
'net_sum': net_sum,
})
groups.sort(key=lambda g: -abs(g['net_sum']))
return groups
# =============================================================================
# === PAYROLL DASHBOARD ===
# The main payroll page. Shows per-worker breakdown of what's owed,
@ -2843,6 +2945,158 @@ def payroll_dashboard(request):
'active_loans_count': active_loans_count,
'active_loans_balance': active_loans_balance,
}
# =========================================================================
# === ADJUSTMENTS TAB CONTEXT ===
# This block only runs when the user is on the Adjustments tab
# (i.e. the URL has ?status=adjustments). It builds a filtered, sorted,
# paginated list of adjustments plus the little stats cards above it.
#
# Group-by rendering, bulk-select, and Team->Workers cross-filter
# will be added in later tasks — this task just covers the basic data.
# =========================================================================
if status_filter == 'adjustments':
from django.core.paginator import Paginator
from django.utils.dateparse import parse_date
# --- Read the filter choices the user picked from the URL ---
# Lists come in as ?type=Bonus&type=Deduction etc.
type_filter = request.GET.getlist('type')
worker_filter = [
int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()
]
team_filter = [
int(v) for v in request.GET.getlist('team') if v.strip().isdigit()
]
adj_status = request.GET.get('adj_status', '').strip()
adj_date_from = request.GET.get('adj_date_from', '').strip()
adj_date_to = request.GET.get('adj_date_to', '').strip()
sort_col = request.GET.get('sort', 'date').strip()
sort_order = request.GET.get('order', 'desc').strip()
# --- Base queryset with eager-loading of related tables ---
# select_related pulls worker/project/payment in the same SQL query
# so we don't hit the database once per row later.
adjustments = PayrollAdjustment.objects.select_related(
'worker', 'project', 'payroll_record'
).prefetch_related('worker__teams')
# --- Apply each filter only if the user actually set one ---
if type_filter:
adjustments = adjustments.filter(type__in=type_filter)
if worker_filter:
adjustments = adjustments.filter(worker_id__in=worker_filter)
if team_filter:
# SUBQUERY PATTERN (CLAUDE.md "M2M filter + aggregate inflation"):
# Joining straight on workers__teams would multiply the row count
# if a worker is on multiple teams, so we pick the matching worker
# IDs in a subquery first and then filter the outer queryset
# without any JOIN expansion.
adjustments = adjustments.filter(
worker__in=Worker.objects.filter(
teams__id__in=team_filter
).values('id')
)
if adj_status == 'unpaid':
adjustments = adjustments.filter(payroll_record__isnull=True)
elif adj_status == 'paid':
adjustments = adjustments.filter(payroll_record__isnull=False)
if adj_date_from:
parsed = parse_date(adj_date_from)
if parsed:
adjustments = adjustments.filter(date__gte=parsed)
if adj_date_to:
parsed = parse_date(adj_date_to)
if parsed:
adjustments = adjustments.filter(date__lte=parsed)
# --- Sort the results ---
# The URL's "sort" value is a short label; translate it to the
# actual database column. Unknown values fall back to date.
sort_map = {
'date': 'date',
'worker': 'worker__name',
'amount': 'amount',
'status': 'payroll_record',
}
sort_field = sort_map.get(sort_col, 'date')
if sort_order == 'desc':
sort_field = '-' + sort_field
# Secondary key "-id" keeps rows in a stable order when the
# main sort key has ties (e.g. two adjustments on the same date).
adjustments = adjustments.order_by(sort_field, '-id')
# --- Stats cards (all computed BEFORE pagination) ---
# These numbers always reflect what the current filter produces,
# not just what fits on the current page.
adj_total_count = adjustments.count()
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
adj_unpaid_count = unpaid_qs.count()
adj_unpaid_sum = unpaid_qs.aggregate(
total=Sum('amount')
)['total'] or Decimal('0.00')
adj_additive_sum = adjustments.filter(
type__in=ADDITIVE_TYPES
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
adj_deductive_sum = adjustments.filter(
type__in=DEDUCTIVE_TYPES
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
# --- Group-by rendering (optional; None = flat view) ---
# When the user clicks the "By Type" or "By Worker" toggle above
# the table, we bucket the FULL filtered queryset (not the paginated
# slice) so each group's row-count and net-sum reflect the whole
# filter, not just whatever landed on this page. Pagination is
# suppressed in the template when grouped (the group headers act
# as their own navigation).
group_by = request.GET.get('group_by', '').strip()
adj_groups = None
if group_by in ('type', 'worker'):
adj_groups = _group_adjustments(list(adjustments), group_by)
# --- Pagination: 50 rows per page (flat view only) ---
paginator = Paginator(adjustments, 50)
adj_page = paginator.get_page(request.GET.get('page', 1))
# --- Everything the Adjustments tab template will need ---
context.update({
'adj_page': adj_page,
'adj_groups': adj_groups,
'adj_total_count': adj_total_count,
'adj_unpaid_count': adj_unpaid_count,
'adj_unpaid_sum': adj_unpaid_sum,
'adj_additive_sum': adj_additive_sum,
'adj_deductive_sum': adj_deductive_sum,
'adj_filter_values': {
'type': type_filter,
'worker': worker_filter,
'team': team_filter,
'adj_status': adj_status,
'adj_date_from': adj_date_from,
'adj_date_to': adj_date_to,
'sort': sort_col,
'order': sort_order,
'group_by': group_by,
},
# Flat list of type labels for the Adjustments tab filter dropdown.
# Stored under a separate key so we don't clobber the existing
# 'adjustment_types' context var (which is TYPE_CHOICES tuples
# used by the Add/Edit adjustment modals).
'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES),
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
# Task 4 will use this to decide +/- signs on each row.
'additive_types': list(ADDITIVE_TYPES),
# === CROSS-FILTER SOURCE: (team_id, worker_id) PAIRS ===
# Consumed by the popover JS to disable Workers checkboxes that
# aren't in any currently-URL-selected team. Raw Python list
# — |json_script in the template handles safe serialisation
# (NOT json.dumps — see the 2026-04-23 inline-filters regression).
'team_worker_pairs_json': list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
),
})
return render(request, 'core/payroll_dashboard.html', context)
@ -3647,6 +3901,63 @@ def edit_adjustment(request, adj_id):
# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime.
# =============================================================================
# =============================================================================
# === ADJUSTMENT CASCADE DELETE HELPER ===
# Shared by delete_adjustment (single row) and bulk_delete_adjustments (many
# rows) so both paths have identical semantics. "New Loan" and "Advance
# Payment" each own a linked Loan row that needs teardown; "Overtime" needs
# its worker un-priced from the WorkLog. Without this helper, bulk-delete
# would orphan Loan rows and leave priced_workers stale.
# =============================================================================
def _delete_adjustment_with_cascade(adj):
"""Delete one PayrollAdjustment, cascading through its linked objects.
Returns a tuple `(ok: bool, reason: str or None)`:
- `(True, None)` row deleted successfully (with any cascades done)
- `(False, 'paid')` adjustment already paid; refuse
- `(False, 'has_paid_repayments')` linked Loan has paid repayments;
deleting it would lose the repayment audit trail
Cascade rules:
- New Loan / Advance Payment: delete the linked `Loan` row plus any
still-unpaid repayment adjustments. If ANY repayment has already
been paid, abort (otherwise we'd lose history of money that
already moved).
- Overtime: remove the worker from work_log.priced_workers so the
overtime can be re-priced cleanly later.
- Other types: plain delete, no cascade.
"""
if adj.payroll_record is not None:
return False, 'paid'
adj_type = adj.type
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
paid_repayments = PayrollAdjustment.objects.filter(
loan=adj.loan,
type=repayment_type,
payroll_record__isnull=False,
)
if paid_repayments.exists():
return False, 'has_paid_repayments'
# Delete all still-unpaid repayments, then the Loan itself
PayrollAdjustment.objects.filter(
loan=adj.loan,
type=repayment_type,
payroll_record__isnull=True,
).delete()
adj.loan.delete()
elif adj_type == 'Overtime' and adj.work_log:
# "Un-price" the overtime — worker can be re-priced cleanly later
adj.work_log.priced_workers.remove(adj.worker)
adj.delete()
return True, None
@login_required
def delete_adjustment(request, adj_id):
if request.method != 'POST':
@ -3655,52 +3966,83 @@ def delete_adjustment(request, adj_id):
return HttpResponseForbidden("Not authorized.")
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
# Can't delete adjustments that have been paid
if adj.payroll_record is not None:
messages.error(request, 'Cannot delete a paid adjustment.')
return redirect('payroll_dashboard')
adj_type = adj.type
worker_name = adj.worker.name
# === CASCADE DELETE for New Loan and Advance Payment ===
# Both create Loan records that need cleanup when deleted.
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
# Determine which repayment type to look for
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
# Check if any paid repayments exist for this loan/advance
paid_repayments = PayrollAdjustment.objects.filter(
loan=adj.loan,
type=repayment_type,
payroll_record__isnull=False,
)
if paid_repayments.exists():
ok, reason = _delete_adjustment_with_cascade(adj)
if not ok:
if reason == 'paid':
messages.error(request, 'Cannot delete a paid adjustment.')
elif reason == 'has_paid_repayments':
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
messages.error(
request,
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
)
return redirect('payroll_dashboard')
return redirect('payroll_dashboard')
# Delete all unpaid repayments for this loan/advance, then the loan itself
PayrollAdjustment.objects.filter(
loan=adj.loan,
type=repayment_type,
payroll_record__isnull=True,
).delete()
adj.loan.delete()
elif adj_type == 'Overtime' and adj.work_log:
# "Un-price" the overtime — remove worker from priced_workers M2M
adj.work_log.priced_workers.remove(adj.worker)
adj.delete()
messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.')
return redirect('payroll_dashboard')
# =============================================================================
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
# Only unpaid adjustments are deleted; paid rows survive because payroll
# history is immutable (matches the existing edit_adjustment view, which
# also refuses to edit paid rows).
# =============================================================================
@login_required
@require_POST
def bulk_delete_adjustments(request):
"""Delete multiple unpaid PayrollAdjustment rows with full cascade.
Body (form-encoded): `adjustment_ids` repeated once per ID.
Returns JSON: `{"deleted": N, "requested": M, "skipped_reasons": {...}}`.
Admin-only; supervisors get 403. POST-only; anything else gets 405
from @require_POST.
Cascade: each row is deleted via `_delete_adjustment_with_cascade`
(shared with the single-row `delete_adjustment` view) so bulk and
single-row have identical semantics. Rows that fail (already paid,
or a linked loan with paid repayments) are counted in `skipped_reasons`
but don't block the rest of the batch.
"""
if not is_admin(request.user):
return JsonResponse({'error': 'Admin access required'}, status=403)
# Int-coerce and drop non-digit values (defensive against garbled input —
# ids are client-generated so any non-digit would crash the queryset).
raw_ids = request.POST.getlist('adjustment_ids')
ids = [int(v) for v in raw_ids if v.strip().isdigit()]
# Fetch each adjustment individually — we need the cascade helper to
# operate per-row (it deletes the linked Loan / unprices Overtime).
# Pre-filtering for .payroll_record__isnull=True is fine as an upfront
# short-circuit but the helper double-checks anyway.
adjustments = list(PayrollAdjustment.objects.filter(
id__in=ids,
payroll_record__isnull=True,
).select_related('loan', 'work_log', 'worker'))
deleted = 0
skipped_reasons = {}
for adj in adjustments:
ok, reason = _delete_adjustment_with_cascade(adj)
if ok:
deleted += 1
else:
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1
return JsonResponse({
'deleted': deleted,
'requested': len(ids),
'skipped_reasons': skipped_reasons,
})
# =============================================================================
# === PREVIEW PAYSLIP (AJAX) ===
# Returns a JSON preview of what a worker's payslip would look like.

View File

@ -0,0 +1,199 @@
# Adjustments Filter Bar v2 — Design (23 Apr 2026)
## Origin
Konrad on the shipped Adjustments tab (commit `672c32c`):
> _"this interface layout is very ugly. And the selection dropdown menus text is a bit large don't you think?"_
Follow-up: _"the spacing above the Show As section is bad"_.
Short redesign after the 22-task feature work shipped. Pure polish — no
backend changes, no test changes, no new URL params.
## Goal
Tighten the visual vocabulary of the Adjustments filter bar. Today the
bar mixes three control shapes (popover pills for Type/Workers/Teams, a
native `<select>` for Status, and a compound widget for Date with
separate inputs + preset links + mode-toggle button). Five different
control treatments crammed into one sticky row reads as noisy.
Secondary: the popover checkbox list uses Bootstrap's default
`.form-check-input` ~1416px font and ~0.2rem row padding, which makes
the 7-type Type popover ~320px tall — larger than it needs to be.
Tertiary: the "Show as: Flat / By Type / By Worker" toggle sits
directly below the filter bar with no vertical breathing room; they
read as one cramped block.
## Who it's for
Admins (`is_staff` or `is_superuser`). Same audience as the whole
Adjustments feature.
## 1. Unified pill vocabulary — 5 pills, identical treatment
Every filter becomes a `.filter-pill.adj-filter-pill`:
- Same height (~32px)
- Same border, same hover/focus, same chevron
- Same label typography
- Same popover anchoring (reuses Feature-1 `.filter-popover`)
| Filter | Current | After |
|--------|---------|-------|
| Type | Pill popover with checkboxes | *(unchanged — already correct)* |
| Workers | Pill popover with checkboxes | *(unchanged)* |
| Teams | Pill popover with checkboxes | *(unchanged)* |
| **Status** | Native `<select>` + `<label>` above | **Pill popover** with 3 radios (All / Unpaid / Paid) |
| **Date** | `<label>` + 2 `<input>`s + preset links + `...` toggle, all inline | **Pill popover** with mode toggle + picker(s) + preset buttons inside |
### Pill label formatting
At-a-glance the pill label reveals what's active:
| State | Label |
|-------|-------|
| Empty | `Type`, `Workers`, `Teams`, `Status`, `Date` |
| 1 selected | `Type: Bonus`, `Status: Unpaid`, `Date: 24 Apr 2026` |
| 2+ selected | `Type` with a `(2)` count badge after the label |
| Date range | `Date: 20 Apr 26 Apr` |
## 2. Popover density — smaller font, tighter rows
| Property | Current | After |
|----------|---------|-------|
| Checkbox-list font size | ~14px (Bootstrap default) | `0.8rem` (~12.8px) |
| Checkbox row padding | `0.2rem 0.25rem` | `0.15rem 0.25rem` |
| Checkbox visual size | 1em | `0.9em` |
| Popover footer buttons | Bootstrap `btn-sm` defaults | `font-size: 0.75rem; padding: 0.25rem 0.5rem` |
| Checkbox-list max-height | `280px` | `260px` |
| Popover max width | `420px` | `360px` |
Net effect on the Type popover (7 rows): drops from ~320px tall to ~240px.
## 3. Status pill — popover with 3 radios
```
[Status ▾] Status popover:
┌──────────────────┐
│ ○ All │
│ ○ Unpaid │
│ ○ Paid │
├──────────────────┤
│ [Cancel] [OK] │
└──────────────────┘
```
- Hidden `<input type="hidden" name="adj_status" value="...">` holds
committed state, rewritten by popover OK
- Pill label: `Status` / `Status: Unpaid` / `Status: Paid`
- Cancel reverts radio to committed state; Esc / click-outside
close without committing (matches existing popover pattern)
## 4. Date pill — popover with mode + picker(s) + presets
```
[Date ▾] Date popover:
┌───────────────────────┐
│ [Single] Range Custom │ ← mode toggle
│ │
│ Date: [ 24/04/2026 ] │ ← picker(s)
│ │
│ Today · Week · Month │ ← presets
├───────────────────────┤
│ [Cancel] [OK] │
└───────────────────────┘
```
- Three modes:
- **Single** (default): one picker; submit sends `adj_date_from = adj_date_to = picked`
- **Range**: two pickers (From / To); submit sends both
- **Custom** — same as range but with a "(custom date, not month-aligned)" hint
*(Simplification possibility: drop Custom; Range covers it. Leaning YAGNI.
Will decide at impl time — if no clean semantic gain, Custom goes.)*
- Presets (Today / This week / This month / Clear) work the same as
the current inline presets, just live inside the popover now
- Pill label formatting:
- Empty: `Date`
- Single: `Date: 24 Apr 2026`
- Range: `Date: 20 Apr 26 Apr 2026`
- Hidden form inputs `adj_date_from` + `adj_date_to` hold committed state
## 5. Filter bar layout
```
┌────────────────────────────────────────────────────────────────────┐
│ [Type ▾] [Workers ▾] [Teams ▾] [Status ▾] [Date ▾] [Apply] C │
└────────────────────────────────────────────────────────────────────┘
↑ 1rem spacing
Show as: [Flat] [By Type] [By Worker]
```
- Remove all `<label>Status</label>` / `<label>Date</label>` elements
above native widgets — the pill text carries that job now
- `.adjustments-filter-bar` keeps `display: flex; flex-wrap: wrap;
gap: 0.75rem; align-items: center;` (was `end` for the native widget
baseline; `center` now that all children are the same pill height)
- Apply + Clear sit at the right end with `margin-left: auto` on their
wrapper
- The "Show as" row gains `margin-top: 1rem` so there's clear
separation from the filter bar
- The Show-as toggle buttons get `padding: 0.3rem 0.75rem; font-size:
0.8rem` to match pill height
## 6. Spacing + density tokens
Add two tokens inside the adjustments block of `static/css/custom.css`:
```css
/* Adjustments tab — shared density tokens for the filter strip */
--adj-filter-font-size: 0.8rem;
--adj-filter-height: 32px;
```
Every filter control picks them up so height + text size stay in lockstep.
## 7. Implementation plan (short — it's template + CSS only)
1. **Template** (`payroll_dashboard.html`):
- Replace the `<select name="adj_status">` + its `<label>` with a Status pill-popover (radios)
- Replace the 6-line Date widget markup with a Date pill-popover (mode toggle, pickers, presets all inside)
- Keep the existing hidden `adj_status` / `adj_date_from` / `adj_date_to` pattern — just write them from the new popovers' OK handlers
2. **JS** (same `<script>` block):
- Tiny Status popover wiring (radios → hidden input on OK; label update via pill-label helper)
- Move Date presets + mode toggle logic INSIDE the Date popover handler
- Reuse `commitCheckboxes` pattern for the Status/Date hidden-input rewrite
3. **CSS** (`custom.css`):
- Density tokens (2 CSS vars)
- Font-size drops on `.adj-checkbox-list` + popover footer buttons
- Shrink `.adj-cb-row` padding + checkbox size
- `.adj-groupby-toggle` gets `margin-top: 1rem`
- `.adj-groupby-toggle .btn` matches pill size
## 8. Out of scope
- **Backend changes** — none. Query param contract stays identical
(`?status=adjustments&type=X&worker=Y&team=Z&adj_status=paid&adj_date_from=2026-04-20&adj_date_to=2026-04-26`).
- **Test changes** — none. All 65 tests exercise the URL contract, not the DOM shape.
- **AJAX partial rendering** — still full page reload on Apply. Matches the rest of the app.
- **Saved filter presets** — URLs are bookmarkable already; YAGNI.
- **"Custom date" mode** — if it's semantically identical to "Range",
drop it at impl time.
## 9. Risks + rollback
Template-only; rollback = revert the commit. No data, schema, migration,
or URL-contract impact.
Biggest risk: a copy-paste error in the Date popover's mode-toggle JS
could leave someone with a submit that posts empty dates. Mitigation:
the existing "apply single mode fallback" behaviour (blank `adj_date_to`
→ server treats as no-filter) already makes this safe.
## 10. Next step
Implement directly (no writing-plans needed — single-commit template +
CSS polish, no new business logic, no new tests). Ship alongside the
Adjustments feature in the same `ai-dev` branch.

View File

@ -0,0 +1,669 @@
# Payroll Adjustments Tab — Design (23 Apr 2026)
## Goal
Add a new **Adjustments** tab to the payroll dashboard (alongside Pending / History / Loans & Advances) that lets admins browse ALL payroll adjustments across all workers — filter by type, worker, team, paid/unpaid status, and date — with semantic colour-coded badges, bulk delete, group-by-type / group-by-worker render, and inline actions matching the rest of the dashboard.
Today the only place to see a list of every adjustment is `/admin/core/payrolladjustment/` (Django admin). Adjustments surface per-worker on the Pending tab, per-worker in the Worker Payment Hub modal, and per-worker on the payslip detail page — but nowhere as a unified, filterable list.
## Origin
Konrad's request from a brainstorming session on 23 Apr 2026:
> _"At the moment I can only lookup loans and advances easily but I would like to be able to see all payroll adjustments and filter between type (keep loans and advances separate as they are both loans and record keep is very important)."_
Followed by a UX refinement:
> _"Can we have the Adjustments tab next to Loans and advances on the payroll dashboard?"_
And a premium-UX request:
> _"So in Adjustments we need filters for type of adjustment (multiselect), Teams, Name (Multiselect), paid/unpaid, Date of Adjustment... let us keep loans dirty yellow, adjustments dark pastel blue, deductions deep purple, bonuses dirty pastel green, overtime dirty pink."_
## Who it's for
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no payroll-dashboard access.
## Architecture at a glance
- **URL**: existing `/payroll/` view with new `?status=adjustments` query param (mirrors the pending/paid/loans pattern already in `payroll_dashboard_view`)
- **Template**: extend `core/templates/core/payroll_dashboard.html` — add tab in the nav-tabs strip + new content block gated on `active_tab == 'adjustments'`
- **Modal reuse**: the existing `#addAdjustmentModal`, `#editAdjustmentModal`, `#payslipPreviewModal` all live on the payroll dashboard already. The Adjustments tab's row actions just trigger them — zero duplication.
- **New**: CSS semantic badge palette (7 types × dark/light themes = 14 colour tokens + 7 badge classes), one bulk-delete endpoint, and a group-by rendering layer
- **No model changes. No migrations.**
## 1. Colour palette — semantic type badges
### Mapping: 7 adjustment types → 5 colour categories
Loan/Advance **repayment** sub-types use the same category colour as their outgoing counterparts but with ~15% more saturation — "same family, hotter signal" so eyes instantly catch "this one is money coming back, not going out".
| Type | Category | Saturation |
|---|---|---|
| Bonus | Bonus | base |
| Overtime | Overtime | base |
| Deduction | Deduction | base |
| New Loan | Loans | base |
| Loan Repayment | Loans | +15% |
| Advance Payment | Advances | base |
| Advance Repayment | Advances | +15% |
### CSS tokens
Added to `static/css/custom.css` `:root` (dark) and `:root.light` blocks:
```css
/* Dark theme — badge palette */
--badge-bonus-bg: #5b8260; --badge-bonus-fg: #e8f3ea;
--badge-overtime-bg: #a16881; --badge-overtime-fg: #fce4ec;
--badge-deduction-bg: #5b4f8c; --badge-deduction-fg: #e0daf3;
--badge-loan-bg: #9b7f39; --badge-loan-fg: #fef4d1;
--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1; /* +15% saturation */
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2; /* +15% saturation */
/* Light theme — overrides in :root.light */
--badge-bonus-bg: #d7e8d9; --badge-bonus-fg: #385640;
--badge-overtime-bg: #f3d1dd; --badge-overtime-fg: #703347;
--badge-deduction-bg: #d8d0ef; --badge-deduction-fg: #3b2f6d;
--badge-loan-bg: #f0dc9d; --badge-loan-fg: #6a5320;
--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418;
--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56;
--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550;
```
Each badge class maps type name → token:
```css
.badge-type-bonus { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-type-overtime { background: var(--badge-overtime-bg); color: var(--badge-overtime-fg); }
.badge-type-deduction { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-type-new-loan { background: var(--badge-loan-bg); color: var(--badge-loan-fg); }
.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); }
.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); }
.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); }
```
Template slug helper: a small template filter `|type_slug` that turns `"Advance Payment"``"advance-payment"` for class naming.
**All badges use Inter, 11pt, weight 500, `padding: 0.3rem 0.7rem`, `border-radius: 999px`.** Amount is shown NOT on the badge (badge shows type only); amount is a separate cell with `+` / `` prefix.
## 2. Tab markup (adds one `<li>` to existing nav-tabs)
In `payroll_dashboard.html` at around line 252:
```django
<ul class="nav nav-tabs mb-3" role="tablist">
<!-- existing: Pending, History, Loans & Advances -->
<li class="nav-item">
<a class="nav-link {% if active_tab == 'adjustments' %}active{% endif %}" href="?status=adjustments">
<i class="fas fa-sliders-h me-1"></i> Adjustments
</a>
</li>
</ul>
```
## 3. Filter bar — sticky, multi-select, with cross-filter
Five filters in a single row under the tab:
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Type ▾ 2] [Workers ▾ 3] [Teams ▾ 1] [Status ▾] [Date 📅] [Apply] │
└──────────────────────────────────────────────────────────────────────────────┘
```
| Filter | UI | Behaviour |
|---|---|---|
| **Type** | Choices.js multi-select, 7 options (the 7 adjustment types). Selected chips show the badge colour of that type. | empty = all types |
| **Workers** | Choices.js multi-select, searchable. Cross-filtered by selected Teams (see below). | empty = all workers |
| **Teams** | Choices.js multi-select, all active teams | empty = all teams |
| **Status** | Native single-select: All / Unpaid / Paid | default All |
| **Date** | Single date picker. A `…` button toggles range mode (From / To). | Single date = exact; range = inclusive bounds |
| **Apply** | Button visible only when filters dirty | Submits via `?status=adjustments&type=Bonus&type=Overtime&worker=1&worker=2&team=3&adj_status=unpaid&adj_date_from=...&adj_date_to=...&group_by=type` |
**Sticky:** the filter bar stays at the top as the table scrolls below it. `position: sticky; top: 0; z-index: 10; background: var(--bg-card);`.
### Cross-filter — Team → Workers
When Team(s) are selected, the Workers dropdown shows only workers in those teams. Implementation mirrors Feature 1's project↔team cross-filter:
- Backend: compute a JSON map of `(team_id, worker_id)` pairs from `Team.workers.through`:
```python
team_worker_pairs = list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
)
context['team_worker_pairs_json'] = json.dumps(team_worker_pairs)
```
- Frontend: when the Workers popover opens, filter visible options based on current team selection. Auto-remove now-invalid worker selections with a toast ("Alice removed — not in selected teams").
- Scope: entire active roster (not filtered by date range) — cross-filter is about data possibility, not data in this period.
One-way: Teams filter Workers. (Worker→Team filter is less useful — you usually pick teams first, then drill down to specific workers within them.)
## 4. Columns + row actions
### Flat view (no grouping)
| # | Column | Sort | Notes |
|---|---|---|---|
| 1 | `☐` (bulk checkbox) | No | Only on unpaid rows (paid rows show disabled checkbox) |
| 2 | Date | Yes ▲▼ | `d M Y` format |
| 3 | Worker | Yes | Link to `/workers/<id>/` |
| 4 | Type | No (filter instead) | Badge with `.badge-type-<slug>` |
| 5 | Amount | Yes | Right-aligned, tabular-nums. Sign: `+` for additive, `` for deductive. Plain text colour (`--text-primary`) |
| 6 | Project | No | Link to `/projects/<id>/` or `—` |
| 7 | Team | No | `worker.teams.first().name` or `—` |
| 8 | Description | No | Truncate 40 chars + `title` attr tooltip for full text |
| 9 | Status | Yes | Badge: `Paid #123` (success, links `/payroll/payslip/<pk>/`) or `Unpaid` (warning) |
| 10 | Actions | No | See below |
**Sortable:** 4 columns (Date, Worker, Amount, Status) with click-to-toggle. Bootstrap sort arrows in the headers. URL state: `?sort=<col>&order=<asc|desc>`.
### Row actions
Inline buttons matching the rest of the dashboard — NO expandable rows:
- **Unpaid row:**
- `[Preview]` — opens existing `#payslipPreviewModal` for that worker (same as Pending Payments tab's Preview)
- `[Edit]` — opens existing `#editAdjustmentModal` pre-filled (same as Pending tab's Edit)
- `[×]` (delete) — opens existing delete confirm flow
- **Paid row:**
- `[View Payslip]` — links to `/payroll/payslip/<pk>/` (same as History tab's View)
Zero new modals. Zero new JS — reuse the handlers already on the payroll dashboard.
## 5. Group-by toggle
Three radio pills above the table (next to the filter bar):
```
Show as: [ Flat ● ] [ By Type ○ ] [ By Worker ○ ]
```
### When grouped:
```
▾ BONUS · 3 rows · +R 1 500 ───────────────────────────────────
22 Apr 2026 Alice M. Bonus +R 500 Wilkot ... Unpaid [Preview][Edit][×]
18 Apr 2026 Bob N. Bonus +R 500 Alpha ... Paid #8 [View]
...
▾ OVERTIME · 5 rows · +R 750 ──────────────────────────────────
...
▸ DEDUCTION · 2 rows · R 400 ───────────────────────────────── (collapsed)
...
```
- Click group header → collapse/expand (Bootstrap Collapse, smooth animation)
- Group header shows: `<category>` · row count · net sum (`+R` for net-additive groups, `R` for net-deductive)
- Default: all groups expanded
- URL state: `?group_by=type` or `?group_by=worker`
### By-worker grouping
Same shape but grouped by `worker.id`:
```
▾ Alice Mokoena · 4 rows · +R 1 200 ─────────────────
...
▾ Bob Ndlovu · 2 rows · +R 300 ──────────────────────
...
```
### Implementation
Backend:
```python
if group_by == 'type':
groups = defaultdict(list)
for adj in paginated_adjustments:
groups[adj.type].append(adj)
rendered_groups = [
{'label': t, 'slug': slug(t), 'rows': rows,
'count': len(rows), 'net_sum': sum_with_sign(rows)}
for t, rows in groups.items()
]
elif group_by == 'worker':
# same shape, groups keyed by worker_id; label = worker.name
```
Template iterates over `rendered_groups` if grouping is active, else over the flat page. Both paths render the same row-level HTML (shared partial `_adjustment_row.html`) so there's no markup duplication.
## 6. Bulk actions
### Checkbox column
Leftmost column with `<input type="checkbox" name="bulk_select">` on each unpaid row. Paid rows show a disabled greyed-out checkbox (visually consistent, not interactive). "Select all" checkbox in the thead toggles all visible unpaid rows on the current page.
### Floating action bar
When ≥1 row is selected, a small bar slides up from the bottom of the viewport (not the page — fixed position):
```
┌─────────────────────────────────────────────────────────┐
│ 3 selected · [🗑 Delete] [Clear selection] │
└─────────────────────────────────────────────────────────┘
```
- `Delete` → confirms via native `confirm()` → POSTs to new endpoint `/payroll/adjustments/bulk-delete/`
- `Clear selection` → unticks all, bar slides away
### Bulk-delete endpoint
```python
@login_required
def bulk_delete_adjustments(request):
"""Delete multiple unpaid adjustments at once.
Only unpaid adjustments can be deleted (paid ones are locked into payroll
history). POST body: list of adjustment IDs. Returns JSON with success
count + error count. Admin-only.
"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
ids = request.POST.getlist('adjustment_ids')
# Only unpaid adjustments; silently skip any paid ones (defensive against UI bugs)
to_delete = PayrollAdjustment.objects.filter(
id__in=ids, payroll_record__isnull=True
)
deleted_count = to_delete.count()
to_delete.delete()
return JsonResponse({'deleted': deleted_count, 'requested': len(ids)})
```
URL: `path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments')`.
## 7. Header stats
Directly under the filter bar, above the table:
```
247 adjustments · 38 unpaid (R 126 500) · +R 45 000 net additive · R 2 100 net deductive
```
All four numbers scoped to the current filter set (not the whole DB). Updates on Apply.
## 8. Date filter — single vs range
The Date field is a single date picker by default. A small `…` (ellipsis) button next to it toggles to range mode:
- **Single mode** (default): pick ONE date. Filter: `date = selected_date`.
- **Range mode**: two date pickers (From / To). Filter: `from ≤ date ≤ to`.
Single mode covers Konrad's "see adjustments for a specific date" use case. Range mode handles "this week" / "this month" audit queries.
Preset quick-buttons above the picker: `[Today] [This week] [This month] [Clear]`. Clicking a preset fills the range and stays in range mode.
## 9. URL state & bookmarkability
Every filter + sort + group-by choice lives in the querystring. Shareable/bookmarkable:
```
/payroll/?status=adjustments
&type=Bonus&type=Overtime ← multi-value (same key repeated)
&worker=1&worker=3
&team=2
&adj_status=unpaid
&adj_date_from=2026-03-01
&adj_date_to=2026-04-30
&group_by=type
&sort=amount&order=desc
&page=2
```
## 10. Empty state
When filters return 0 rows:
```
┌──────────────────────────────────────────┐
│ 📭 │
│ No adjustments match these filters. │
│ │
│ [Clear filters] [Add new adjustment] │
└──────────────────────────────────────────┘
```
Action buttons offer immediate recovery paths.
## 11. Backend changes
### New branch in `payroll_dashboard` view
Around line 2496 where `status_filter` is parsed:
```python
elif status_filter == 'adjustments':
active_tab = 'adjustments'
# Parse filters
type_filter = request.GET.getlist('type')
worker_filter = [int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()]
team_filter = [int(v) for v in request.GET.getlist('team') if v.strip().isdigit()]
adj_status = request.GET.get('adj_status', '').strip()
adj_date_from = request.GET.get('adj_date_from', '').strip()
adj_date_to = request.GET.get('adj_date_to', '').strip()
group_by = request.GET.get('group_by', '').strip()
sort_col = request.GET.get('sort', 'date').strip()
sort_order = request.GET.get('order', 'desc').strip()
adjustments = PayrollAdjustment.objects.select_related(
'worker', 'project', 'payroll_record'
).prefetch_related('worker__teams')
if type_filter:
adjustments = adjustments.filter(type__in=type_filter)
if worker_filter:
adjustments = adjustments.filter(worker_id__in=worker_filter)
if team_filter:
# Subquery pattern per CLAUDE.md ORM gotcha — avoids M2M JOIN inflation
adjustments = adjustments.filter(
worker__in=Worker.objects.filter(teams__id__in=team_filter).values('id')
)
if adj_status == 'unpaid':
adjustments = adjustments.filter(payroll_record__isnull=True)
elif adj_status == 'paid':
adjustments = adjustments.filter(payroll_record__isnull=False)
if adj_date_from:
adjustments = adjustments.filter(date__gte=parse_date(adj_date_from))
if adj_date_to:
adjustments = adjustments.filter(date__lte=parse_date(adj_date_to))
# Sorting
sort_map = {'date': 'date', 'worker': 'worker__name',
'amount': 'amount', 'status': 'payroll_record'}
sort_field = sort_map.get(sort_col, 'date')
if sort_order == 'desc':
sort_field = '-' + sort_field
adjustments = adjustments.order_by(sort_field, '-id')
# Stats (scoped to filtered set)
adj_total_count = adjustments.count()
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
adj_unpaid_count = unpaid_qs.count()
adj_unpaid_sum = unpaid_qs.aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
additive_sum = adjustments.filter(type__in=ADDITIVE_TYPES).aggregate(
total=Sum('amount'))['total'] or Decimal('0.00')
deductive_sum = adjustments.filter(type__in=DEDUCTIVE_TYPES).aggregate(
total=Sum('amount'))['total'] or Decimal('0.00')
# Pagination
paginator = Paginator(adjustments, 50)
adj_page = paginator.get_page(request.GET.get('page', 1))
# Group-by rendering
adj_groups = None
if group_by in ('type', 'worker'):
adj_groups = _group_adjustments(adj_page.object_list, group_by)
# Cross-filter data for JS
team_worker_pairs_json = json.dumps(list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
))
context.update({
'adj_page': adj_page,
'adj_groups': adj_groups,
'adj_total_count': adj_total_count,
'adj_unpaid_count': adj_unpaid_count,
'adj_unpaid_sum': adj_unpaid_sum,
'adj_additive_sum': additive_sum,
'adj_deductive_sum': deductive_sum,
'adj_filter_values': {
'type': type_filter, 'worker': worker_filter, 'team': team_filter,
'adj_status': adj_status, 'adj_date_from': adj_date_from,
'adj_date_to': adj_date_to, 'group_by': group_by,
'sort': sort_col, 'order': sort_order,
},
'adjustment_types': ADDITIVE_TYPES + DEDUCTIVE_TYPES,
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
'team_worker_pairs_json': team_worker_pairs_json,
})
```
### New helper `_group_adjustments`
```python
def _group_adjustments(adjustments, group_by):
"""Regroup a flat list of adjustments by type or by worker.
Returns list of dicts: [{'label', 'slug', 'rows', 'count', 'net_sum'}, ...]
Groups ordered by descending net_sum magnitude (biggest impact first).
"""
from collections import defaultdict
buckets = defaultdict(list)
for adj in adjustments:
key = adj.type if group_by == 'type' else adj.worker_id
buckets[key].append(adj)
groups = []
for key, rows in buckets.items():
if group_by == 'type':
label = key
slug = key.lower().replace(' ', '-')
else: # worker
label = rows[0].worker.name
slug = f'worker-{key}'
net_sum = sum(
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
for r in rows
)
groups.append({
'label': label, 'slug': slug, 'rows': rows,
'count': len(rows), 'net_sum': net_sum,
})
groups.sort(key=lambda g: -abs(g['net_sum']))
return groups
```
### New endpoint `bulk_delete_adjustments`
(Shown in section 6.)
### New template filter `|type_slug`
In `core/templatetags/format_tags.py`:
```python
@register.filter
def type_slug(value):
"""Convert 'Advance Payment' -> 'advance-payment' for CSS class naming."""
if not value:
return ''
return value.lower().replace(' ', '-')
```
## 12. Testing
New `AdjustmentsTabTests` class in `core/tests.py` (~8 tests):
- `test_admin_sees_adjustments_tab` — 200 for admin at `?status=adjustments`
- `test_supervisor_forbidden` — 403 for supervisor at `/payroll/?status=adjustments`
- `test_type_multi_filter``?type=Bonus&type=Overtime` returns union (not intersection)
- `test_worker_multi_filter``?worker=1&worker=2` returns union
- `test_team_cross_filter` — team filter uses subquery pattern (no inflation on linked counts)
- `test_status_filter_unpaid` — only unpaid rows returned
- `test_date_filter_single_vs_range` — single date = exact match; range = inclusive
- `test_group_by_type` — response context has `adj_groups` with correct keys
- `test_group_by_worker` — same with worker grouping
- `test_bulk_delete_only_affects_unpaid` — POST with mixed paid+unpaid IDs deletes only unpaid
- `test_bulk_delete_requires_admin` — 403 for supervisor
(~11 tests; aim for ~130 lines of test code.)
## 13. Scope estimate
| Change | Lines |
|---|---|
| `core/views.py` — new `payroll_dashboard` branch + `_group_adjustments` helper + `bulk_delete_adjustments` endpoint | ~180 |
| `core/templates/core/payroll_dashboard.html` — tab `<li>` + filter bar + sticky stats row + group-by toggle + table with grouping + row actions + bulk action bar | ~260 |
| `core/templates/core/_adjustment_row.html` — shared row partial (used by both flat + grouped views) | ~40 |
| `core/templatetags/format_tags.py``type_slug` filter | ~10 |
| `static/css/custom.css` — 14 badge colour tokens × 2 themes + 7 badge classes + sticky filter bar + group header + bulk action bar | ~170 |
| JS (inline in template) — Choices.js init × 3 multi-selects + Team→Worker cross-filter + bulk checkbox logic + group-header collapse + sort header clicks + date single/range toggle | ~170 |
| `core/urls.py` — 1 new path (bulk-delete) | ~3 |
| Tests in `core/tests.py` | ~130 |
| **Total** | **~960 lines** |
~12 tasks, 2 checkpoints.
**Suggested checkpoints:**
1. After core filter logic + Choices.js multi-selects + stats + sort + flat table render
2. After group-by + bulk delete + date single/range + cross-filter + row actions + full QA
## 14. Edge cases
| Case | Behaviour |
|---|---|
| Filter returns 0 rows | Empty-state card with "Clear filters" + "Add new adjustment" CTAs |
| Paid row selected via bulk checkbox (shouldn't happen via UI; defensive) | Backend silently skips paid rows in bulk-delete |
| Worker with no teams (orphan) | Hidden from Workers dropdown when any Team filter is active |
| Sort by column with missing values (e.g. sorting by worker name when worker deleted) | NULLs sort last; fallback to date-desc within same key |
| Group-by on an empty filter result | Empty state message (no group headers shown) |
| 500+ adjustments in DB | Pagination handles it — 50/page, URL `?page=2` |
| User clicks Edit on a paid row (shouldn't have button; defensive) | Edit modal wouldn't allow edit; existing `edit_adjustment` view blocks paid |
| Bulk-delete hits a race condition (another admin pays one of the selected) | Endpoint filters `payroll_record__isnull=True` at DELETE time; stale ones silently excluded |
## 15. Dependencies on Feature 1 (inline filters)
Feature 1's Choices.js infrastructure (CDN loading, SRI hashes, dark/light theme overrides) is already shipped. This feature reuses it directly — no duplicate CDN loads.
Feature 1 retires the report-config modal; this feature is on a different page and doesn't interact with that change.
**Order of implementation: either can ship first.** Feature 1 is smaller (~5-6 tasks) and may inform JS patterns we lift into Feature 2. Feature 2 is larger but self-contained. Suggested order: Feature 1 first, then Feature 2, separate plans.
## 16. Out of scope (YAGNI)
- **Bulk Mark Paid** — entangled with Pay Now flow; use existing per-worker `process_payment` via Preview modal
- **CSV export** — easy to add later if requested; not in original ask
- **Keyboard shortcuts beyond Esc** — browser Tab is fine
- **Persistent sort / filter session state** — URL bookmark covers it
- **Inline editing of description or amount** — Edit modal is sufficient
- **Adjustment history / audit log** — would require a new model; not asked for
- **Group by Project** — Type and Worker cover the two most useful axes
## 17. Rollback
Template + view additions only; one new endpoint; no schema changes. Rollback = revert the commits (or disable the tab via template edit if a panic fix is needed).
## 18. Next step
Hand off to `superpowers:writing-plans`. Two design docs exist today:
- `docs/plans/2026-04-23-inline-filters-design.md` (Feature 1 — report page pills)
- `docs/plans/2026-04-23-adjustments-tab-design.md` (this doc — Feature 2)
Recommended sequence: **Feature 1 first** (smaller, ~5-6 tasks; Choices.js patterns learned here can lift into Feature 2). Ship Feature 1, validate on production, then Feature 2's plan + implementation. Both design docs stay local until their respective implementations ship; then push everything together.
---
## 19. Shipped — 2026-04-23
Implementation complete. 11 tasks + 1 hard-pause checkpoint + 1 round of
Konrad feedback fixes. 65 tests passing (up from 47 pre-feature).
### Commit map
| Task | Commits | Scope |
|------|---------|-------|
| 1 | `97d8a69` | `type_slug` template filter (+ tests) |
| 2 | `a20a025` | CSS badge palette + foundational styles |
| 3 | `10d381e`, `89f109a` | Backend filter branch + stats; strengthened subquery test |
| 4 | `b450bd3`, `06b3315` | Tab markup + filter bar + flat table; pagination / a11y / N+1 fixes |
| 4* | `e088192`, `4c1cdb6` | Two multi-line `{# #}` comment hotfixes — see Deviations #2 |
| CP1 A | `b59eb31` | Row actions → modals + project link → History tab |
| CP1 B | `4f15e4b` | **Replaced Choices.js chip-multiselect with popover-checkbox filter UX** — see Deviations #1 |
| 5 | `0862805`, `e5d06f9` | Group-by type/worker + toggle + colour-accented headers; chevron + ordering polish |
| 6 | `03f177e`, `5f2e6d8`, `4c3e90f` | Bulk-delete endpoint; id-collision fix; **cascade logic fix** — see Deviations #3 |
| 7 | `6905703` | Team → Workers cross-filter |
| 8 | `c851b49` | Date picker single/range toggle + preset buttons |
| 9 | `7b71048` | Sortable column headers with URL state |
| 10 | `9bb9ede` | Empty-state card with recovery CTAs |
### Deviations from the original design
1. **Choices.js chip-multiselect → popover-checkbox filters.** The original
design (§3) specified Choices.js for Type/Workers/Teams multi-selects —
the same pattern used in the report page's retired modal. At Checkpoint 1
Konrad flagged that the chip-style rendering was intrusive once multiple
options were selected, dominating the filter bar. We replaced the
Choices.js widgets with pill-buttons that open popovers containing a
scrollable checkbox list + search + Select All / Invert / Clear. Reuses
Feature 1's `.filter-pill` / `.filter-popover` CSS vocabulary.
Implemented in `4f15e4b`.
2. **Multi-line `{# ... #}` comment bug, twice.** Django's `{# #}` comment
syntax is single-line only — multi-line blocks need
`{% comment %}...{% endcomment %}`. We shipped the bug in the Task 4
row partial (`e088192` fixed it) and then AGAIN in the Fix-A worker
cell (`4c1cdb6` fixed it). Both shipped into production-looking
renders, not caught by automated tests. Lesson: add a repo-wide
grep guard or a Django linter for this class of template bug.
3. **Bulk-delete cascade gap.** The original Task 6 spec's reference
implementation (`PayrollAdjustment.objects.filter(...).delete()`)
silently orphaned linked `Loan` rows and `priced_workers` M2M entries
when bulk-deleting adjustments of type "New Loan", "Advance Payment",
or "Overtime". The single-row `delete_adjustment` view had 30+ lines
of cascade logic the bulk view didn't use. Code review caught it.
Fix: extracted `_delete_adjustment_with_cascade(adj)` helper and
delegated both views to it — ensuring bulk and single-row have
identical semantics. Also added a 'has_paid_repayments' skip reason
in the JSON response so the UI can indicate why some rows were kept.
Implemented in `4c3e90f`.
4. **Row actions → modals (CP1 Fix A).** The original design §4 said row
actions "match the rest of the dashboard — NO expandable rows". We
interpreted this as table-to-page navigation (Worker name → `/workers/<id>/`,
View Payslip → `/payroll/payslip/<pk>/`). At CP1 Konrad clarified he
wanted in-place MODALS matching the Pending tab: worker name opens
`#workerLookupModal`, paid-row eye icon opens `#previewPayslipModal`,
project name goes to `/projects/<id>/#history` (History tab active).
Implemented in `b59eb31`; tiny tab-activation helper in
`projects/detail.html` picks up the URL hash.
5. **id collision.** Task 4 added `id="adjSelectAll"` to the table
header checkbox, but the Add Adjustment modal already used that id
for its Select-All anchor. `document.getElementById` returns only
the first match, so the modal's handler silently bound to the table
checkbox. Renamed the table's to `#adjTableSelectAll` in `5f2e6d8`.
### Tests
Added 14 tests in `AdjustmentsTabTests`:
- `test_admin_sees_adjustments_tab` — 200 + active_tab set
- `test_supervisor_forbidden` — non-admin redirected
- `test_type_multi_filter` — union on multi-value param (uses adj_total_count)
- `test_worker_multi_filter` — worker filter
- `test_team_filter_uses_subquery_no_inflation` — proves the subquery
pattern with 2 teams × 2 workers × 3 adjustments (naive would return 6)
- `test_status_filter_unpaid` — payroll_record__isnull filter
- `test_date_range_filter` — date__gte/lte
- `test_stats_scoped_to_filtered_set` — counts + sums respect filter
- `test_group_by_type` — buckets + net_sum + descending-magnitude ordering
- `test_group_by_worker` — buckets by worker_id
- `test_bulk_delete_only_affects_unpaid` — paid row survives
- `test_bulk_delete_requires_admin` — 403 for supervisors
- `test_bulk_delete_cascades_new_loan` — Loan + unpaid repayments gone too
- `test_bulk_delete_skips_loan_with_paid_repayments` — refuses, reports reason
- `test_team_worker_pairs_json_context_key` — raw Python list shape (not double-encoded)
Also extended existing tests:
- `test_group_by_type` gained a descending-magnitude ordering assertion
- `TypeSlugFilterTests` has 3 tests for the new template filter
### Net code churn
- `core/views.py`: ~+200 lines (filter branch + 2 helpers + bulk-delete view)
- `core/templates/core/payroll_dashboard.html`: ~+450 lines (tab + filter bar + popover markup + table + JS modules)
- `core/templates/core/_adjustment_row.html`: new file, ~120 lines
- `core/templatetags/format_tags.py`: ~+35 lines (`type_slug`, `money_abs`, `url_replace`)
- `static/css/custom.css`: ~+220 lines (badge palette + layout skeleton + popover extensions + colour-accented group headers + chevron rotation)
- `core/tests.py`: ~+380 lines (14 new adjustments tests + 3 type_slug tests)
- `core/urls.py`: +1 route
- Total: ~+1,400 lines added, ~-100 replaced/removed.
(Original estimate: ~960 lines. Actual: +44% — mostly from the popover-
checkbox filter rewrite, the bulk-delete cascade, and the cross-filter JS.)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,277 @@
# Inline Filters on Report Page — Design (23 Apr 2026)
## Goal
Replace the modal-based filter form on `/report/` with inline, interactive filter pills so Konrad can tweak project/team/date filters without opening a modal every time. Adds cross-filter awareness: selecting a project hides teams that never worked on it (and vice versa).
## Origin
Raised by Konrad at Checkpoint 3 of the Executive Report v2 work (just shipped):
> _"I wonder if the generate should not rather have static filters instead of the popup open up for every report? Easier to add or remove a project or team or change dates instead of having to start from scratch."_
And the cross-filter request that came later:
> _"Is it possible to filter out teams when selecting a project that has not worked on that project?"_
## Who it's for
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no report access.
## Architecture at a glance
- Every filter pill on the report page becomes a **clickable dropdown**. Click → popover opens directly under that pill → edit → click **OK** inside the popover → popover closes + pill shows dirty state + Apply button appears at the right end of the pill strip.
- **Apply** submits to `/report/?<reconstructed querystring>` — full page reload, same URL scheme as today. No AJAX.
- The Generate Report modal is **deleted**. The dashboard's "Generate Report" button becomes a plain link to `/report/?from_month=<current>&to_month=<current>`.
- Cross-filter: a serialised map of `(project_id, team_id)` pairs from `WorkLog` is passed as JSON to the page. Popovers filter their options based on the other pill's current selection.
- **Backend code touched: minimal** — one new context key (`project_team_pairs_json`) and removal of the now-unused `selected_project_ids` / `selected_team_ids` keys the modal needed.
## 1. Filter trigger — explicit Apply with dirty-state indicator
Chosen over auto-apply-on-change because:
- Admin users care about correctness; brief lag between "change filter" and "see numbers" is fine.
- `_build_report_context` does ~10 aggregation queries; running it on every checkbox tick is wasteful.
- Matches the rest of the codebase's pattern (Worker/Team/Project edit pages all use explicit Save).
Refinement: Apply button is **hidden when filters are in sync with URL**, so it can't be "forgotten" when there are no pending changes.
## 2. Three interactive pills, one Apply action
```
┌──────────────────────────┬──────────────────────────────────┬──────────────────────┬──────────┐
│ 📅 Mar 2026 Apr 2026 ▾ │ 📁 Wilkot Boerdery + 1 more ▾ × │ 👥 All Teams ▾ │ [Apply] │
└──────────────────────────┴──────────────────────────────────┴──────────────────────┴──────────┘
```
**Pill states**:
- **Closed**: current value + ▾ chevron; cursor pointer; tooltip "Click to edit".
- **Dirty** (has uncommitted changes): accent-orange outline + small pulsing dot; Apply button appears.
- **Open**: popover expanded; pill background darker to indicate focus.
**× button** on project and team pills: still clears that filter instantly (shortcut for "reset to All"). Only shown when a filter is active.
## 2.1 Date pill popover
Click `📅 Mar 2026 Apr 2026 ▾` → popover opens with same fields as the current modal:
- Date Selection radio toggle: Month(s) / Custom Dates
- From / To month pickers (when Month mode)
- Start Date / End Date date pickers (when Custom mode)
- Cancel / OK buttons
Click OK → popover closes, pill updates to new range in dirty state.
## 2.2 Projects / Teams pill popover
Click `📁 Wilkot Boerdery + 1 more ▾` → popover with a Choices.js multi-select (the same widget used in the just-retired modal):
- Chip-style selected items at top; typing filters options.
- Option list below the input (cross-filtered — see section 2.5).
- Cancel / OK at the bottom.
Click OK → popover closes, pill text updates. Pill display rules:
- 0 selected: "All Projects" (or "All Teams")
- 1 selected: show the name
- 2 selected: show both comma-joined
- 3+ selected: show first + "+ N more"
## 2.3 Apply button behaviour
- **Hidden** when filters match the URL (no dirty state).
- **Appears** when any pill is dirty.
- **Click Apply** → submits via `window.location = '/report/?' + querystring`. Full page reload.
- **Cancel** button appears alongside Apply when dirty → reverts all pills to URL-current values without submitting.
- Browser back button works normally (each Apply = real URL change).
## 2.4 Generate Report button → plain link
**Before (shipped)**:
- Dashboard "Quick Actions" card → "Generate Report" button → modal → submit.
- Report page "New Report" button → modal → submit.
**After**:
- Dashboard "Generate Report" card → `<a>` link to `/report/?from_month={{ current_month }}&to_month={{ current_month }}`. One click → land on report with current month defaults → all filters are pills.
- Report page "New Report" button → **deleted**. Pills are the new-report interface.
**Files affected by retirement**:
- `core/templates/core/_report_config_modal.html`**delete** (no other callers).
- `core/templates/core/index.html` — remove `{% include 'core/_report_config_modal.html' %}`; change "Generate Report" button to plain link.
- `core/templates/core/report.html` — remove `{% include %}`; remove "New Report" button.
- `core/views.py` — remove `context['selected_project_ids']` / `selected_team_ids` from `index()` and `generate_report` (only existed for the modal's pre-selection).
## 2.5 Cross-filter (project ↔ team)
When a project is selected, the Teams popover shows only teams that have worked on at least one of the selected projects. Symmetric: selecting teams filters the Projects popover.
**Semantics**:
- **Union across selections**: 2 projects selected → teams that have worked on EITHER project appear.
- **All (no cross-filter)**: if no project is selected, all teams appear in the Teams popover. Same for projects.
- **Scope = entire history**: "has worked on" means "has at least one `WorkLog` on that project, ever". NOT filtered by the report's date range — date is about data shown; cross-filter is about data possible.
**Auto-removal of invalid selections**:
If you have Team Beta selected, then add Project Wilkot, and Beta has never worked on Wilkot → Beta is **auto-removed** from the team pill with a brief inline notice ("Team Beta removed — no logs on selected projects"). Toast-style, auto-dismisses after 4 seconds.
**Data source — one new context key**:
```python
# In generate_report view, serialise distinct (project_id, team_id) pairs as JSON.
# The frontend uses this to filter dropdown options + auto-remove invalid selections.
pairs = list(
WorkLog.objects
.filter(project__isnull=False, team__isnull=False)
.values('project_id', 'team_id')
.distinct()
)
context['project_team_pairs_json'] = json.dumps(pairs)
```
Injected into the template via `{{ project_team_pairs_json|json_script:"projectTeamPairs" }}` (Django's safe-JSON pattern, already used for `team_workers_map_json` on the payroll dashboard per CLAUDE.md).
**Frontend JS** (inside the pill-popover module):
- When a popover opens, determine which options to hide based on the OTHER pill's current selection.
- When a popover "OK" fires, diff the new selection against pairs; remove invalid entries from the other pill; show a toast.
- Choices.js supports programmatically setting/hiding options via its API.
**Edge cases**:
- Team with zero work logs ever (e.g. just created) → invisible when any project is selected. Correct behaviour.
- Deleted project/team references (`SET_NULL` in the DB) → already filtered out by the `filter(project__isnull=False, team__isnull=False)` clause.
- If the URL specifies a team that isn't valid for the current project selection (shouldn't happen in practice, but if someone edits the URL) → the team still renders on the report's selected-period data (backend doesn't know about cross-filter); the pill shows it normally; next Apply will clean it up.
## 3. PDF stays identical
The "Download PDF" button uses `?{{ query_string }}` — whatever filters are in the URL flow into the PDF. No changes to `generate_report_pdf` or the PDF template.
## 4. CSS — new rules
In `static/css/custom.css`:
- `.filter-pill--editable` — pointer cursor, hover tint, ▾ chevron alignment
- `.filter-pill--dirty` — accent-orange outline, small pulsing dot (subtle)
- `.filter-popover` — absolute-positioned below the pill, `--bg-card` + `--border-default` + shadow (same tokens as work log payroll modal)
- `.filter-popover__footer` — right-aligned Cancel + OK buttons
- `.apply-filters-btn` — primary button, slides in from right edge when dirty
- `.filter-toast` — small accent-orange toast at the top of the report, auto-dismisses
~80 lines.
## 5. JS — one scoped module
Inline `<script>` in `report.html`. Structure (~150 lines):
```js
document.addEventListener('DOMContentLoaded', function() {
// Parse the pairs map from json_script
const pairs = JSON.parse(document.getElementById('projectTeamPairs').textContent);
// Build lookup indices: project_id -> Set(team_id), team_id -> Set(project_id)
const teamsByProject = buildIndex(pairs, 'project_id', 'team_id');
const projectsByTeam = buildIndex(pairs, 'team_id', 'project_id');
// State
const state = {
urlProjects: [...], urlTeams: [...], urlDateRange: {...},
pendingProjects: [...], pendingTeams: [...], pendingDateRange: {...},
};
// Pill click handlers → open popovers
// Popover OK handlers → update pending state + re-render pill text + trigger cross-filter + maybe show toast
// Apply button → navigate to /report/?querystring
// Cancel button → reset pending to url state
// Esc key → close open popover
});
```
Uses `createElement` + `textContent` (XSS-safe; matches work log payroll modal pattern). No innerHTML.
## 6. Testing
No new backend tests — URL contract unchanged, context contract unchanged, PDF contract unchanged. All 42 existing tests keep passing.
**One new test** for the cross-filter context key:
```python
class ProjectTeamPairsTests(TestCase):
def test_pairs_context_key_populated(self):
"""Report view exposes (project_id, team_id) pairs for cross-filter JS."""
# Assert generate_report renders with project_team_pairs_json containing
# the (p, t) pairs from created work logs.
```
Manual QA checklist (10 flows):
1. `/report/` → pills show current filters; no Apply button (clean state)
2. Click date pill → popover opens; change month; OK → pill dirty; Apply visible
3. Click Apply → URL updates; re-render; clean state
4. Click project pill → popover; pick 2 projects; OK → dirty
5. Team pill options now show only teams that worked on those projects
6. Select a team that wasn't in the project's history → warning toast, not selected
7. Click × on project pill → filter clears; Apply button visible → click → URL drops projects
8. Click pill, edit, click Cancel → no URL change; pill reverts
9. Esc key closes popover
10. Dashboard "Generate Report" → lands on `/report/?from_month=current&to_month=current` as plain link (no modal)
## 7. Scope estimate
- `core/views.py`: ~+5 lines (`project_team_pairs_json` in `generate_report`; remove `selected_*_ids` in both `index` and `generate_report`) — net roughly neutral
- `core/templates/core/index.html`: -1 line (remove modal include), change one button to a link
- `core/templates/core/report.html`: ~+30 lines (new pill-popover markup + json_script) / -5 lines (remove modal include + "New Report" button)
- **Delete** `core/templates/core/_report_config_modal.html` (-140 lines)
- `static/css/custom.css`: +80 lines
- JS (inline in report.html): ~+150 lines
- Tests: 1 new test class, ~20 lines
- Net: ~+170 / -160 across 4 files + 1 deletion
About 5-6 focused tasks, 1 checkpoint after cross-filter behaviour works end-to-end.
## 8. Out of scope (YAGNI)
- **AJAX partial re-render** — Apply still triggers a full page reload. SSR pattern matches the rest of the app.
- **"Save this filter set" feature** — URLs are bookmarkable already.
- **Keyboard shortcuts for pills** — Tab/Enter work via native button/link behaviour.
- **Undo stack** — browser back button is sufficient.
- **Permissive cross-filter** (Option B — greyed-out but still selectable) — strict cross-filter is the clearest UX for FoxFitt's admin use case.
- **Date-range scoped cross-filter** — "has worked on this project, ever" is simpler to explain than "within this report's date range".
## 9. Rollback plan
Feature is template-only (no backend behaviour change beyond one new serialised context key). Rollback = revert the commit. No data, schema, or migration impact.
## Next step
Hand off to `superpowers:writing-plans` after Feature 2 (Payroll Adjustments Browser) has been brainstormed and its design doc committed. Both features can then become either one combined plan or two separate plans — user's choice.
---
## 10. Shipped — 2026-04-23
Implementation and Checkpoint-1 UX approval complete. Everything on this branch before the final push. Sections below list what deviated from the design above, with the driving feedback + commit SHAs.
### Deviations from the original design
| # | Original | Shipped | Why |
|---|----------|---------|-----|
| 1 | Popover **OK** sets pending state; global **Apply** button commits when any pill is dirty (§2, §2.3) | No global Apply. Each popover's **OK** rebuilds the URL and navigates immediately. | Konrad on CP-1: Apply button was far-right + easy to miss, and the dirty-diff on multi-selects was unreliable. `ffb3ef6` |
| 2 | Date pill uses **From / To** pickers, both required (§2.1) | **Until** is the always-filled anchor. **From (optional)** blank = single-month (JS submits `from_month = to_month`). Visual order: `From (optional)` left, `Until` right, English reading order. | Konrad on CP-1: "Until must be auto-filled, From optional." `71f8558`, `3fa3cdc` |
| 3 | Cross-filter scope = **entire history** (§2.5 Semantics) | Cross-filter + picker lists scoped to the **currently-selected date range**. URL-selected IDs always unioned in so they never vanish. | Konrad on CP-1: "Filter out teams and projects that has no log for any of the dates chosen." `71f8558` |
| 4 | Cross-filter **auto-removes** invalid selections and shows a toast (§2.5) | Read-time only: disable invalid options on popover open. No runtime removal, no toast (the next OK submits, so the server handles validation). | Side-effect of (1) — with auto-submit-on-OK there's no pending state to patch. `ffb3ef6` |
| 5 | Dashboard "Generate Report" → `?from_month=current&to_month=current` (§2.4) | Same, implemented as `{% now 'Y-m' %}` template tag. | No deviation, just recording. `1d00a3a` |
| 6 | Not in scope | **"Last Activity" column** added to All Time Projects table. | Konrad on CP-1 surprise ask. Extends `_build_report_context` with `Max(WorkLog.date)`; mirrored in PDF. `f6975bf` |
### Polish not in the original design
| Commit | What |
|--------|------|
| `5c4162d` | Fixed double-encoded `project_team_pairs_json` — the view was calling `json.dumps(pairs)` AND the template's `\|json_script` filter was re-serialising. Now passes raw list. Regression test added. |
| `c1937cd` | Tooltip on **Until** "(ⓘ Single month select)" + shrink `(optional)` helper to 0.6rem |
| `0bbf2ca` | Popover border → 2px accent-orange + three-layer shadow so it visually detaches from the report body. Separate light-theme shadow palette. |
| `dcc0eeb` | Choices.js dropdown → `position: static` scoped to `.filter-popover` so it flows inline (dropdown was being clipped by `overflow: hidden` and not contributing to the body's scrollHeight). Specificity trick: mirrored Choices.js's own `[aria-expanded]` selector to win the source-order tiebreaker. |
| `c26d2e0` | Auto-open Choices dropdown on pill click via `showDropdown(true)` (dropdown was opening hidden until user clicked the input). Helper text colour swapped from `opacity: 0.75` over Bootstrap's `.form-text` default to `var(--text-tertiary)` — was unreadable on the dark card. |
### Tests
- 42 → 47 passing, +5 locked-in behaviours:
- `InlineFiltersPairsContextTests.test_pairs_context_key_populated`
- `test_pairs_excludes_null_project_or_team`
- `test_pairs_renders_as_valid_json_in_template` — end-to-end HTML check for the double-encoding bug
- `test_pickers_and_pairs_are_date_scoped` — out-of-range entries absent from picker and pair map
- `test_url_selected_projects_survive_even_out_of_range` — URL selection unioned into picker list
### Total churn
17 commits on `ai-dev` (prior to push). Across the feature: template -375 net, CSS -65 net (rewritten smaller), view +65 net, tests +130 net. Modal partial deleted (-160). JS module -200 after collapsing the pending/dirty/Apply model.

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,18 @@
/* Layout dimensions */
--bottom-nav-height: 64px;
/* === ADJUSTMENTS TAB — badge palette (dark theme) === */
/* Each adjustment type has its own colour family. Loan-Repayment and
Advance-Repayment are +15% saturation siblings of their parent colour
so "money coming back" reads as a hotter signal than "money going out". */
--badge-bonus-bg: #5b8260; --badge-bonus-fg: #e8f3ea;
--badge-overtime-bg: #a16881; --badge-overtime-fg: #fce4ec;
--badge-deduction-bg: #5b4f8c; --badge-deduction-fg: #e0daf3;
--badge-loan-bg: #9b7f39; --badge-loan-fg: #fef4d1;
--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1;
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2;
}
/* === LIGHT MODE === */
@ -132,6 +144,15 @@
--color-warning-bg: #fffbeb;
--color-info: #2563eb;
--color-info-bg: #eff6ff;
/* === ADJUSTMENTS TAB — badge palette (light theme) === */
--badge-bonus-bg: #d7e8d9; --badge-bonus-fg: #385640;
--badge-overtime-bg: #f3d1dd; --badge-overtime-fg: #703347;
--badge-deduction-bg: #d8d0ef; --badge-deduction-fg: #3b2f6d;
--badge-loan-bg: #f0dc9d; --badge-loan-fg: #6a5320;
--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418;
--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56;
--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550;
}
/* ===================================================================
@ -1733,3 +1754,357 @@ body, .card, .modal-content, .form-control, .form-select,
border-top: 2px solid var(--border-default) !important;
background: var(--bg-inset);
}
/* === Inline Filters (pill-as-dropdown) on the report page === */
/*
Layered on top of the existing .filter-pill rules (lines ~14961524).
Three components:
1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron
2. .filter-popover: absolute-positioned dropdown anchored under the pill
3. .filter-popover__footer: sticky bottom bar so the OK button stays
visible even when Choices.js expands its dropdown list over the body
There is intentionally NO dirty-state indicator and NO global Apply button
each popover's OK commits and reloads the page immediately. Simpler model,
less state to reason about. (Earlier revision had both; removed 2026-04-23
after UX feedback.)
*/
/* --- Wrapper keeps the popover anchored to its pill --- */
.filter-pill-wrap {
display: inline-flex;
align-items: center;
}
/* --- Editable pill: button, cursor, hover state, chevron --- */
.filter-pill--editable {
cursor: pointer;
border: 1px solid var(--border-default);
background: var(--bg-inset);
color: var(--text-primary);
transition: background-color 120ms, border-color 120ms, box-shadow 120ms;
}
.filter-pill--editable:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.filter-pill--editable[aria-expanded="true"] {
background: var(--bg-card-hover);
border-color: var(--accent);
}
.filter-pill__chevron {
opacity: 0.7;
transition: transform 120ms;
}
.filter-pill--editable[aria-expanded="true"] .filter-pill__chevron {
transform: rotate(180deg);
}
/* --- Popover positioned under the pill --- */
/* Border + shadow beefed up 2026-04-23 so the popover visually detaches
from the report body behind it previous subtle shadow was getting
lost against the amber-accented report cards.
The popover uses a flex column so a sticky footer stays pinned at the
bottom even when the body scrolls. We DO NOT set overflow: hidden on
the popover itself see the Choices.js override below for why. */
.filter-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
min-width: 300px;
max-width: 420px;
max-height: min(70vh, 520px);
display: flex;
flex-direction: column;
background: var(--bg-card);
/* Two-layer border for depth: outer accent-tinted halo + inner crisp edge */
border: 2px solid var(--accent);
border-radius: 0.5rem;
box-shadow:
0 0 0 4px rgba(232, 133, 26, 0.08), /* soft accent halo */
0 18px 44px rgba(0, 0, 0, 0.55), /* deep drop shadow */
0 6px 12px rgba(0, 0, 0, 0.35); /* near shadow for edge crispness */
padding: 0;
}
/* Light theme: shadow and halo need different opacity to read against white */
:root.light .filter-popover {
box-shadow:
0 0 0 4px rgba(217, 119, 6, 0.12),
0 18px 44px rgba(15, 23, 42, 0.22),
0 6px 12px rgba(15, 23, 42, 0.14);
}
.filter-popover[hidden] {
display: none;
}
.filter-popover__body {
padding: 1rem;
overflow-y: auto; /* body scrolls when content exceeds max-height */
flex: 1 1 auto;
}
/* Footer is sticky at the bottom of the popover so the OK button is always
reachable fixes the "Choices.js dropdown hides the OK button" complaint. */
.filter-popover__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--border-default);
background: var(--bg-inset);
border-radius: 0 0 0.5rem 0.5rem;
flex: 0 0 auto;
position: sticky;
bottom: 0;
z-index: 2;
}
/* --- Choices.js dropdown override (scoped to filter popovers) ---
Choices.js renders its option list as position: absolute beneath the
input. Inside our popovers (a flex column with a max-height and a
sticky footer) that's a problem:
1. The absolute-positioned dropdown doesn't contribute to the body's
scrollHeight, so the body's overflow-y: auto never creates a
scrollbar and the user's wheel scroll falls through to the page.
2. The dropdown's rendered position overlaps / sits behind the sticky
footer, so options aren't visible.
Forcing the dropdown to position: static lets it flow inline: the body
grows to contain it, the sticky footer pushes below, and for long
option lists the dropdown's own max-height + overflow-y gives a clean
internal scroll.
Specificity note: we mirror Choices.js's selector list
(`.choices__list--dropdown, .choices__list[aria-expanded]`) because
the second branch carries a class+attribute specificity (0,0,2,0)
that ties with a naive two-class override. Source order then decides
the winner and Choices.js's stylesheet loads AFTER ours. Mirroring
the selector lifts our specificity one step on the aria-expanded
branch and wins cleanly without needing !important.
Scoped to .filter-popover so other Choices.js usages in the app
(worker/team pickers on edit pages, etc.) keep their default behaviour. */
.filter-popover .choices__list--dropdown,
.filter-popover .choices__list[aria-expanded] {
position: static;
margin-top: 0.35rem;
max-height: 260px;
overflow-y: auto;
}
/* --- Mobile: popovers stretch full-width below the pill strip --- */
@media (max-width: 576px) {
.filter-popover {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100vw;
max-width: 100vw;
max-height: 80vh;
border-radius: 0.5rem 0.5rem 0 0;
z-index: 1050;
}
}
/* =============================================================================
* ADJUSTMENTS TAB
* Visual vocabulary for the Payroll Adjustments tab.
* - 7 badge classes, one per adjustment type
* - Sticky filter bar that stays visible as the table scrolls
* - Group-by header style (collapsible section divider)
* - Floating bulk-action bar at the bottom of the viewport
* ============================================================================= */
/* --- Type badges (one class per PayrollAdjustment type) --- */
.badge-type-bonus,
.badge-type-overtime,
.badge-type-deduction,
.badge-type-new-loan,
.badge-type-loan-repayment,
.badge-type-advance-payment,
.badge-type-advance-repayment {
display: inline-block;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.badge-type-bonus { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-type-overtime { background: var(--badge-overtime-bg); color: var(--badge-overtime-fg); }
.badge-type-deduction { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-type-new-loan { background: var(--badge-loan-bg); color: var(--badge-loan-fg); }
.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); }
.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); }
.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); }
/* --- Sticky filter bar (keeps filters visible as the table scrolls) --- */
.adjustments-filter-bar {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-card);
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-default);
border-radius: 0.5rem 0.5rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
/* All children are now same-height pills — center-align them */
align-items: center;
}
/* Apply / Clear push to the right end of the bar */
.adjustments-filter-bar > form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.adjustments-filter-bar .adj-apply-group {
margin-left: auto;
display: flex;
gap: 0.35rem;
}
/* --- Group header (collapsible section divider for group-by mode) --- */
.adj-group-header {
cursor: pointer;
padding: 0.75rem 1rem;
background: var(--bg-inset);
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
gap: 0.75rem;
user-select: none;
transition: background-color 120ms;
}
.adj-group-header:hover { background: var(--bg-card-hover); }
.adj-group-header .fa-chevron-down,
.adj-group-header .fa-chevron-right { opacity: 0.7; width: 0.8rem; }
.adj-group-header .adj-group-label { font-weight: 600; }
.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; }
/* --- By-Type group headers: 4px left-accent picks up the type's signature
badge colour so grouped rows visually echo the badges below.
Uses a [data-type="..."] attribute on the <tr class="adj-group-header">
so the selector is self-descriptive (no per-type class explosion). --- */
.adj-group-header[data-type="Bonus"] { border-left: 4px solid var(--badge-bonus-bg); }
.adj-group-header[data-type="Overtime"] { border-left: 4px solid var(--badge-overtime-bg); }
.adj-group-header[data-type="Deduction"] { border-left: 4px solid var(--badge-deduction-bg); }
.adj-group-header[data-type="New Loan"] { border-left: 4px solid var(--badge-loan-bg); }
.adj-group-header[data-type="Loan Repayment"] { border-left: 4px solid var(--badge-loan-rep-bg); }
.adj-group-header[data-type="Advance Payment"] { border-left: 4px solid var(--badge-advance-bg); }
.adj-group-header[data-type="Advance Repayment"] { border-left: 4px solid var(--badge-advance-rep-bg); }
/* --- Chevron rotates to indicate collapsed / expanded state.
Bootstrap sets aria-expanded="false" on the toggle when collapsed;
we rotate the chevron 90deg counter-clockwise so it points right,
a familiar "this is collapsed" signal. --- */
.adj-group-header .fa-chevron-down { transition: transform 150ms ease; }
.adj-group-header[aria-expanded="false"] .fa-chevron-down { transform: rotate(-90deg); }
/* --- Floating bulk action bar (appears when >=1 row selected) --- */
.adj-bulk-bar {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
z-index: 1050;
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: 2rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 0.6rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
animation: adj-bulk-bar-in 180ms ease-out;
}
.adj-bulk-bar[hidden] { display: none; }
@keyframes adj-bulk-bar-in {
from { opacity: 0; transform: translate(-50%, 10px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* --- Empty state card --- */
.adj-empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.adj-empty-state .adj-empty-icon { font-size: 2.5rem; opacity: 0.35; margin-bottom: 1rem; }
/* --- Group-by toggle pill buttons (Flat / By Type / By Worker) --- */
/* margin-top: 1rem gives breathing room between the sticky filter bar and
this row they used to read as one cramped block. */
.adj-groupby-toggle {
margin-top: 1rem;
margin-bottom: 0.75rem;
}
.adj-groupby-toggle .btn { font-size: 0.8rem; padding: 0.3rem 0.75rem; }
/* --- Sort header arrows --- */
th.sortable { cursor: pointer; user-select: none; }
th.sortable .sort-arrow {
opacity: 0.4;
margin-left: 0.25rem;
font-size: 0.7rem;
transition: opacity 120ms;
}
th.sortable:hover .sort-arrow,
th.sortable.sorted .sort-arrow { opacity: 1; }
/* =============================================================================
* ADJUSTMENTS TAB pill-popover checkbox list
* The Type / Workers / Teams filters each open a popover that reuses the
* shared .filter-popover styles (see "Inline Filters" block above). This
* section only adds the bits specific to the checkbox-list body the rest
* of the visual vocabulary (pill button, popover chrome, sticky footer)
* is inherited.
* ============================================================================= */
/* --- Adjustments popover tighter density for the checkbox/radio list ---
Font-size drop (~14px 12.8px) + tighter row padding cuts the
7-type Type popover from ~320px tall to ~240px without losing
readability. Radios in the Status popover and date fields in the
Date popover inherit the same size via .adj-radio-list. */
.adj-checkbox-list,
.adj-radio-list {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-subtle);
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Each row is a full-width <label> so clicking the text toggles the input */
.adj-cb-row {
cursor: pointer;
margin: 0;
padding: 0.15rem 0.25rem;
border-radius: 0.25rem;
transition: background-color 120ms;
}
.adj-cb-row:hover { background: var(--bg-card-hover); }
.adj-cb-label { user-select: none; }
.adj-cb-row .form-check-input { width: 0.9em; height: 0.9em; }
/* Popover footer buttons (OK / Cancel / All / Invert / Clear) match the
same compact typography reads as one cohesive strip. */
.filter-popover__footer .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; }
/* The Adjustments popover is also slightly narrower now (less visual weight
once the filter bar is all pills of the same size). */
.adj-filter-pill + .filter-popover,
#adjustmentsFilters .filter-popover { max-width: 360px; }
/* --- Count badge shown on the pill when 2+ options are selected --- */
/* (For 0 or 1 selected the label text carries the info; the badge stays hidden.) */
.filter-pill__count {
font-size: 0.75em;
opacity: 0.75;
font-weight: 600;
}