23 Commits

Author SHA1 Message Date
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
89f109afb4 test(adjustments): strengthen subquery + multi-filter tests
The team-filter test was passing regardless of the subquery pattern
because each worker was on only one team — no cardinality to inflate.
Fixture now puts both workers on both teams so a naive
worker__teams__id__in filter would return 6 rows (2 teams × 3
adjustments). The type-filter test now passes BOTH Bonus AND Deduction
so it exercises the multi-select code path (not just a single value).

Both assertions use adj_total_count (.count() at queryset level) so
regressions blow up at aggregation rather than just the paginator page.
2026-04-23 15:22:19 +02:00
Konrad du Plessis
10d381e2ae feat(adjustments): backend filter branch for ?status=adjustments
Type / worker / team / status / date filters, sort, stats, pagination.
Subquery pattern on the team filter avoids M2M JOIN inflation
(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter
come later (Tasks 5/6/7).
2026-04-23 15:12:19 +02:00
Konrad du Plessis
97d8a69212 feat(adjustments): add |type_slug template filter for badge class naming 2026-04-23 14:54:50 +02:00
Konrad du Plessis
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
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
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
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
16d192d5fc Refactor _build_report_context signature to multi-value filters
project_id/team_id become project_ids/team_ids (list[int] or None).
Every internal filter uses the __in lookup; M2M filters use the
id__in subquery pattern documented in CLAUDE.md's Django ORM gotcha.
generate_report and generate_report_pdf switch to request.GET.getlist.
Old URL ?project=1 still works - getlist returns a single-element list.

Return dict gains six hero-KPI keys: current_outstanding, current_as_of,
company_avg_daily, company_avg_monthly, company_working_days,
team_project_activity - ready for the template restructure in Tasks 9-12.

Tests: 3 new multi-filter tests; existing inflation tests updated to the
new kwarg names. 42 total, all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:53:16 +02:00
Konrad du Plessis
ea1e4bdbcb Enrich alltime_projects context with working_days + avg_per_working_day
Chapter I of the executive report needs per-project working-day count and
avg rand per working day. Instead of modifying the shared _get_labour_costs
helper (used by other sections with different column sets), enrich the
output INSIDE _build_report_context: wrap the raw result and add
working_days (distinct work-log dates per project) and avg_per_working_day
(total_cost / working_days, null-safe).

Also attaches start_date from the Project model (may be None if not set).

1 test added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:45:08 +02:00
Konrad du Plessis
e8ba2c6745 Add _team_project_activity helper + 4 tests
Chapter IV pivot backend: for each (team, project) pair in the given
work-logs queryset, counts distinct work-log dates. Returns columns
(projects), rows (teams with cell dict), column totals, and grand total
ready for direct template rendering.

Logs with NULL team or NULL project are excluded (can't pivot on NULL).
Teams/projects with zero activity don't appear as rows/columns — keeps
the pivot tight.

Tests cover shape, cell counts, row+column+grand totals, and
zero-activity team omission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:38:53 +02:00
Konrad du Plessis
ccc44a8d51 Fix _current_outstanding_in_scope sort + tighten team-filter test
Two tweaks from code review on 82594fa:

1. The sort `key=lambda r: -r['amount']` placed NEGATIVE amounts
   (rare but possible: a project with only a deductive adjustment)
   AHEAD of larger positive exposures. Swapped to
   `key=lambda r: r['amount'], reverse=True` — same runtime, clearer
   intent, correct for negatives.

2. test_team_filter_scopes_total only asserted the net total. A
   partial scoping regression where the adjustment leaked but netted
   to zero would have silently passed. Added two assertions that
   by_project has exactly the expected 2 entries and R 500 never
   appears in the amount list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:35:30 +02:00
Konrad du Plessis
82594faad7 Add _current_outstanding_in_scope helper + 3 tests
Hero KPI card 2 needs 'Outstanding NOW' scoped to the report's selected
projects/teams. This helper wraps _compute_outstanding, reshapes the
by_project dict into a sorted list, and exposes the net total for direct
rendering.

Tests cover unfiltered total, project-scoped total, and team-scoped
total (including the worker__teams subquery path for adjustments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:29:41 +02:00
Konrad du Plessis
e74f48f050 Add _company_cost_velocity helper + 3 tests
Computes company-wide avg daily and monthly labour cost for the
executive report's hero KPI band (cards 3 and 4). Denominator is
working days (distinct work-log dates), not calendar days — true
cost-of-a-productive-day metric per design section 2.

Monthly = daily * 30.44 (the 365.25/12 month-length approximation,
which keeps annualised totals correct on average).

Tests cover: empty DB returns zero, known values with assertAlmostEqual
for the 30.44 multiplication, and that multiple workers on one date
count as 1 working day (not N).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:20:14 +02:00
Konrad du Plessis
0ceceebba4 Fix: supervisor picker hid regular active users (only admins showed)
Reported: when creating a new team or project from the friendly UI
(/teams/new/ or /projects/new/), the Supervisor dropdown only lists
is_staff / is_superuser accounts. Users who should be eligible to
supervise (e.g. eendman, supervisor_smoke) are invisible in the
picker even though they are active.

Root cause:
core.forms._supervisor_user_queryset filtered to
  is_active=True AND (is_staff OR is_superuser OR groups__name='Work Logger')
That was strictly more restrictive than the app's own permission
helper is_supervisor(user) in views.py, which grants supervisor
powers to ANYONE assigned to a team/project (via the team.supervisor
FK or project.supervisors M2M), regardless of group membership.
On Konrad's dev DB that excluded 2 of 6 active users from the picker
(one in a custom group, one in no group) even though both were valid
supervisor candidates by the permission model.

Fix:
Queryset now returns every active user. The act of assigning a user
to a team/project is what confers supervisor-ness downstream, so
the picker no longer needs a pre-registered allow-list. Inactive
users (is_active=False) remain excluded — the one hard guardrail.

Docstring rewritten to explain the new behavior and why. Stale comment
in TeamForm.__init__ updated to match (the old comment still described
the pre-fix Work-Logger-group requirement).

Tests: 4 new regression tests in SupervisorPickerQuerysetTests:
  - regular active user is selectable (the core bug)
  - user in an unrelated group is selectable
  - inactive user is still excluded (guardrail)
  - admin is still selectable (no regression for prior use case)
All 28 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:52:29 +02:00
Konrad du Plessis
f1e246ce24 Fix: filtered payroll report inflates worker totals by N^2
Reported: when the generate-report page is filtered by BOTH project and
team, every amount in the "Worker Breakdown" and "Payments by Date"
tables blew up by ~100x. Example: Billy Baloyi R 5,400 (correct)
became R 604,800 (wrong, 112x) after selecting Wilkot + Civils One.

Root cause:
_build_report_context chained `records.filter(work_logs__project_id=X)
.distinct().filter(work_logs__team_id=Y).distinct()`. In Django's ORM
each chained M2M filter creates a SEPARATE JOIN alias on
core_payrollrecord_work_logs, so the SQL produces the cartesian product
of (matching-logs-for-project) x (matching-logs-for-team) rows per
PayrollRecord. A downstream `.values().annotate(Sum('amount_paid'))`
then summed across those duplicated rows - inflating every total by
N * M where N and M are the log counts per record.

Why total_paid_out looked correct: `.aggregate(Sum(...))` wraps the
query in a subquery when distinct() is in play, so it dedupes before
summing. `.values().annotate(Sum(...))` uses GROUP BY on the raw
joined rows and doesn't get that help.

Fix:
Replace chained M2M filters with id__in subquery filters:
  records.filter(id__in=PayrollRecord.objects.filter(
      work_logs__project_id=X).values('id'))
This keeps the outer queryset JOIN-free, so values().annotate(Sum())
aggregates over distinct records. Same pattern applied to the
adjustments team-filter (worker__teams M2M) for the adjustment
summary.

Tests: 5 new regression tests in ReportContextFilterInflationTests
covering project-only, team-only, both-filters, total_paid_out
invariant, and the adjustment summary path. All 24 tests pass
(19 existing + 5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:51:07 +02:00
Konrad du Plessis
6d37d1ba9b Task 10: add Task 3 full-payload test + mark design doc as shipped
Adds a consolidated regression test to WorkLogPayrollAjaxTests that
exercises: paid worker serialization shape, null team branch, OT flag
in JSON, full_page_url value, and adjustment payslip-link serialization.
Closes the 'Important' coverage gap flagged in Task 3's quality review.

Also appends a 'Shipped' block to the design doc summarising QA
status and capturing all five deferred nits (admin-gate consistency,
template branch tests, |default:0 redundancy, admin-gate expression
readability, background vs background-color) so they survive the
merge into project history.

All 19 tests pass. manage.py check clean. No migrations needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:23:24 +02:00
Konrad du Plessis
9276e588a0 Full-page view at /history/<id>/ for work log payroll status
Extends base.html; breadcrumb, attendance card, workers table,
adjustments card (conditional), totals. Pay-period uses
get_pay_period() and falls back to 'no schedule' + configure link.
2 view-level tests: admin 200, supervisor 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:34:16 +02:00
Konrad du Plessis
5720ca95ad AJAX endpoint returns JSON payload for work log payroll modal
work_log_payroll_ajax serializes the helper's output to JSON with
floats (not Decimals), ISO dates, and payroll_record/worker IDs for
client-side link construction. Admin-only; supervisor = 403, anon =
302, unknown log = 404. Matches the worker_lookup_ajax pattern.

Added 4 view-level tests (total 16 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:57:16 +02:00
Konrad du Plessis
b0aa35661b Fix overtime_needs_pricing flag + add regression tests
The helper used log.overtime (which doesn't exist on WorkLog); the
correct field is overtime_amount. Combined with a defensive
`getattr(..., None) or 0`, the bug made the flag permanently False,
which would have silently hidden the 'Price now' banner in Tasks 3
and 4. Now reads overtime_amount directly (it's non-nullable with a
0.00 default, so no defensive shim is needed).

Adds 4 regression tests:
- test_overtime_needs_pricing_flag: the bug that just got fixed
- test_query_count_is_bounded: N+1 guard (4 queries regardless of worker count)
- test_empty_log_returns_zero_totals: log with no workers attached
- test_log_without_team_has_no_pay_period: log whose team became NULL

Also removes unused `reverse` import from tests.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:21:34 +02:00
Konrad du Plessis
385d654082 Implement _build_work_log_payroll_context helper + 8 tests
Pure-function helper that classifies each worker on a work log as
Paid / Priced-not-paid / Unpaid, collects log-linked adjustments,
and computes totals + pay-period context. Used by both the AJAX
endpoint and the full-page view so they can't drift.

Bootstraps core/tests.py (was empty); 8 tests cover the three
statuses, totals, log-linked adjustments, and the pay-period branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:01:14 +02:00
Flatlogic Bot
d3fb8046d5 Initial version 2026-02-22 12:14:54 +00:00