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>
169 lines
8.4 KiB
Markdown
169 lines
8.4 KiB
Markdown
# 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:
|
||
- **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 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
|
||
|
||
## 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: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.
|