38686-vm/docs/plans/2026-04-22-work-log-payroll-crosslink-design.md
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

8.4 KiB
Raw Permalink Blame History

Work Log → Payroll Cross-Link — Design (22 Apr 2026)

Goal

Let admins click a historic work log (anywhere it appears) and instantly see which workers on that log have been paid, which haven't, and for paid ones, which payslip paid them — with hyperlinks through to the existing Worker and Payslip detail pages.

Today the data exists (PayrollRecord.work_logs M2M, PayrollAdjustment.work_log FK) but is only reachable from the payroll dashboard by working backwards from a payment to its logs. This closes the loop: from a log you can reach its payments, not just the other way round.

Who it's for

  • Admins (is_staff=True or is_superuser=True) — full click-through to payroll data
  • Supervisors (Work Logger group) — unchanged behaviour. Payroll data stays hidden per the existing permission rule.

Entry points (three places, same modal)

Page Row source
/history/ — Work History each WorkLog in the paginated table
/teams/<id>/ — Recent Work Logs card same logs, team-filtered
/projects/<id>/ — Recent Work Logs card same logs, project-filtered

On each of these, the work-log row becomes clickable for admins: cursor: pointer, --bg-card-hover on hover, a small chevron icon at the right edge. For supervisors the rows keep their current non-interactive styling.

Interaction model — hybrid modal + full page

  • Row click → Bootstrap modal fetches JSON and renders inline. Fast path for "did X get paid for this?". Same pattern as the existing Worker Lookup modal.
  • [Open full page] button inside the modal → navigates to /history/<log_id>/, a dedicated bookmark-able page. Unlimited room for detail.

Both views share the same underlying data; the modal is a compact subset of the page.

What each view shows

Modal (compact)

  1. Header strip — date, project (link), team (link), supervisor, worker count, OT-priced flag
  2. Workers table — one row per log.workers.all():
    • Worker name → /workers/<id>/
    • Status: Paid · Priced, not paid · Unpaid
    • Earned from this log (net of adjustments on the same payroll record)
    • Payslip reference → /payroll/payslip/<pk>/ (or )
    • Paid on date (or )
  3. Related adjustments — every PayrollAdjustment with adj.work_log == log. Worker and payslip as links.
  4. Footer — total earned, total paid, total outstanding · [Open full page] · [Close]

Full page (/history/<log_id>/)

Everything the modal has, plus:

  • BreadcrumbHistory {date} · {project} · {team}, each segment a link
  • Attendance detail block — OT hours per worker (from log.overtime), supervisor, notes
  • Pay-period context — uses get_pay_period(log.team) to show which period this log falls in and expected paydate if unpaid. Graceful "no schedule configured" fallback if the team has no pay_frequency.

Layout matches /workers/<id>/ — single-column card-paneled page, same typography and spacing.

Status logic (three states)

For each worker on the log:

record = PayrollRecord.objects.filter(work_logs=log, worker=worker).first()

if record:
    status = "Paid"           # show payslip + date
elif worker in log.priced_workers.all():
    status = "Priced, not paid"   # OT priced, no payroll cycle yet
else:
    status = "Unpaid"

Same per-worker checking CLAUDE.md documents for the dashboard's outstanding calculation. Handles partially-paid logs correctly.

Edge cases

  • Brand-new log, never paid → all workers Unpaid, zero totals, no error
  • Partially paid → mixed statuses per row (the whole reason per-worker checking exists)
  • Overtime not yet priced (log.overtime > 0 and log.priced_workers empty) → amber banner at top of modal: "Overtime not yet priced · [Price now]" linking to the existing price_overtime flow
  • Worker deactivated since → name struck through, grey Inactive pill, still shown
  • Team/project deleted (SET_NULL) → header shows for the missing reference, no crash
  • Supervisor hits URL directly → 403 via is_admin(), same as every /payroll/* view
  • Log with many workers → modal scrolls internally (max-height: 70vh); full page handles it natively

Implementation shape (no code yet)

New URLs

/history/<int:log_id>/              → work_log_payroll_detail    (HTML full page)
/history/<int:log_id>/payroll/ajax/ → work_log_payroll_ajax      (JSON for modal)

New views in core/views.py (both @login_required, both admin-gated)

  • _build_work_log_payroll_context(log) — private helper. Returns a context dict with workers+status, adjustments, totals, pay-period info. One function, used by both endpoints so the JSON payload and the HTML page can never drift.
  • work_log_payroll_ajax(request, log_id) — calls the helper, returns JSON for the modal
  • work_log_payroll_detail(request, log_id) — calls the helper, renders the full-page template

New template

  • core/templates/core/work_log_payroll.html — full-page view. Structure mirrors /workers/<id>/ so it feels like a sibling.

Small edits to existing templates

  • core/templates/core/work_history.html — admin-gated data-log-id attribute on each row + JS click handler
  • core/templates/core/teams/detail.html — same on the Recent Work Logs card rows
  • core/templates/core/projects/detail.html — same

New JS (inline in base.html or work_log_modal.js)

  • Single click handler, attached to [data-log-id] elements across any page
  • fetch → inject HTML into a shared #work-log-payroll-modal defined once in base.html
  • Same pattern as the existing Worker Lookup modal

What we DON'T need

  • No model changes
  • No migrations
  • No new fields
  • No changes to process_payment, add_adjustment, or any mutation path

Before:

Payroll Dashboard → Payslip → [dead end]
Work History      → [dead end]

After:

Work History ↘                    Payslip ↗ Worker detail
Team detail → Work Log Payroll →
Project detail ↗                  Worker name ↘ Worker detail

Every work log becomes a first-class navigable node, reachable from three places, linking out to the two existing detail pages.

Rough scope

  • ~150 lines new view code (helper + two endpoints)
  • ~120 lines new full-page template
  • ~40 lines JS for the modal
  • ~30 lines CSS for hover and chevron states
  • ~20 lines of edits across three existing templates

No third-party dependencies added.

Out of scope (deliberately)

  • "Pay these workers now" action inside the modal/page — kept read-only for this pass; admins still use the payroll dashboard's Pay flow. Can add later if useful.
  • "Add adjustment pre-filled for this log" shortcut — tempting but expands scope; revisit after the read-only version ships.
  • Supervisor-visible attendance-only view of the same click-through — supervisors get no click affordance for now; if they later need a non-payroll drill-down, that's a separate design.

Next step

Hand off to superpowers:writing-plans to produce a task-by-task implementation plan with review checkpoints.


Shipped — 22 Apr 2026

Commits: 1c00ba2 (design) through the Task 10 "shipped" commit (this one). Plan: docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md.

QA summary:

  • 19 tests pass (python manage.py test core.tests)
  • python manage.py check — no new issues
  • No model changes, no migrations, no pushed-to-prod artefacts
  • Three entry points clickable for admins; supervisors unchanged

Deferred for future passes (non-blocking):

  • Admin-gate consistency: work_history uses {% if is_admin %}; teams/projects detail templates use {% if user.is_staff or user.is_superuser %}. Both semantically identical but stylistically inconsistent.
  • Task 4 template branch tests: OT banner, adjustments table, Paid badge, Inactive worker — covered end-to-end via modal tests now, not via Django test client.
  • Task 4 template: redundant |default:0 on log.overtime_amount (harmless, renders "0" instead of "0.00").
  • Task 5 admin gate: user.is_authenticated and user.is_staff or user.is_superuser could be simplified to user.is_staff or user.is_superuser.
  • background shorthand in Task 9 hover rule could be background-color for precision (pedantic).

"Pay these workers now" modal action and "Add adjustment pre-filled for this log" shortcut — explicitly out-of-scope per the design; revisit if users ask.