docs: TDD plan for Managers pay-type filter (4 tasks, HARD STOP)

4 bite-sized TDD tasks: (1) worker_list ?pay_type= view+tests,
(2) /workers/ dropdown, (3) Add-Adjustment modal data-pay-type +
client-side toggle, (4) docs. ~205/205 expected. Nothing pushed until
Konrad's local verification — rides with paused Manager/Salaried.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-16 13:17:39 +02:00
parent 4aac2c1cf2
commit 45871225e1

View File

@ -0,0 +1,482 @@
# Managers Pay-Type Filter — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task (in-session, fresh subagent + 2-stage review per task). Each subagent MUST use superpowers:test-driven-development.
**Goal:** Add a display-only `pay_type` filter so `Worker(pay_type='fixed')` managers are fast to find on `/workers/` and in the Add-Adjustment modal picker — no model/migration/URL/money-math changes.
**Architecture:** One new `?pay_type=` GET branch in `worker_list` mirroring the existing `?status=`/`?team=` pattern; one extra `<select>` in `workers/list.html`; a `data-pay-type` attribute + a small client-side show/hide `<select>` + JS handler in the Add-Adjustment modal (reusing the existing `.add-adj-worker` / team-quick-select pattern). Server-side worker querysets are NEVER narrowed for the modal (preserves the must-stay-payable invariant).
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite local (`USE_SQLITE=true`), Bootstrap 5 templates, vanilla JS.
**Design doc:** `docs/plans/2026-05-16-managers-paytype-filter-design.md` (commit `4aac2c1`).
**Branch / baseline:** `ai-dev`, HEAD `4aac2c1`, **201/201 tests passing**. Builds on top of the **paused, un-pushed Manager/Salaried commits**.
**Test command (Git Bash, per CLAUDE.md):**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
```
Single class: append `.ClassName``core.tests.WorkerListPayTypeFilterTests`.
> ⛔ **HARD STOP after Task 4.** Do NOT `git push`, do NOT deploy. After all
> 4 tasks pass locally, STOP and hand back to Konrad to run the manual
> verification checklist in the design doc. Pushing happens only on
> Konrad's explicit say-so, bundled with the rest of the paused
> Manager/Salaried feature.
**Path-A reminder:** the DB value is `'fixed'` (used in querysets, the
`data-pay-type` attribute, and `<option value>`); the user-facing label
is "Managers (Salaried)". Never invert these.
---
### Task 1: `/workers/` `?pay_type=` filter — view + tests
**Files:**
- Modify: `core/views.py``worker_list` (currently lines 16061664)
- Test: `core/tests.py` — new class `WorkerListPayTypeFilterTests`, inserted immediately before `class WorkHistoryTeamFilterTests` (currently line 3242)
**Step 1: Write the failing tests**
Insert this new class right before line 3242 (`class WorkHistoryTeamFilterTests(TestCase):`). It mirrors the existing `WorkerListTeamFilterTests` setup style (line 3183).
```python
class WorkerListPayTypeFilterTests(TestCase):
"""The /workers/ page accepts ?pay_type=fixed (managers only) and
?pay_type=daily (daily workers only). No param = unchanged 'all
pay types' behaviour. Display-only filter — no money math touched."""
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(
username='ptadmin', password='pw', is_staff=True, is_superuser=True,
)
cls.daily = Worker.objects.create(
name='Danny Daily', id_number='PT-D1',
monthly_salary=Decimal('6000'), # pay_type defaults to 'daily'
)
cls.mgr = Worker.objects.create(
name='Mary Manager', id_number='PT-M1',
monthly_salary=Decimal('40000'), pay_type='fixed',
)
def setUp(self):
self.client.force_login(self.admin)
def test_pay_type_fixed_shows_only_managers(self):
resp = self.client.get('/workers/?pay_type=fixed')
names = [w.name for w in resp.context['workers']]
self.assertIn('Mary Manager', names)
self.assertNotIn('Danny Daily', names)
self.assertEqual(resp.context['pay_type_filter'], 'fixed')
def test_pay_type_daily_shows_only_daily(self):
resp = self.client.get('/workers/?pay_type=daily')
names = [w.name for w in resp.context['workers']]
self.assertIn('Danny Daily', names)
self.assertNotIn('Mary Manager', names)
def test_no_pay_type_param_shows_both(self):
# Regression: the default /workers/ behaviour must NOT change.
resp = self.client.get('/workers/')
names = [w.name for w in resp.context['workers']]
self.assertIn('Danny Daily', names)
self.assertIn('Mary Manager', names)
self.assertEqual(resp.context['pay_type_filter'], '')
```
**Step 2: Run the tests to verify they fail**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.WorkerListPayTypeFilterTests -v 2
```
Expected: FAIL — `KeyError: 'pay_type_filter'` (context key absent) and the `fixed`/`daily` filters not applied.
**Step 3: Implement the view change**
In `core/views.py::worker_list`:
(a) Read the param next to the existing `team_filter` line (currently line 1622):
```python
team_filter = (request.GET.get('team') or '').strip()
pay_type_filter = (request.GET.get('pay_type') or '').strip()
```
(b) Add the filter branch immediately AFTER the `# === Team filter ===`
block (right after the `elif team_filter.isdigit():` lines, currently
~line 1643, BEFORE the `# Annotate days worked` comment):
```python
# === Pay-type filter ===
# Display-only narrowing by Worker.pay_type. 'fixed' = managers /
# salaried staff; 'daily' = normal field workers. Any other value
# (including absent) leaves the list unfiltered — the default view
# is deliberately unchanged. DB value is 'fixed'/'daily' (Path-A;
# the user-facing label is "Managers (Salaried)").
if pay_type_filter in ('fixed', 'daily'):
workers = workers.filter(pay_type=pay_type_filter)
```
(c) Add the context key (in the `context = { ... }` dict, alongside
`'team_filter': team_filter,`):
```python
'pay_type_filter': pay_type_filter,
```
**Step 4: Run the tests to verify they pass**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.WorkerListPayTypeFilterTests -v 2
```
Expected: PASS (3 tests, OK).
**Step 5: Commit**
```bash
git add core/views.py core/tests.py
git commit -m "feat: ?pay_type= filter on /workers/ (managers/daily, display-only)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: `/workers/` list template — filter dropdown
**Files:**
- Modify: `core/templates/core/workers/list.html` — filter form (lines 3563) + "Clear filters" condition (line 130)
- Test: `core/tests.py` — add one method to `WorkerListPayTypeFilterTests`
**Step 1: Write the failing test**
Append this method to `WorkerListPayTypeFilterTests` (created in Task 1):
```python
def test_list_renders_pay_type_dropdown_with_selection(self):
# The filter row must render a pay-type <select> and mark the
# active option selected so the dropdown reflects the URL.
resp = self.client.get('/workers/?pay_type=fixed')
self.assertContains(resp, 'name="pay_type"')
self.assertContains(resp, 'Managers (Salaried)')
# The 'fixed' option must be the selected one.
self.assertContains(
resp, '<option value="fixed" selected>Managers (Salaried)</option>',
html=False,
)
```
**Step 2: Run to verify it fails**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.WorkerListPayTypeFilterTests.test_list_renders_pay_type_dropdown_with_selection -v 2
```
Expected: FAIL — `name="pay_type"` not found in response.
**Step 3: Implement the template change**
In `core/templates/core/workers/list.html`:
(a) The filter `<form>` row currently is `col-md-4` (search) + `col-md-3`
(team) + `col-md-3` (status) + `col-md-2` (button) = 12. Rebalance to
fit a 4th control. Change the column classes:
- Search wrapper `col-md-4``col-md-3`
- Team wrapper `col-md-3``col-md-3` (unchanged)
- Status wrapper `col-md-3``col-md-2`
- (new) Pay-type wrapper `col-md-2`
- Button wrapper `col-md-2``col-md-2` (unchanged)
(3+3+2+2+2 = 12)
So edit the Status wrapper from:
```html
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
</select>
</div>
```
to (note `col-md-3``col-md-2`, then a NEW pay-type block right after):
```html
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
</select>
</div>
{# === Pay-type filter === #}
{# DB values are 'daily'/'fixed' (Path-A); label is friendly. #}
{# Empty value = 'All pay types' (unchanged default view). #}
<div class="col-md-2">
<label class="form-label small fw-semibold mb-1">Pay type</label>
<select name="pay_type" class="form-select">
<option value="" {% if not pay_type_filter %}selected{% endif %}>All pay types</option>
<option value="daily" {% if pay_type_filter == 'daily' %}selected{% endif %}>Daily workers</option>
<option value="fixed" {% if pay_type_filter == 'fixed' %}selected{% endif %}>Managers (Salaried)</option>
</select>
</div>
```
Also change the Search wrapper `col-md-4``col-md-3` (one-word edit on
line 36).
(b) Update the "Clear filters" visibility condition (currently line 130)
from:
```html
{% if q or status != 'active' or team_filter %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
```
to (add `or pay_type_filter`):
```html
{% if q or status != 'active' or team_filter or pay_type_filter %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
```
**Single-line `{# #}` check (CLAUDE.md gotcha):** the two `{# ... #}`
comments added in (a) are each fully self-contained on one line — verify
no multi-line `{#` was introduced:
```bash
grep -rn "^\s*{#" core/templates/core/workers/list.html | awk -F: '$0 !~ /#}/ {print}'
```
Expected: no output.
**Step 4: Run tests to verify pass**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.WorkerListPayTypeFilterTests -v 2
```
Expected: PASS (4 tests, OK).
**Step 5: Commit**
```bash
git add core/templates/core/workers/list.html core/tests.py
git commit -m "feat: pay-type dropdown on /workers/ filter row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Add-Adjustment modal — `data-pay-type` + "Managers only" toggle
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html` — worker rows (~line 1087), filter row (~line 10751084), JS (~after line 1999)
- Test: `core/tests.py` — add one method to `ManagerSalariedPayUITests` (class at line 3727)
**Step 1: Write the failing test**
Append this method to `ManagerSalariedPayUITests` (after
`test_worker_list_shows_manager_label_for_fixed`, i.e. after current
line 3759, before `def test_salary_immediate_payslip_has_no_zero_days_line`):
```python
def test_add_adjustment_modal_has_pay_type_scaffolding(self):
# The Add-Adjustment modal must (a) still include a manager in
# the picker (the must-stay-payable invariant), (b) tag that
# row with data-pay-type="fixed", and (c) render the client-side
# "pay type" filter <select>. The toggle's runtime hide/show is
# verified by Konrad's manual checklist (it's vanilla JS).
mgr = Worker.objects.create(
name='Modal Mgr', id_number='MM-1',
monthly_salary=Decimal('40000'), pay_type='fixed')
resp = self.client.get('/payroll/')
self.assertEqual(resp.status_code, 200)
# (a) invariant: manager present in the modal picker queryset
self.assertIn(mgr, resp.context['all_workers'])
# (b) the row carries the DB pay_type value as a data attribute
self.assertContains(resp, 'data-pay-type="fixed"')
# (c) the client-side filter control exists
self.assertContains(resp, 'id="addAdjPayTypeFilter"')
```
**Step 2: Run to verify it fails**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayUITests.test_add_adjustment_modal_has_pay_type_scaffolding -v 2
```
Expected: FAIL — `data-pay-type="fixed"` / `id="addAdjPayTypeFilter"` not in response.
**Step 3: Implement the modal changes**
In `core/templates/core/payroll_dashboard.html`:
(a) Worker row — add `data-pay-type` to the `.form-check` wrapper.
Currently (lines ~10861092):
```html
{% for w in all_workers %}
<div class="form-check">
<input class="form-check-input add-adj-worker" type="checkbox"
name="workers" value="{{ w.id }}" id="addW{{ w.id }}">
<label class="form-check-label" for="addW{{ w.id }}">{{ w.name }}</label>
</div>
{% endfor %}
```
Change the wrapper opening tag to:
```html
<div class="form-check" data-pay-type="{{ w.pay_type }}">
```
(only that one line changes; the `{{ w.pay_type }}` field is already on
the `Worker` model — no view change).
(b) Filter row — add the pay-type `<select>`. Currently (lines
~10751084):
```html
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<select id="addAdjTeamSelect" class="form-select form-select-sm" style="max-width: 250px;">
<option value="">Quick select by team...</option>
{% for team in all_teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none text-nowrap">Select All</a>
<a href="#" id="adjDeselectAll" class="small text-muted text-decoration-none text-nowrap">Clear</a>
</div>
```
Insert the new `<select>` immediately AFTER the `addAdjTeamSelect`
`</select>` and BEFORE the `Select All` link:
```html
</select>
<select id="addAdjPayTypeFilter" class="form-select form-select-sm" style="max-width: 170px;" aria-label="Filter picker by pay type">
<option value="">All pay types</option>
<option value="daily">Daily only</option>
<option value="fixed">Managers only</option>
</select>
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none text-nowrap">Select All</a>
```
(c) JS handler — add immediately AFTER the team quick-select handler
(after the closing `}` of `if (addAdjTeamSelect) { ... }`, currently
line 1999, before the `// === QUICK ADJUST BUTTON ===` comment at line
2001):
```javascript
// Pay-type filter: show/hide picker rows by data-pay-type.
// Display-only — selection state still lives on the checkboxes;
// hidden rows keep whatever checked state they had. "All" (empty
// value) reveals every row again. Same pattern as the pending-table
// team/loan client-side filters.
var addAdjPayTypeFilter = document.getElementById('addAdjPayTypeFilter');
if (addAdjPayTypeFilter) {
addAdjPayTypeFilter.addEventListener('change', function() {
var want = this.value; // '', 'daily', or 'fixed'
addAdjWorkerCheckboxes.forEach(function(cb) {
var row = cb.closest('.form-check');
if (!row) return;
var rowType = row.getAttribute('data-pay-type') || '';
row.style.display = (!want || rowType === want) ? '' : 'none';
});
});
}
```
**Single-line `{# #}` check** (no Django comments added here, but the
JS `//` comments are fine — run the repo-wide guard anyway to be safe):
```bash
grep -rn "^\s*{#" core/templates/core/payroll_dashboard.html | awk -F: '$0 !~ /#}/ {print}'
```
Expected: no output.
**Step 4: Run tests to verify pass**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayUITests -v 2
```
Expected: PASS (all methods in the class, OK).
**Step 5: Commit**
```bash
git add core/templates/core/payroll_dashboard.html core/tests.py
git commit -m "feat: 'Managers only' client-side filter on Add-Adjustment picker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Docs — parked-work + CLAUDE.md
**Files:**
- Modify: `docs/plans/parked-work.md` — the paused Manager/Salaried entry
- Modify: `CLAUDE.md` — one line under the "Manager / Salaried pay (May 2026)" section
**Step 1: Full regression run (no new test — docs only)**
```bash
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
```
Expected: **205/205 OK** (201 baseline + 4 new). If any FAIL, STOP and
fix before touching docs.
**Step 2: Update `docs/plans/parked-work.md`**
Find the paused Manager/Salaried Pay entry. Append a sentence noting the
pay-type filter rides with it, e.g.:
> Also includes the display-only `?pay_type=` filter on `/workers/` +
> the "Managers only" toggle on the Add-Adjustment modal (design
> `2026-05-16-managers-paytype-filter-design.md`, plan
> `2026-05-16-managers-paytype-filter-plan.md`). Same HARD STOP — all
> un-pushed until Konrad's local verification.
(If no Manager/Salaried paused entry exists yet, add a short one under
the "⏸ Paused — ready to execute" section following the existing
entry's format.)
**Step 3: Update `CLAUDE.md`**
In the "Manager / Salaried pay (May 2026)" section, add one line:
> Finding them: `/workers/?pay_type=fixed` (display-only filter,
> mirrors the status/team filters) and a "Managers only" client-side
> toggle on the Add-Adjustment modal picker. Both are display-only —
> the modal's `all_workers` queryset is NOT narrowed server-side
> (preserves the must-stay-payable invariant).
**Step 4: Verify docs render sanely**
```bash
grep -n "pay_type" docs/plans/parked-work.md CLAUDE.md
```
Expected: the new lines appear.
**Step 5: Commit**
```bash
git add docs/plans/parked-work.md CLAUDE.md
git commit -m "docs: note managers pay-type filter (rides with paused Manager/Salaried)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## ⛔ HARD STOP — hand back to Konrad
After Task 4's commit:
1. Confirm `git status` is clean and `git log --oneline -5` shows the 4
new commits on `ai-dev` on top of `4aac2c1`.
2. Run the full suite one last time — expect **205/205 OK**.
3. **Do NOT `git push`. Do NOT deploy.** Report the commit list + test
count to Konrad and point him at the **Verification (manual, local)**
section of `docs/plans/2026-05-16-managers-paytype-filter-design.md`.
4. Pushing/deploying happens only when Konrad explicitly approves, and
it goes out **bundled with the rest of the paused Manager/Salaried
feature** (single push, his call).
## Notes
- **DRY/YAGNI:** the filter reuses the existing status/team filter
pattern (view) and the existing `.add-adj-worker` + team-quick-select
pattern (modal). No new abstraction, no helper — two call sites only.
- **No migration:** `Worker.pay_type` already exists (migration
`0016_worker_pay_type`). This plan adds zero migrations; if
`makemigrations --check` ever flags something, STOP — something
unintended changed.
- **Why JS isn't unit-tested:** the show/hide is vanilla DOM mutation;
the test asserts the *scaffolding* (data attribute + select element +
server-side invariant). Runtime behaviour is on Konrad's manual
checklist (design doc § Verification steps 46).