From 0ec3f667392f9693b40d40459b06500f9c05d552 Mon Sep 17 00:00:00 2001
From: Konrad du Plessis
Date: Wed, 22 Apr 2026 13:34:21 +0200
Subject: [PATCH] Plan: work log -> payroll cross-link implementation plan
Task-by-task plan for implementing the modal + /history// 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)
---
...6-04-22-work-log-payroll-crosslink-plan.md | 1347 +++++++++++++++++
1 file changed, 1347 insertions(+)
create mode 100644 docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
diff --git a/docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md b/docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
new file mode 100644
index 0000000..39ff465
--- /dev/null
+++ b/docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
@@ -0,0 +1,1347 @@
+# 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//` 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//` 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//`.
+- 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//', views.work_log_payroll_detail, name='work_log_payroll_detail'),
+ path('history//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"
stub for log {log.id}
")
+```
+
+**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// and /history//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 `` with a real log id):
+
+```
+http://localhost:8000/history//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//`
+
+**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 %}
+
+{% 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// 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//` 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// 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 `
`. 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//`, payslip link to `/payroll/payslip//`, "Open full page" to `/history//`.
+
+**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//.
+"
+```
+
+---
+
+## 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 `