38686-vm/docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
Konrad du Plessis 0ec3f66739 Plan: work log -> payroll cross-link implementation plan
Task-by-task plan for implementing the modal + /history/<id>/ page
designed in the companion design doc. 10 tasks, 4 hard-pause review
checkpoints (after tasks 2, 4, 6, 10). TDD for the pure helper
function (bootstraps the currently-empty core/tests.py), view-level
tests for the AJAX + detail endpoints, manual smoke tests for the
template/JS work.

Uses the existing worker_lookup_ajax JSON+DOM pattern for the modal
(createElement + textContent, not innerHTML) to match the codebase's
XSS-safe convention. Full page is server-side rendered via a Django
template.

No model changes. No migrations. Admin-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:34:21 +02:00

1348 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Work Log → Payroll Cross-Link Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Let admins click any historic work log (in Work History, team detail, or project detail) and instantly see who on that log has been paid vs. not, with hyperlinks to the existing Worker and Payslip pages — via both a fast modal and a shareable `/history/<id>/` page.
**Architecture:** One private helper `_build_work_log_payroll_context(log)` produces the data. The AJAX endpoint serializes that data to **JSON** (same XSS-safe pattern as `worker_lookup_ajax`) and the JS modal builds its DOM safely with `createElement` + `textContent`. The full-page view at `/history/<log_id>/` renders the same context via a Django template (server-side, auto-escaped). JS click handler lives in `base.html` and listens for any element with `data-log-id`, so all three source pages opt in by adding that one attribute. No model changes, no migrations, admin-only.
**Tech Stack:** Django 5.2.7 · Bootstrap 5.3 modal · vanilla JS (`fetch` + JSON + `createElement`/`textContent`, no new libs) · Font Awesome 6. Follows the pattern established by `worker_lookup_ajax` (core/views.py:3352) and its modal (core/templates/core/payroll_dashboard.html:864). The safer JSON+DOM approach (instead of innerHTML string injection) is chosen for consistency with that existing code, so future edits use one mental model.
**Design source:** `docs/plans/2026-04-22-work-log-payroll-crosslink-design.md` (commit 1c00ba2) — read that first if you haven't.
**Commit style:** Follow CLAUDE.md. Use `# === SECTION NAME ===` comments in Python/templates. Write comments a non-programmer could read. One commit per task; never amend.
---
## Review checkpoints
After **Tasks 2, 4, 6, and 10**, stop and wait for Konrad to confirm before continuing. Each is a natural "demo-able" point:
- After Task 2 — helper function works; data correct (unit-testable without UI).
- After Task 4 — full-page view works in the browser at `/history/<log_id>/`.
- After Task 6 — row click on `/history/` opens the modal end-to-end.
- After Task 10 — complete feature QA'd across all three entry points.
---
## Task 1: URL scaffolding + empty view stubs
**Why first:** gets routes in place so URL reverse lookups work in tests and templates from the start. Nothing visible yet.
**Files:**
- Modify: `core/urls.py` (add 2 new paths after the `history/export/` line, around line 20)
- Modify: `core/views.py` (add 3 stubs immediately after the existing `work_history` view)
**Step 1.1: Add URL patterns in `core/urls.py`**
Inside `urlpatterns = [`, right after the existing `path('history/export/'...)`:
```python
# === WORK LOG PAYROLL CROSS-LINK (admin-only) ===
# Click a historic work log -> see who got paid and who didn't.
# AJAX endpoint returns JSON (the modal builds its own DOM safely);
# detail view renders the same data as a shareable full page.
path('history/<int:log_id>/', views.work_log_payroll_detail, name='work_log_payroll_detail'),
path('history/<int:log_id>/payroll/ajax/', views.work_log_payroll_ajax, name='work_log_payroll_ajax'),
```
**Step 1.2: Add stub views in `core/views.py`**
Immediately after the existing `work_history` view (scroll past its return statement). Helper first so views can call it:
```python
# =============================================================================
# === WORK LOG PAYROLL CROSS-LINK ===
# From any historic work log, see which workers got paid, which didn't, and
# (for paid ones) which payslip it was. Admin-only; supervisors never see
# payroll data. Two endpoints share one helper so the modal and the full
# page can never drift apart.
# =============================================================================
def _build_work_log_payroll_context(log):
"""Return a context dict describing the payroll status of a work log.
Used by both the AJAX modal endpoint and the full-page detail view so
they always show identical data. See Task 2 for the full implementation.
"""
# Stub — implemented in Task 2
return {'log': log}
@login_required
def work_log_payroll_ajax(request, log_id):
"""Return JSON describing the payroll status of a work log."""
# Stub — implemented in Task 3
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
get_object_or_404(WorkLog, id=log_id)
return JsonResponse({'stub': True})
@login_required
def work_log_payroll_detail(request, log_id):
"""Render the full payroll-status page for a single work log."""
# Stub — implemented in Task 4
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
log = get_object_or_404(WorkLog, id=log_id)
return HttpResponse(f"<p>stub for log {log.id}</p>")
```
**Step 1.3: Verify Django starts clean**
```bash
python manage.py check
```
Expected: `System check identified no issues (0 silenced).`
**Step 1.4: Smoke-test the routes**
With the dev server running and logged in as admin in your browser:
- Open `http://localhost:8000/history/1/` → should show "stub for log 1" (or 404 if there's no log with id=1 — try another id).
- Open `http://localhost:8000/history/1/payroll/ajax/` → should show JSON `{"stub": true}`.
If either is a plain 404 (not Django's debug 404), the URL didn't register.
**Step 1.5: Commit**
```bash
git add core/urls.py core/views.py
git commit -m "Add URL routes + stubs for work log payroll cross-link
Routes /history/<id>/ and /history/<id>/payroll/ajax/ to stub views.
Both admin-gated; no data yet. Sets up the surface for Tasks 2-4.
"
```
---
## Task 2: Implement `_build_work_log_payroll_context(log)` — with tests
**Why TDD here:** the helper is pure logic, zero UI. Tests prove correctness before we invest in templates. Also, `core/tests.py` is currently empty — this task bootstraps the test file.
**Files:**
- Modify: `core/views.py` (replace Task-1 stub with the real helper)
- Modify: `core/tests.py` (currently empty; add 8 tests)
**Step 2.1: Write the failing tests first**
Replace the entire contents of `core/tests.py` with:
```python
# === TESTS FOR WORK LOG PAYROLL CROSS-LINK ===
# Covers the _build_work_log_payroll_context helper — the core logic that
# determines, for each worker on a log, whether they were paid for it.
import datetime
from decimal import Decimal
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.views import _build_work_log_payroll_context
class WorkLogPayrollContextTests(TestCase):
"""Tests for the helper that builds the payroll-status view of a work log."""
def setUp(self):
# Minimal scenario: 1 admin, 1 project, 1 team, 3 workers, 1 log.
# Worker A has been paid for the log; Worker B is priced-not-paid;
# Worker C is unpaid.
self.admin = User.objects.create_user(username='admin', is_staff=True)
self.project = Project.objects.create(name='Test Project')
self.team = Team.objects.create(name='Team X', supervisor=self.admin)
self.worker_a = Worker.objects.create(name='Alice', id_number='A1', monthly_salary=Decimal('4000'))
self.worker_b = Worker.objects.create(name='Bob', id_number='B1', monthly_salary=Decimal('4000'))
self.worker_c = Worker.objects.create(name='Carol', id_number='C1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=self.project,
team=self.team,
supervisor=self.admin,
)
self.log.workers.add(self.worker_a, self.worker_b, self.worker_c)
# Worker A has a PayrollRecord linking them and this log — "Paid".
self.record_a = PayrollRecord.objects.create(
worker=self.worker_a,
amount_paid=Decimal('200.00'),
date=datetime.date(2026, 4, 15),
)
self.record_a.work_logs.add(self.log)
# Worker B appears in priced_workers but has no PayrollRecord — "Priced, not paid".
self.log.priced_workers.add(self.worker_b)
# Worker C has neither — "Unpaid".
def test_returns_log_and_worker_rows(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['log'], self.log)
self.assertEqual(len(ctx['worker_rows']), 3)
def test_paid_worker_has_payslip_link(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_a.id)
self.assertEqual(row['status'], 'Paid')
self.assertEqual(row['payroll_record'], self.record_a)
self.assertGreater(row['earned'], 0)
def test_priced_but_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_b.id)
self.assertEqual(row['status'], 'Priced, not paid')
self.assertIsNone(row['payroll_record'])
def test_totally_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_c.id)
self.assertEqual(row['status'], 'Unpaid')
self.assertIsNone(row['payroll_record'])
def test_totals(self):
ctx = _build_work_log_payroll_context(self.log)
# Paid = Alice's daily_rate (one record exists for this log+worker).
self.assertEqual(ctx['total_paid'], self.worker_a.daily_rate)
# Outstanding = Bob + Carol each at their daily_rate.
expected = self.worker_b.daily_rate + self.worker_c.daily_rate
self.assertEqual(ctx['total_outstanding'], expected)
def test_adjustments_linked_to_log(self):
adj = PayrollAdjustment.objects.create(
worker=self.worker_a,
project=self.project,
type='Overtime',
amount=Decimal('50.00'),
date=datetime.date(2026, 4, 10),
reason='Extra hour',
work_log=self.log,
)
ctx = _build_work_log_payroll_context(self.log)
self.assertIn(adj, ctx['adjustments'])
def test_pay_period_absent_if_no_schedule(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
def test_pay_period_present_when_schedule_configured(self):
self.team.pay_frequency = 'weekly'
self.team.pay_start_date = datetime.date(2026, 1, 5) # A Monday
self.team.save()
ctx = _build_work_log_payroll_context(self.log)
start, end = ctx['pay_period']
self.assertIsNotNone(start)
self.assertIsNotNone(end)
self.assertLessEqual(start, self.log.date)
self.assertGreaterEqual(end, self.log.date)
```
**Step 2.2: Run tests — they MUST fail**
```bash
python manage.py test core.tests.WorkLogPayrollContextTests -v 2
```
Expected: multiple failures/errors like `KeyError: 'worker_rows'` (the stub returns `{'log': log}` only). If tests pass here, you didn't replace the stub — go back.
**Step 2.3: Implement the helper**
In `core/views.py`, replace the stub `_build_work_log_payroll_context` with:
```python
def _build_work_log_payroll_context(log):
"""Return a context dict describing the payroll status of a work log.
Plain-English summary for future-you:
For the given work log, loop over each worker on it and decide which of
three buckets they fall into:
- "Paid" -> a PayrollRecord links this worker + this log
- "Priced, not paid" -> worker is in log.priced_workers but no record yet
- "Unpaid" -> neither
Also collects any PayrollAdjustments tied to this log (e.g. overtime).
Used by the AJAX endpoint AND the full detail page — keep them sharing
this helper so they can never show different data.
"""
# Prefetch payroll records once, rather than re-querying per worker.
payroll_records = list(
PayrollRecord.objects.filter(work_logs=log).select_related('worker')
)
# Lookup: worker_id -> first PayrollRecord found.
record_by_worker = {r.worker_id: r for r in payroll_records}
priced_worker_ids = set(log.priced_workers.values_list('id', flat=True))
worker_rows = []
total_earned = Decimal('0.00')
total_paid = Decimal('0.00')
total_outstanding = Decimal('0.00')
for worker in log.workers.all():
record = record_by_worker.get(worker.id)
if record:
status = 'Paid'
earned = worker.daily_rate
total_paid += earned
elif worker.id in priced_worker_ids:
status = 'Priced, not paid'
earned = worker.daily_rate
total_outstanding += earned
else:
status = 'Unpaid'
earned = worker.daily_rate
total_outstanding += earned
total_earned += earned
worker_rows.append({
'worker': worker,
'status': status,
'earned': earned,
'payroll_record': record,
'paid_date': record.date if record else None,
})
# Adjustments tied directly to this log (mostly overtime pricing).
adjustments = list(
log.payrolladjustment_set
.select_related('worker', 'payroll_record')
.order_by('type', 'id')
)
# Pay-period info (only if the team has a schedule configured).
pay_period = get_pay_period(log.team) if log.team else (None, None)
# Overtime "needs pricing" flag: log has OT hours but no priced_workers yet.
log_overtime = getattr(log, 'overtime', None) or 0
overtime_needs_pricing = log_overtime > 0 and not priced_worker_ids
return {
'log': log,
'worker_rows': worker_rows,
'adjustments': adjustments,
'total_earned': total_earned,
'total_paid': total_paid,
'total_outstanding': total_outstanding,
'pay_period': pay_period,
'overtime_needs_pricing': overtime_needs_pricing,
}
```
**Step 2.4: Run tests — all pass**
```bash
python manage.py test core.tests.WorkLogPayrollContextTests -v 2
```
Expected: `OK` with 8 tests passing.
**Step 2.5: Commit**
```bash
git add core/views.py core/tests.py
git commit -m "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.
"
```
### 🛑 CHECKPOINT 1
Paste the test output into chat. Konrad approves (or tweaks), then Task 3.
---
## Task 3: AJAX endpoint returns JSON (safe for DOM-building client side)
**Files:**
- Modify: `core/views.py` (replace Task-1 stub `work_log_payroll_ajax`)
- Modify: `core/tests.py` (add view-level tests)
**Step 3.1: Implement the AJAX view**
Replace the stub with:
```python
@login_required
def work_log_payroll_ajax(request, log_id):
"""Return JSON describing the payroll status of a work log.
Admin-only. The modal's JS builds its DOM from this JSON using
textContent/createElement (matches the worker_lookup_ajax pattern).
"""
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=log_id,
)
ctx = _build_work_log_payroll_context(log)
# --- Serialize the context for the JS modal ---
# Keep Decimals as floats (JS can't do Decimal) and dates as ISO strings.
def _date_iso(d):
return d.strftime('%Y-%m-%d') if d else None
worker_rows = [{
'worker_id': row['worker'].id,
'worker_name': row['worker'].name,
'worker_active': row['worker'].active,
'status': row['status'],
'earned': float(row['earned']),
'payroll_record_id': row['payroll_record'].pk if row['payroll_record'] else None,
'paid_date': _date_iso(row['paid_date']),
} for row in ctx['worker_rows']]
adjustments = [{
'type': adj.type,
'amount': float(adj.amount),
'worker_id': adj.worker.id,
'worker_name': adj.worker.name,
'payroll_record_id': adj.payroll_record.pk if adj.payroll_record else None,
} for adj in ctx['adjustments']]
return JsonResponse({
'log_id': log.id,
'date': _date_iso(log.date),
'project': {'id': log.project.id, 'name': log.project.name} if log.project else None,
'team': {'id': log.team.id, 'name': log.team.name} if log.team else None,
'supervisor': (log.supervisor.get_full_name() or log.supervisor.username) if log.supervisor else None,
'worker_rows': worker_rows,
'adjustments': adjustments,
'total_earned': float(ctx['total_earned']),
'total_paid': float(ctx['total_paid']),
'total_outstanding': float(ctx['total_outstanding']),
'pay_period_start': _date_iso(ctx['pay_period'][0]),
'pay_period_end': _date_iso(ctx['pay_period'][1]),
'overtime_needs_pricing': ctx['overtime_needs_pricing'],
'full_page_url': reverse('work_log_payroll_detail', args=[log.id]),
})
```
(Make sure `reverse` is imported from `django.urls` near the top of views.py — grep first; it almost certainly already is.)
**Step 3.2: Add view-level tests**
Append to `core/tests.py`:
```python
class WorkLogPayrollAjaxTests(TestCase):
"""Tests for the JSON AJAX endpoint that powers the modal."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P')
team = Team.objects.create(name='T', supervisor=self.admin)
worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_sees_200_json(self):
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['log_id'], self.log.id)
self.assertEqual(len(data['worker_rows']), 1)
self.assertEqual(data['worker_rows'][0]['status'], 'Unpaid')
def test_supervisor_forbidden(self):
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
def test_anonymous_redirected_to_login(self):
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 302)
def test_missing_log_is_404(self):
self.client.login(username='admin', password='pass')
resp = self.client.get('/history/99999/payroll/ajax/')
self.assertEqual(resp.status_code, 404)
```
**Step 3.3: Run tests**
```bash
python manage.py test core.tests -v 2
```
Expected: 12 tests pass (8 helper + 4 ajax).
**Step 3.4: Smoke test**
In a browser logged in as admin, visit (replace `<id>` with a real log id):
```
http://localhost:8000/history/<id>/payroll/ajax/
```
Expected: valid JSON with fields `log_id`, `worker_rows`, `adjustments`, totals, etc.
**Step 3.5: Commit**
```bash
git add core/views.py core/tests.py
git commit -m "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.
"
```
---
## Task 4: Full-page detail view at `/history/<log_id>/`
**Files:**
- Create: `core/templates/core/work_log_payroll.html`
- Modify: `core/views.py` (replace Task-1 stub `work_log_payroll_detail`)
- Modify: `core/tests.py` (add detail-page tests)
**Step 4.1: Create the template**
New file `core/templates/core/work_log_payroll.html`:
```django
{# === WORK LOG PAYROLL — FULL PAGE === #}
{# Shareable, bookmark-able view for one work log's payroll status. #}
{# Same data source as the modal; different presentation. #}
{% extends "base.html" %}
{% load format_tags %}
{% block title %}Work Log {{ log.date|date:"d M Y" }} | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4" style="max-width: 960px;">
{# --- Breadcrumb --- #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small mb-0">
<li class="breadcrumb-item"><a href="{% url 'work_history' %}" class="text-decoration-none">History</a></li>
<li class="breadcrumb-item active">
{{ log.date|date:"d M Y" }}
{% if log.project %} · <a href="{% url 'project_detail' log.project.id %}" class="text-decoration-none">{{ log.project.name }}</a>{% endif %}
{% if log.team %} · <a href="{% url 'team_detail' log.team.id %}" class="text-decoration-none">{{ log.team.name }}</a>{% endif %}
</li>
</ol>
</nav>
{# --- Page header --- #}
<div class="d-flex align-items-start justify-content-between mb-4">
<div>
<h3 class="mb-1"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h3>
<p class="text-muted mb-0 small">Who was paid for this day's work and who is still outstanding.</p>
</div>
<a href="{% url 'work_history' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to history
</a>
</div>
{# --- Attendance card --- #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Attendance</h6>
<div class="row g-3 small">
<div class="col-md-6">
<div><span class="text-muted">Workers present:</span> <strong>{{ worker_rows|length }}</strong></div>
<div><span class="text-muted">Overtime hours:</span> <strong>{{ log.overtime|default:0 }}</strong></div>
</div>
<div class="col-md-6">
<div><span class="text-muted">Supervisor:</span> <strong>
{% if log.supervisor %}{{ log.supervisor.get_full_name|default:log.supervisor.username }}{% else %}{% endif %}
</strong></div>
{% if pay_period.0 %}
<div><span class="text-muted">Pay period:</span> <strong>{{ pay_period.0|date:"d M" }} {{ pay_period.1|date:"d M Y" }}</strong></div>
{% else %}
<div><span class="text-muted">Pay period:</span>
<span class="text-muted fst-italic">no schedule</span>
{% if log.team %}<a href="{% url 'team_edit' log.team.id %}" class="small ms-1">configure</a>{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# --- Unpriced OT banner --- #}
{% if overtime_needs_pricing %}
<div class="alert alert-warning py-2 px-3 mb-3 small">
<i class="fas fa-triangle-exclamation me-1"></i>
Overtime on this log hasn't been priced yet.
<a href="{% url 'payroll_dashboard' %}" class="alert-link">Price now</a>.
</div>
{% endif %}
{# --- Workers table --- #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Workers on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th class="text-end">Earned</th>
<th>Payslip</th>
<th>Paid on</th>
</tr>
</thead>
<tbody>
{% for row in worker_rows %}
<tr>
<td>
<a href="{% url 'worker_detail' row.worker.id %}"
class="text-decoration-none {% if not row.worker.active %}text-decoration-line-through{% endif %}">
{{ row.worker.name }}
</a>
{% if not row.worker.active %}<span class="badge bg-secondary ms-1">Inactive</span>{% endif %}
</td>
<td>
{% if row.status == 'Paid' %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Paid</span>
{% elif row.status == 'Priced, not paid' %}
<span class="badge bg-info text-dark">Priced, not paid</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="fas fa-clock me-1"></i>Unpaid</span>
{% endif %}
</td>
<td class="text-end">R {{ row.earned|money }}</td>
<td>
{% if row.payroll_record %}
<a href="{% url 'payslip_detail' row.payroll_record.pk %}" class="text-decoration-none">#{{ row.payroll_record.pk }}</a>
{% else %}{% endif %}
</td>
<td>{{ row.paid_date|date:"d M Y"|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# --- Adjustments card (only when present) --- #}
{% if adjustments %}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Adjustments on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Type</th><th>Worker</th><th class="text-end">Amount</th><th>Payslip</th></tr></thead>
<tbody>
{% for adj in adjustments %}
<tr>
<td>{{ adj.type }}</td>
<td><a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">{{ adj.worker.name }}</a></td>
<td class="text-end">R {{ adj.amount|money }}</td>
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.pk %}" class="text-decoration-none">#{{ adj.payroll_record.pk }}</a>
{% else %}<span class="text-muted">unpaid</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# --- Totals footer --- #}
<div class="d-flex gap-4 pt-2 small">
<div><span class="text-muted">Total earned:</span> <strong>R {{ total_earned|money }}</strong></div>
<div><span class="text-muted">Paid:</span> <strong>R {{ total_paid|money }}</strong></div>
<div><span class="text-muted">Outstanding:</span> <strong>R {{ total_outstanding|money }}</strong></div>
</div>
</div>
{% endblock %}
```
**Step 4.2: Implement the detail view**
Replace the stub:
```python
@login_required
def work_log_payroll_detail(request, log_id):
"""Full-page payroll-status view for a single work log. Admin-only."""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=log_id,
)
context = _build_work_log_payroll_context(log)
return render(request, 'core/work_log_payroll.html', context)
```
**Step 4.3: Add detail-page tests**
Append to `core/tests.py`:
```python
class WorkLogPayrollDetailTests(TestCase):
"""Tests for the full-page /history/<id>/ view."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P2')
team = Team.objects.create(name='T2', supervisor=self.admin)
worker = Worker.objects.create(name='Wanda', id_number='X', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_gets_full_page(self):
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'FoxFitt')
self.assertContains(resp, 'History')
self.assertContains(resp, 'Wanda')
def test_supervisor_forbidden(self):
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
```
**Step 4.4: Run tests**
```bash
python manage.py test core.tests -v 2
```
Expected: 14 tests pass.
**Step 4.5: Visual smoke test**
Open `http://localhost:8000/history/<real-log-id>/` in the browser as admin. Expected:
- Full FoxFitt shell (topbar, dark theme)
- Breadcrumb: `History 10 Apr 2026 · {Project} · {Team}`
- Attendance card with supervisor + pay period
- Workers table with statuses
- Totals footer
**Step 4.6: Commit**
```bash
git add core/templates/core/work_log_payroll.html core/views.py core/tests.py
git commit -m "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.
"
```
### 🛑 CHECKPOINT 2
Screenshot the page for Konrad. Approve → Task 5.
---
## Task 5: Shared modal shell + JS in `base.html`
**Why in `base.html`:** one place to define the modal and click handler; every page opts in with `data-log-id` on a row.
**Files:**
- Modify: `core/templates/base.html`
**Step 5.1: Add the modal shell before `</body>`**
Open `core/templates/base.html` and find the closing `</body>` tag (near the bottom). Just before it, add:
```django
{# === WORK LOG PAYROLL MODAL (admin-only) === #}
{# Hidden by default. Any element with data-log-id anywhere in the app #}
{# triggers this modal. Fetches JSON and builds the DOM safely. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
<div class="modal fade" id="workLogPayrollModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="workLogPayrollBody">
<div class="text-center py-4 text-muted">
<div class="spinner-border" role="status"></div>
<p class="mt-2 small">Loading…</p>
</div>
</div>
<div class="modal-footer">
<a href="#" id="workLogPayrollFullLink" class="btn btn-sm btn-accent">
<i class="fas fa-external-link-alt me-1"></i>Open full page
</a>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
```
**Step 5.2: Add the JS click handler**
In the same `base.html`, find the existing global `<script>` block (contains theme toggle / tooltip init). Immediately after it, add a new `<script>` block:
```html
{# === WORK LOG PAYROLL MODAL — click handler + safe DOM builder === #}
{# Builds the modal body from JSON via createElement + textContent. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
<script>
(function() {
var modalEl = document.getElementById('workLogPayrollModal');
if (!modalEl) return;
var bodyEl = document.getElementById('workLogPayrollBody');
var fullLinkEl = document.getElementById('workLogPayrollFullLink');
var bsModal = new bootstrap.Modal(modalEl);
// --- Safe element creator (copied from the worker lookup pattern) ---
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function link(href, text, className) {
var a = document.createElement('a');
a.setAttribute('href', href);
a.className = className || 'text-decoration-none';
a.textContent = text;
return a;
}
function formatRand(amount) {
return 'R ' + Number(amount).toLocaleString('en-ZA', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
function statusBadge(status) {
var span = document.createElement('span');
if (status === 'Paid') {
span.className = 'badge bg-success';
span.textContent = 'Paid';
} else if (status === 'Priced, not paid') {
span.className = 'badge bg-info text-dark';
span.textContent = 'Priced, not paid';
} else {
span.className = 'badge bg-warning text-dark';
span.textContent = 'Unpaid';
}
return span;
}
// --- Reset body to a spinner ---
function showSpinner() {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
var wrap = el('div', 'text-center py-4 text-muted');
var spin = el('div', 'spinner-border');
spin.setAttribute('role', 'status');
wrap.appendChild(spin);
wrap.appendChild(el('p', 'mt-2 small', 'Loading…'));
bodyEl.appendChild(wrap);
}
// --- Replace body content with a built DOM fragment ---
function render(data) {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
// Header strip (date + project/team/supervisor)
var header = el('div', 'mb-3');
var dateLine = el('div', 'fs-5 fw-semibold', data.date || '');
header.appendChild(dateLine);
var subLine = el('div', 'text-muted small');
if (data.project) subLine.appendChild(link('/projects/' + data.project.id + '/', data.project.name));
else subLine.appendChild(document.createTextNode('—'));
subLine.appendChild(document.createTextNode(' · '));
if (data.team) subLine.appendChild(link('/teams/' + data.team.id + '/', data.team.name));
else subLine.appendChild(document.createTextNode('—'));
subLine.appendChild(document.createTextNode(' · ' + data.worker_rows.length + ' worker' +
(data.worker_rows.length === 1 ? '' : 's')));
if (data.supervisor) subLine.appendChild(document.createTextNode(' · Logged by ' + data.supervisor));
header.appendChild(subLine);
bodyEl.appendChild(header);
// Unpriced OT banner (if needed)
if (data.overtime_needs_pricing) {
var banner = el('div', 'alert alert-warning py-2 px-3 mb-3 small');
banner.appendChild(document.createTextNode('Overtime on this log hasn\u2019t been priced yet. '));
banner.appendChild(link('/payroll/', 'Price now', 'alert-link'));
banner.appendChild(document.createTextNode('.'));
bodyEl.appendChild(banner);
}
// Workers table
var wrap = el('div', 'table-responsive mb-3');
var table = el('table', 'table table-sm align-middle mb-0');
var thead = document.createElement('thead');
var headRow = document.createElement('tr');
['Worker', 'Status', 'Earned', 'Payslip', 'Paid on'].forEach(function(h, i) {
var th = el('th', i === 2 ? 'text-end' : null, h);
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = document.createElement('tbody');
data.worker_rows.forEach(function(row) {
var tr = document.createElement('tr');
// Worker cell with link + optional Inactive badge
var tdW = document.createElement('td');
var wLink = link('/workers/' + row.worker_id + '/', row.worker_name);
if (!row.worker_active) wLink.className += ' text-decoration-line-through';
tdW.appendChild(wLink);
if (!row.worker_active) {
var badge = el('span', 'badge bg-secondary ms-1', 'Inactive');
tdW.appendChild(badge);
}
tr.appendChild(tdW);
// Status
var tdS = document.createElement('td');
tdS.appendChild(statusBadge(row.status));
tr.appendChild(tdS);
// Earned
tr.appendChild(el('td', 'text-end', formatRand(row.earned)));
// Payslip link or em-dash
var tdP = document.createElement('td');
if (row.payroll_record_id) {
tdP.appendChild(link('/payroll/payslip/' + row.payroll_record_id + '/', '#' + row.payroll_record_id));
} else {
tdP.textContent = '\u2014';
}
tr.appendChild(tdP);
// Paid on
tr.appendChild(el('td', null, row.paid_date || '\u2014'));
tbody.appendChild(tr);
});
table.appendChild(tbody);
wrap.appendChild(table);
bodyEl.appendChild(wrap);
// Adjustments (optional)
if (data.adjustments && data.adjustments.length) {
var adjWrap = el('div', 'mb-3');
adjWrap.appendChild(el('h6', 'fw-semibold small text-uppercase text-muted mb-2', 'Adjustments on this log'));
var adjTable = el('table', 'table table-sm align-middle mb-0');
var adjHead = document.createElement('thead');
var adjHeadRow = document.createElement('tr');
['Type', 'Worker', 'Amount', 'Payslip'].forEach(function(h, i) {
adjHeadRow.appendChild(el('th', i === 2 ? 'text-end' : null, h));
});
adjHead.appendChild(adjHeadRow);
adjTable.appendChild(adjHead);
var adjBody = document.createElement('tbody');
data.adjustments.forEach(function(adj) {
var tr = document.createElement('tr');
tr.appendChild(el('td', null, adj.type));
var wTd = document.createElement('td');
wTd.appendChild(link('/workers/' + adj.worker_id + '/', adj.worker_name));
tr.appendChild(wTd);
tr.appendChild(el('td', 'text-end', formatRand(adj.amount)));
var pTd = document.createElement('td');
if (adj.payroll_record_id) {
pTd.appendChild(link('/payroll/payslip/' + adj.payroll_record_id + '/', '#' + adj.payroll_record_id));
} else {
pTd.appendChild(el('span', 'text-muted', 'unpaid'));
}
tr.appendChild(pTd);
adjBody.appendChild(tr);
});
adjTable.appendChild(adjBody);
adjWrap.appendChild(adjTable);
bodyEl.appendChild(adjWrap);
}
// Totals footer
var totals = el('div', 'd-flex gap-4 pt-2 border-top small');
function totalPair(label, value) {
var wrap = document.createElement('div');
wrap.appendChild(el('span', 'text-muted', label + ' '));
wrap.appendChild(el('strong', null, formatRand(value)));
return wrap;
}
totals.appendChild(totalPair('Total earned:', data.total_earned));
totals.appendChild(totalPair('Paid:', data.total_paid));
totals.appendChild(totalPair('Outstanding:', data.total_outstanding));
bodyEl.appendChild(totals);
// Footer "Open full page" link target
fullLinkEl.setAttribute('href', data.full_page_url);
}
function renderError() {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
bodyEl.appendChild(el('div', 'alert alert-danger', 'Failed to load payroll info for this log.'));
}
// --- Open the modal and fetch data ---
function openForLog(logId) {
showSpinner();
fullLinkEl.setAttribute('href', '/history/' + logId + '/');
bsModal.show();
fetch('/history/' + logId + '/payroll/ajax/')
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(render)
.catch(renderError);
}
// --- Delegated click listener: any [data-log-id] triggers the modal ---
document.addEventListener('click', function(ev) {
var target = ev.target.closest('[data-log-id]');
if (!target) return;
// Let real links/buttons inside the row do their own thing.
if (ev.target.closest('a, button')) return;
ev.preventDefault();
openForLog(target.getAttribute('data-log-id'));
});
})();
</script>
{% endif %}
```
**Step 5.3: Smoke test — markup present**
Reload any page as admin. DevTools → Elements → search for `workLogPayrollModal`. Should be present at bottom of `<body>`. As supervisor: absent.
**Step 5.4: Smoke test — JS works on fake trigger**
In DevTools Console (as admin):
```js
var t = document.createElement('div');
t.setAttribute('data-log-id', '1'); // use a real log id
document.body.appendChild(t);
t.click();
t.remove();
```
Modal should open, fetch, and render. Worker name link should point to `/workers/<id>/`, payslip link to `/payroll/payslip/<id>/`, "Open full page" to `/history/<id>/`.
**Step 5.5: Commit**
```bash
git add core/templates/base.html
git commit -m "Shared work log payroll modal + safe DOM builder in base.html
Modal shell + JS click handler live in base.html so any page opts in
by adding data-log-id to a row. JS uses createElement + textContent
(matches worker_lookup_ajax pattern) to build the modal body from
JSON — no innerHTML. Supervisors never receive the markup.
Footer 'Open full page' links to /history/<id>/.
"
```
---
## Task 6: Wire up row clicks in Work History
**Files:**
- Modify: `core/templates/core/work_history.html` (around line 442)
**Step 6.1: Make admin rows clickable**
Find `{% for log in logs %}` at line 441 and change the following `<tr>` line:
```django
<tr>
```
to:
```django
<tr {% if is_admin %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
```
**Step 6.2: Verify in browser**
As admin: rows cursor-pointer, click opens modal, worker badges (inside the row) do NOT open the modal (JS skips `<a>` clicks).
As supervisor: rows unchanged.
**Step 6.3: Commit**
```bash
git add core/templates/core/work_history.html
git commit -m "Make Work History rows clickable for admins -> payroll modal
Admin users get cursor:pointer + data-log-id on each row. Click
opens the shared modal from base.html. Supervisors unchanged.
"
```
### 🛑 CHECKPOINT 3
Demo end-to-end for Konrad. Approve → Tasks 7 + 8.
---
## Task 7: Wire up Team Detail Recent Work Logs
**Files:**
- Modify: `core/templates/core/teams/detail.html` (around line 157)
**Step 7.1: Edit the row**
Change the `<tr>` inside the Recent Work Logs card to:
```django
{% for log in recent_logs %}
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.project.name }}</td>
<td class="text-end">{{ log.workers.count }}</td>
</tr>
{% endfor %}
```
**Step 7.2: Test** — open a team detail as admin, click a row, modal should open.
**Step 7.3: Commit**
```bash
git add core/templates/core/teams/detail.html
git commit -m "Team detail: Recent Work Logs rows open payroll modal (admin)"
```
---
## Task 8: Wire up Project Detail Recent Work Logs
**Files:**
- Modify: `core/templates/core/projects/detail.html` (around line 166)
**Step 8.1: Edit the row** — same shape as Task 7:
```django
{% for log in recent_logs %}
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.team.name|default:'—' }}</td>
<td class="text-end">{{ log.workers.count }}</td>
</tr>
{% endfor %}
```
**Step 8.2: Test** — as admin, open a project detail, click a row.
**Step 8.3: Commit**
```bash
git add core/templates/core/projects/detail.html
git commit -m "Project detail: Recent Work Logs rows open payroll modal (admin)"
```
---
## Task 9: Add the hover-highlight CSS rule
**Why:** make the clickable rows visually discoverable without inline `<style>` blocks duplicated across three templates.
**Files:**
- Modify: `static/css/custom.css`
**Step 9.1: Add rule at the end of `static/css/custom.css`**
```css
/* === Work log payroll: clickable row hover === */
/* Applied only by base.html / templates that add class="work-log-row" */
/* (admin-only; supervisors never get the class so hover doesn't apply). */
.work-log-row {
transition: background-color 120ms ease-in-out;
}
.work-log-row:hover td {
background: var(--bg-card-hover);
}
```
**Step 9.2: Visual check**
Reload `/history/`. Hover a row → subtle background change. If you don't see it, hard-refresh (`Ctrl+Shift+R`) to beat browser cache.
Since the `deployment_timestamp` cache-bust token changes every request (per CLAUDE.md Cache-Busting section), this should just work on the next page load.
**Step 9.3: Commit**
```bash
git add static/css/custom.css
git commit -m "Add .work-log-row hover rule to custom.css
Subtle background tint on hover to cue that the row is clickable.
Deployment timestamp handles CDN cache-busting. Supervisors never
get the class, so hover never applies for them.
"
```
---
## Task 10: QA pass + final sign-off
**No code changes expected unless QA reveals a bug.** If a bug surfaces, fix it in a new task (10a, 10b, …).
**Step 10.1: Full test suite**
```bash
python manage.py test core.tests -v 2
```
Expected: 14 tests pass.
**Step 10.2: Django system check + migrations dry-run**
```bash
python manage.py check
python manage.py makemigrations --dry-run
```
Expected: no issues, no pending migrations.
**Step 10.3: Permission matrix**
| User | `/history/` rows clickable? | `/history/<id>/` direct URL? | `/history/<id>/payroll/ajax/` direct? |
|---|---|---|---|
| Admin (`is_staff=True`) | Yes | 200 | 200 JSON |
| Superuser | Yes | 200 | 200 JSON |
| Work Logger supervisor | No | 403 | 403 JSON |
| Anonymous | — (login first) | 302 → login | 302 → login |
**Step 10.4: Three statuses**
Find or create in `/admin/core/worklog/`:
- A log with one worker paid, one unpaid → open the modal, verify mixed statuses.
- A log with a `priced_workers` entry but no `PayrollRecord` → verify "Priced, not paid" badge.
**Step 10.5: Overtime banner**
A log with `overtime > 0` and empty `priced_workers` → modal shows the amber banner with "Price now" link to `/payroll/`.
**Step 10.6: Inactive worker**
In `/admin/core/worker/`, uncheck Active on a worker who's on a log. Open that log's modal → worker shown with strikethrough and "Inactive" pill. (Re-activate when done.)
**Step 10.7: Pay-period context on full page**
Open `/history/<id>/` for:
- A log whose team has `pay_frequency='weekly'` and `pay_start_date` set → Attendance card shows period range.
- A log whose team has no schedule → Attendance card shows "no schedule · configure".
**Step 10.8: Entry-point parity**
Confirm modal opens from all three:
- `/history/` row
- `/teams/<id>/` Recent Work Logs row
- `/projects/<id>/` Recent Work Logs row
Each should pre-fill the "Open full page" button with the correct log id.
**Step 10.9: Mark design doc as shipped**
Append to `docs/plans/2026-04-22-work-log-payroll-crosslink-design.md`:
```markdown
---
## Shipped
**Date:** 22 Apr 2026
**Commits:** 1c00ba2 (design) through [last commit hash]
**Plan:** `docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md`
**QA notes:** all 14 tests pass; permission matrix verified; three-state statuses confirmed on live data.
```
Then:
```bash
git add docs/plans/2026-04-22-work-log-payroll-crosslink-design.md
git commit -m "docs: mark work log payroll cross-link as shipped"
```
### 🛑 CHECKPOINT 4
Demo all three entry points to Konrad. Walk through:
1. `/history/` → click row → modal → worker name → worker detail page
2. Back → click a payslip → payslip detail page
3. Back → modal → "Open full page" → full page with breadcrumb
4. Team detail → Recent Work Logs → row → modal
5. Project detail → Recent Work Logs → row → modal
6. Log out, log in as supervisor → rows NOT clickable, `/history/<id>/` returns 403
Approve → then decide push strategy (sync with Flatlogic first, then `git push github ai-dev` + ask Gemini to pull).
---
## Deferred (NOT in this implementation)
Raised in brainstorming but deferred; make separate plans if users ask for them after living with the feature for a week:
- **"Pay these workers now" action inside the modal** — inline button that jumps to the existing Pay flow.
- **"Add adjustment for this log" shortcut** — opens Add Adjustment modal pre-filled with project/date/worker filter.
- **Supervisor-visible attendance-only variant** — a simpler modal supervisors CAN click through (no payroll data).
- **N+1 check on work_history view** — already prefetches `workers` and `payroll_records`; re-profile only if the history table feels slow after the feature ships.
---
Plan complete and saved to `docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md`. Two execution options:
**1. Subagent-Driven (this session)** — I dispatch a fresh subagent per task, review between tasks, fast iteration, stay in this session.
**2. Parallel Session (separate)** — Open a new Claude Code session with `superpowers:executing-plans`, batch execution with the four checkpoints above.
**Which approach?**