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>
8.4 KiB
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=Trueoris_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)
- Header strip — date, project (link), team (link), supervisor, worker count, OT-priced flag
- 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
—)
- Worker name →
- Related adjustments — every
PayrollAdjustmentwithadj.work_log == log. Worker and payslip as links. - Footer — total earned, total paid, total outstanding ·
[Open full page]·[Close]
Full page (/history/<log_id>/)
Everything the modal has, plus:
- Breadcrumb —
History › {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 nopay_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 > 0andlog.priced_workersempty) → amber banner at top of modal: "Overtime not yet priced · [Price now]" linking to the existingprice_overtimeflow - Worker deactivated since → name struck through, grey
Inactivepill, 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 modalwork_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-gateddata-log-idattribute on each row + JS click handlercore/templates/core/teams/detail.html— same on the Recent Work Logs card rowscore/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-modaldefined once inbase.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
Cross-links this adds to the app's graph
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:0onlog.overtime_amount(harmless, renders "0" instead of "0.00"). - Task 5 admin gate:
user.is_authenticated and user.is_staff or user.is_superusercould be simplified touser.is_staff or user.is_superuser. backgroundshorthand in Task 9 hover rule could bebackground-colorfor 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.