docs: add Manager / Salaried Pay implementation plan (8 TDD tasks)

Task-by-task TDD plan: Worker.pay_type + migration, Salary additive
type, attendance/absence picker exclusions, add_adjustment Salary
branch, per-project salaried-cost report line + byte-for-byte
daily-numbers regression guard, UI, docs. Ends with a HARD STOP
before any push for Konrad's local verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-15 19:03:26 +02:00
parent 325c59d4a1
commit 4dadb7cf23

View File

@ -0,0 +1,801 @@
# Manager / Salaried Pay Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or
> superpowers:subagent-driven-development if running in-session) to implement
> this plan task-by-task.
**Goal:** Let Konrad log fixed monthly pay for managers/salaried staff (people
with no daily work logs, e.g. Fitz) so it lands in payroll records + project
costs and can carry loans/advances — without changing anything about how
daily-rated workers are logged, paid, or costed.
**Architecture:** Approach A — a `Worker.pay_type` discriminator
(`'daily'` default | `'fixed'`). Managers are excluded *only* from the
attendance/absence pickers so they can never reach a `WorkLog` (which makes
all WorkLog-derived money math provably unchanged). Their pay is a new
additive `Salary` `PayrollAdjustment` type, project-attributed, logged through
the existing `add_adjustment` rails (a new branch modelled on the proven
`New Loan` branch). Loans/advances/deductions/payslips/history work for them
with zero new code because those are all `Worker`-FK'd.
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local dev) / MySQL (prod),
Bootstrap 5, Django test framework.
---
## Context the implementing engineer MUST read first
- **Design doc:** `docs/plans/2026-05-15-manager-salaried-pay-design.md`
(committed locally as `325c59d` on `ai-dev`, NOT pushed). Read it in full.
- **Project guide:** `CLAUDE.md` at repo root. Especially the
"UI-vs-DB naming drift" section (Path-A pattern) and the multi-line
Django comment gotcha.
- **Baseline:** branch `ai-dev`, **173/173 tests passing**. Do not regress.
- **Test command (Git Bash):**
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
- Run a single test class:
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
- On `cmd.exe` use `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests`
- **Test gotcha (already bit a prior session):** in `core/tests.py` use
`datetime.date.today()` / `datetime.date(...)`, NOT `timezone.now().date()`.
`tests.py` imports `datetime` (and a `_date` alias around line 1693); it does
NOT import `timezone` in many test classes. Match the surrounding style of
the absence test classes.
- **Template comment gotcha:** Django `{# ... #}` is SINGLE-LINE only. After
ANY template edit run:
`grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'`
— every match is a broken multi-line comment rendering as visible text.
Use `{% comment %}...{% endcomment %}` for multi-line.
- **Add new test classes at the END of `core/tests.py`** (after the last
existing class) unless told otherwise. Name them as specified per task so the
spec reviewer can find them.
## Exact code locations (verified during planning)
| What | Location |
|---|---|
| `Worker` model | `core/models.py:37` (note `daily_rate` property at `:92-95`) |
| `PayrollAdjustment.TYPE_CHOICES` | `core/models.py:289` |
| `ADDITIVE_TYPES` / `DEDUCTIVE_TYPES` | top of `core/views.py` — locate with `grep -n "^ADDITIVE_TYPES" core/views.py` |
| `AttendanceLogForm` worker queryset — supervisor branch | `core/forms.py:106-109` |
| `AttendanceLogForm` worker queryset — admin branch | `core/forms.py:114` |
| `PayrollAdjustmentForm.clean` `project_required_types` | `core/forms.py:179` (worker queryset `:170`**must KEEP managers**) |
| `WorkerForm` | `core/forms.py:248` (fields `:256-263`, widgets `:264-288`) |
| `AbsenceLogForm.workers` queryset — admin default | `core/forms.py:662` |
| `AbsenceLogForm.workers` queryset — supervisor branch | `core/forms.py:696-702` |
| `AbsenceEditForm.worker` queryset — admin / supervisor | `core/forms.py:842` / `:851` |
| `_build_team_workers_map` worker prefetch | `core/views.py:588` |
| attendance cost-estimate JS rates loop | `core/views.py:791` |
| `add_adjustment` | `core/views.py:4204` (`project_required_types` `:4247`, `New Loan` branch `:4280-4312`, generic fall-through `:4388-4398`) |
| `_process_single_payment` | `core/views.py:3696` (NOT modified — regression only) |
| `_build_report_context` | `core/views.py:2416` (adjustments filtered by project `:2484-2490`, labour-cost-by-project `~:2502`) |
## CRITICAL invariants (the spec reviewer will check these)
1. The `pay_type='fixed'` exclusion is applied **ONLY** to attendance + absence
pickers (Tasks 3 & 4). It must **NEVER** be added to `PayrollAdjustmentForm`
(`forms.py:170`), the Team edit form (`forms.py:427`), or any payroll modal
worker picker — managers MUST remain selectable there.
2. `_process_single_payment` (`views.py:3696`) logic is **not modified**. The
only money-path edits are: adding `'Salary'` to `ADDITIVE_TYPES`, and a new
`Salary` branch in `add_adjustment`.
3. The migration defaults `pay_type` to `'daily'` for every existing worker.
4. **Pay-Immediately Salary nuance (intentional):** the immediate path creates
an isolated `PayrollRecord` exactly like the existing `New Loan`
pay-immediately sub-branch — it does NOT call `_process_single_payment`.
The UNPAID path falls through to the generic pending-adjustment creation and
is netted by `_process_single_payment` later when the admin clicks Pay on
the dashboard. This satisfies the design intent ("pay now → PayrollRecord +
payslip"; "unchecked → pending, nets with deductions/loan-repayments at
pay time"). Do not "fix" this to route immediate through
`_process_single_payment`.
5. No DB rename — table/model stays `Worker`. UI label only ("Manager /
Salaried"). This is a Path-A display-only entry.
---
## Task 1: `Worker.pay_type` field + `is_salaried` property + migration
**Files:**
- Modify: `core/models.py:37` (Worker class; add field near the other simple
fields, add property next to `daily_rate` at `:92-95`)
- Create: `core/migrations/0016_worker_pay_type.py` (auto-generated; the latest
existing migration is `0015_absence_project`)
- Test: `core/tests.py` (new class `ManagerSalariedPayModelTests` at end of file)
**Step 1: Write the failing tests**
```python
class ManagerSalariedPayModelTests(TestCase):
"""Worker.pay_type discriminator + is_salaried convenience property."""
def test_pay_type_defaults_to_daily(self):
w = Worker.objects.create(
name='Daily Dan', id_number='PT-D1', monthly_salary=Decimal('6000.00'),
)
self.assertEqual(w.pay_type, 'daily')
self.assertFalse(w.is_salaried)
def test_pay_type_fixed_is_salaried(self):
m = Worker.objects.create(
name='Manager Fitz', id_number='PT-M1',
monthly_salary=Decimal('40000.00'), pay_type='fixed',
)
self.assertEqual(m.pay_type, 'fixed')
self.assertTrue(m.is_salaried)
```
**Step 2: Run tests to verify they fail**
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
Expected: FAIL — `Worker() got unexpected keyword 'pay_type'` / `AttributeError: is_salaried`.
**Step 3: Implement**
In `core/models.py`, inside `class Worker`, add (place the choices + field
with the other simple fields, e.g. just after `active = models.BooleanField(...)`):
```python
# === PAY TYPE ===
# 'daily' = normal field worker, paid per logged work day (daily_rate).
# 'fixed' = manager / salaried staff: paid a fixed monthly amount via a
# 'Salary' PayrollAdjustment, never logged on a WorkLog. See
# CLAUDE.md "Manager / Salaried pay" + the Path-A naming note.
PAY_TYPE_CHOICES = [
('daily', 'Daily-rated'),
('fixed', 'Fixed salary'),
]
pay_type = models.CharField(
max_length=10, choices=PAY_TYPE_CHOICES, default='daily',
help_text='Daily-rated workers are logged per day. Fixed-salary '
'staff (managers) are paid a set monthly amount and are '
'never added to a work log.',
)
```
Add the property next to `daily_rate` (after it, ~line 96):
```python
@property
def is_salaried(self):
# True for managers / fixed-salary staff (pay_type='fixed').
return self.pay_type == 'fixed'
```
**Step 4: Make + run the migration, then run tests**
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations core`
Expected: creates `core/migrations/0016_worker_pay_type.py` adding `pay_type`
with `default='daily'`. Open it and confirm `default='daily'` is present.
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
Expected: PASS (2 tests).
**Step 5: Commit**
```bash
git add core/models.py core/migrations/0016_worker_pay_type.py core/tests.py
git commit -m "feat: add Worker.pay_type discriminator + is_salaried property"
```
---
## Task 2: Register the `Salary` adjustment type as additive
**Files:**
- Modify: `core/models.py:289` (`PayrollAdjustment.TYPE_CHOICES`)
- Modify: `core/views.py` (`ADDITIVE_TYPES` — find with
`grep -n "^ADDITIVE_TYPES" core/views.py`)
- Test: `core/tests.py` (`ManagerSalariedPayTypeRegistrationTests`)
**Step 1: Write the failing tests**
```python
class ManagerSalariedPayTypeRegistrationTests(TestCase):
def test_salary_is_a_valid_adjustment_type(self):
from core.models import PayrollAdjustment
type_values = [c[0] for c in PayrollAdjustment.TYPE_CHOICES]
self.assertIn('Salary', type_values)
def test_salary_is_additive(self):
from core.views import ADDITIVE_TYPES
self.assertIn('Salary', ADDITIVE_TYPES)
```
**Step 2: Run — Expected:** FAIL (`'Salary'` not in list).
**Step 3: Implement**
`core/models.py` — append to `PayrollAdjustment.TYPE_CHOICES` (DB value ==
label, so NO UI-vs-DB drift — see CLAUDE.md):
```python
('Salary', 'Salary'),
```
`core/views.py` — add `'Salary'` to the `ADDITIVE_TYPES` list (it increases
net pay). Leave `DEDUCTIVE_TYPES` untouched. Add a short inline comment:
`# 'Salary' = manager / fixed-salary monthly pay (additive).`
**Step 4: Run — Expected:** PASS (2 tests). Then run the FULL suite to confirm
no existing test hard-codes the choices length:
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
Expected: still green (175 now).
**Step 5: Commit**
```bash
git add core/models.py core/views.py core/tests.py
git commit -m "feat: register 'Salary' PayrollAdjustment type as additive"
```
---
## Task 3: Exclude managers from the daily ATTENDANCE pickers
**Files:**
- Modify: `core/forms.py:106-109` (AttendanceLogForm supervisor branch),
`core/forms.py:114` (admin branch)
- Modify: `core/views.py:588` (`_build_team_workers_map` prefetch queryset)
- Modify: `core/views.py:791` (attendance cost-estimate JS rates loop)
- Test: `core/tests.py` (`ManagerSalariedAttendanceExclusionTests`)
**Step 1: Write the failing tests**
Mirror the existing attendance/team-filter test setup (see
`WorkHistoryTeamFilterTests` / `AbsenceLogViewTests` for fixtures patterns —
create an admin user, a supervisor user, a Team, a daily worker, a fixed
worker).
```python
class ManagerSalariedAttendanceExclusionTests(TestCase):
def setUp(self):
self.admin = User.objects.create_user('msa_admin', password='x', is_staff=True)
self.sup = User.objects.create_user('msa_sup', password='x')
self.daily = Worker.objects.create(
name='Daily Del', id_number='MSA-D', monthly_salary=Decimal('6000'))
self.mgr = Worker.objects.create(
name='Mgr Mo', id_number='MSA-M', monthly_salary=Decimal('40000'),
pay_type='fixed')
self.team = Team.objects.create(name='MSA Team', supervisor=self.sup)
self.team.workers.add(self.daily, self.mgr)
def test_attendance_admin_picker_excludes_fixed(self):
from core.forms import AttendanceLogForm
qs = AttendanceLogForm(user=self.admin).fields['workers'].queryset
self.assertIn(self.daily, qs)
self.assertNotIn(self.mgr, qs)
def test_attendance_supervisor_picker_excludes_fixed(self):
from core.forms import AttendanceLogForm
qs = AttendanceLogForm(user=self.sup).fields['workers'].queryset
self.assertIn(self.daily, qs)
self.assertNotIn(self.mgr, qs)
def test_team_workers_map_excludes_fixed(self):
from core.views import _build_team_workers_map
m = _build_team_workers_map(self.admin)
ids = m.get(self.team.id) or m.get(str(self.team.id)) or []
self.assertIn(self.daily.id, ids)
self.assertNotIn(self.mgr.id, ids)
```
**Step 2: Run — Expected:** FAIL (`mgr` present in querysets/map).
**Step 3: Implement**
Add `.exclude(pay_type='fixed')` to each queryset:
- `core/forms.py:106-109` (supervisor branch):
```python
self.fields['workers'].queryset = Worker.objects.filter(
active=True,
teams__in=supervised_teams,
).exclude(pay_type='fixed').distinct()
```
- `core/forms.py:114` (admin branch):
```python
self.fields['workers'].queryset = Worker.objects.filter(
active=True).exclude(pay_type='fixed')
```
- `core/views.py:588` — the `Prefetch('workers', queryset=Worker.objects
.filter(active=True), to_attr='active_workers_cached')`: change the inner
queryset to `Worker.objects.filter(active=True).exclude(pay_type='fixed')`.
Add a comment: `# Managers (pay_type='fixed') never go on a WorkLog.`
- `core/views.py:791``for w in Worker.objects.filter(active=True):`
`for w in Worker.objects.filter(active=True).exclude(pay_type='fixed'):`
**Step 4: Run — Expected:** PASS (3 tests).
**Step 5: Commit**
```bash
git add core/forms.py core/views.py core/tests.py
git commit -m "feat: exclude fixed-salary managers from attendance pickers"
```
---
## Task 4: Exclude managers from the ABSENCE pickers
**Files:**
- Modify: `core/forms.py:662` (`AbsenceLogForm.workers` admin default),
`core/forms.py:696-702` (supervisor branch)
- Modify: `core/forms.py:842` (`AbsenceEditForm.worker` admin),
`core/forms.py:851` (supervisor branch)
- Test: `core/tests.py` (`ManagerSalariedAbsenceExclusionTests`)
**Step 1: Write the failing tests**
```python
class ManagerSalariedAbsenceExclusionTests(TestCase):
def setUp(self):
self.admin = User.objects.create_user('msab_admin', password='x', is_staff=True)
self.sup = User.objects.create_user('msab_sup', password='x')
self.daily = Worker.objects.create(
name='Abs Daily', id_number='MSAB-D', monthly_salary=Decimal('6000'))
self.mgr = Worker.objects.create(
name='Abs Mgr', id_number='MSAB-M', monthly_salary=Decimal('40000'),
pay_type='fixed')
self.team = Team.objects.create(name='MSAB Team', supervisor=self.sup, active=True)
self.team.workers.add(self.daily, self.mgr)
def test_absencelog_admin_excludes_fixed(self):
from core.forms import AbsenceLogForm
qs = AbsenceLogForm(user=self.admin).fields['workers'].queryset
self.assertIn(self.daily, qs)
self.assertNotIn(self.mgr, qs)
def test_absencelog_supervisor_excludes_fixed(self):
from core.forms import AbsenceLogForm
qs = AbsenceLogForm(user=self.sup).fields['workers'].queryset
self.assertIn(self.daily, qs)
self.assertNotIn(self.mgr, qs)
def test_absenceedit_admin_excludes_fixed(self):
from core.forms import AbsenceEditForm
qs = AbsenceEditForm(user=self.admin).fields['worker'].queryset
self.assertNotIn(self.mgr, qs)
def test_absenceedit_supervisor_excludes_fixed(self):
from core.forms import AbsenceEditForm
qs = AbsenceEditForm(user=self.sup).fields['worker'].queryset
self.assertNotIn(self.mgr, qs)
```
> If `AbsenceEditForm(user=...)` needs an `instance=` to construct, mirror how
> the existing absence-edit tests instantiate it (check
> `AbsenceEditDeleteTests` in `core/tests.py`) and adjust the two
> `AbsenceEditForm` tests accordingly — the assertion (mgr not in queryset)
> stays the same.
**Step 2: Run — Expected:** FAIL.
**Step 3: Implement** — add `.exclude(pay_type='fixed')` to each of the four
querysets (`forms.py:662`, `:696-702`, `:842`, `:851`), preserving any existing
`.distinct()` (keep `.exclude(pay_type='fixed')` before `.distinct()`).
**Step 4: Run — Expected:** PASS (4 tests).
**Step 5: Commit**
```bash
git add core/forms.py core/tests.py
git commit -m "feat: exclude fixed-salary managers from absence pickers"
```
---
## Task 5: `add_adjustment` Salary branch + project-required validation
**Files:**
- Modify: `core/views.py:4247` (add `'Salary'` to `project_required_types`)
- Modify: `core/views.py` — new `Salary` branch placed AFTER the
`Advance Payment` branch (ends ~`:4386`) and BEFORE the generic
"ALL OTHER TYPES" block (`:4388`)
- Modify: `core/forms.py:179` (`PayrollAdjustmentForm.clean`
`project_required_types` tuple — add `'Salary'`)
- Test: `core/tests.py` (`ManagerSalariedPaySalaryAdjustmentTests`)
**Step 1: Write the failing tests**
```python
class ManagerSalariedPaySalaryAdjustmentTests(TestCase):
def setUp(self):
self.admin = User.objects.create_user('mss_admin', password='x', is_staff=True)
self.client.force_login(self.admin)
self.proj = Project.objects.create(name='MSS Project')
self.mgr = Worker.objects.create(
name='Sal Mgr', id_number='MSS-M', monthly_salary=Decimal('40000'),
pay_type='fixed')
def _post(self, **over):
data = {
'workers': [self.mgr.id], 'type': 'Salary',
'amount': '40000.00', 'description': 'Salary - May 2026',
'date': _date.today().strftime('%Y-%m-%d'),
'project': self.proj.id,
}
data.update(over)
return self.client.post(reverse('add_adjustment'), data)
def test_salary_requires_project(self):
resp = self._post(project='')
self.assertFalse(
PayrollAdjustment.objects.filter(worker=self.mgr, type='Salary').exists())
def test_salary_unpaid_creates_pending_adjustment(self):
self._post() # no pay_immediately
adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary')
self.assertIsNone(adj.payroll_record)
self.assertEqual(adj.project, self.proj)
self.assertEqual(adj.amount, Decimal('40000.00'))
def test_salary_pay_immediately_creates_payroll_record(self):
self._post(pay_immediately='1')
adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary')
self.assertIsNotNone(adj.payroll_record)
self.assertEqual(adj.payroll_record.amount_paid, Decimal('40000.00'))
def test_salary_required_in_payrolladjustmentform_clean(self):
from core.forms import PayrollAdjustmentForm
f = PayrollAdjustmentForm(data={
'type': 'Salary', 'worker': self.mgr.id, 'amount': '40000.00',
'date': _date.today().strftime('%Y-%m-%d'), 'description': 'x',
})
self.assertFalse(f.is_valid())
self.assertIn('project', f.errors)
```
> `_date` is the `datetime.date` alias already imported in `core/tests.py`
> (~line 1693). If your new class is above that import, add
> `from datetime import date as _date` locally or use `datetime.date`.
**Step 2: Run — Expected:** FAIL (Salary not project-required; no branch).
**Step 3: Implement**
`core/forms.py:179` — add `'Salary'`:
```python
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary')
```
`core/views.py:4247` — add `'Salary'`:
```python
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary')
```
`core/views.py` — insert this branch AFTER the Advance Payment block's
`continue` (~`:4386`), BEFORE the generic `# === ALL OTHER TYPES ===` block:
```python
# === SALARY — manager / fixed-salary monthly payment ===
# Mirrors the New Loan pay-immediately pattern (isolated
# PayrollRecord). If "Pay Immediately" is unchecked the row falls
# through to the generic pending-adjustment creation below and is
# netted with any deductions/loan-repayments by
# _process_single_payment when the admin clicks Pay on the dashboard.
if adj_type == 'Salary':
pay_immediately = request.POST.get('pay_immediately') == '1'
if pay_immediately:
salary_adj = PayrollAdjustment.objects.create(
worker=worker,
type='Salary',
amount=amount,
date=adj_date,
description=description,
project=project,
)
payroll_record = PayrollRecord.objects.create(
worker=worker,
amount_paid=amount,
date=adj_date,
)
salary_adj.payroll_record = payroll_record
salary_adj.save()
_send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00'))
created_count += 1
continue # Skip the generic PayrollAdjustment creation below
# else: fall through to generic pending creation (keeps project)
```
(The generic block at `:4388-4398` already sets `project=project`, so an
unpaid Salary needs no extra code there.)
**Step 4: Run — Expected:** PASS (4 tests). Then full suite:
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2` — green.
**Step 5: Commit**
```bash
git add core/views.py core/forms.py core/tests.py
git commit -m "feat: add_adjustment Salary branch (project-required, pay-now/pending)"
```
---
## Task 6: Report — per-project "Management / Salaried Cost" + regression guard
**Files:**
- Modify: `core/views.py:2416` (`_build_report_context`) — add a
`salaried_cost_by_project` rollup from `Salary`-typed adjustments
- Modify: `core/templates/core/report.html` — render the new line in the
per-project section (find the existing "Labour Cost by Project" block)
- Test: `core/tests.py` (`ManagerSalariedPayReportTests`)
**Step 1: Write the failing tests**
The regression test is the important one — it proves daily-worker numbers are
byte-for-byte unchanged when a manager + Salary adjustment exist.
```python
class ManagerSalariedPayReportTests(TestCase):
def setUp(self):
self.proj = Project.objects.create(name='Rep Proj')
self.daily = Worker.objects.create(
name='Rep Daily', id_number='RP-D', monthly_salary=Decimal('6000'))
self.start = _date(2026, 5, 1)
self.end = _date(2026, 5, 31)
wl = WorkLog.objects.create(date=_date(2026, 5, 4), project=self.proj)
wl.workers.add(self.daily)
def _ctx(self):
from core.views import _build_report_context
return _build_report_context(self.start, self.end)
def test_daily_numbers_unchanged_when_manager_added(self):
before = self._ctx()
mgr = Worker.objects.create(
name='Rep Mgr', id_number='RP-M', monthly_salary=Decimal('40000'),
pay_type='fixed')
PayrollAdjustment.objects.create(
worker=mgr, type='Salary', amount=Decimal('40000.00'),
date=_date(2026, 5, 25), project=self.proj)
after = self._ctx()
# WorkLog-derived figures must be identical with a manager present.
self.assertEqual(
before.get('total_worker_days'), after.get('total_worker_days'))
self.assertEqual(
str(before.get('labour_cost_by_project')),
str(after.get('labour_cost_by_project')))
def test_salaried_cost_by_project_exposed(self):
mgr = Worker.objects.create(
name='Rep Mgr2', id_number='RP-M2', monthly_salary=Decimal('40000'),
pay_type='fixed')
PayrollAdjustment.objects.create(
worker=mgr, type='Salary', amount=Decimal('40000.00'),
date=_date(2026, 5, 25), project=self.proj)
ctx = self._ctx()
self.assertIn('salaried_cost_by_project', ctx)
total = sum(
(row.get('total') or Decimal('0'))
for row in ctx['salaried_cost_by_project'])
self.assertEqual(total, Decimal('40000.00'))
```
> Adjust the exact context-key names in the assertions to match what
> `_build_report_context` actually returns for the labour-cost-by-project
> structure (inspect `views.py:~2502` and the keys the function returns near
> its `return {...}`). Keep the **intent**: (a) WorkLog-derived keys identical
> before/after a manager exists; (b) a new `salaried_cost_by_project` key whose
> Salary amounts sum to 40000.
**Step 2: Run — Expected:** FAIL (`salaried_cost_by_project` missing).
**Step 3: Implement**
In `_build_report_context`, after the existing adjustments queryset is built
(it is already filtered by `project_id` when `project_ids` is set, ~`:2484-2490`)
and near the "Labour Cost by Project" computation (~`:2502`), add a salaried
rollup that does NOT touch the WorkLog-derived `labour_cost_by_project`:
```python
# --- Management / Salaried Cost by Project (selected period) ---
# Managers have no WorkLogs, so their pay never appears in the
# WorkLog-derived labour cost above. Surface it as a SEPARATE line so
# it is visible + attributed but never merged into daily-worker numbers.
salaried_rows = (
adjustments.filter(type='Salary')
.values('project__id', 'project__name')
.annotate(total=Sum('amount'))
.order_by('-total')
)
salaried_cost_by_project = [
{
'project_id': r['project__id'],
'project': r['project__name'] or 'Unassigned',
'total': r['total'] or Decimal('0.00'),
}
for r in salaried_rows
]
```
Add `salaried_cost_by_project` to the context dict that the function returns
(find its `return {...}` / `context = {...}` and add the key).
`core/templates/core/report.html` — in the per-project section that renders
"Labour Cost by Project", add a sibling block iterating
`salaried_cost_by_project` titled **"Management / Salaried Cost"**, mirroring
the existing block's markup/classes. Keep money formatting consistent
(`{{ row.total|money }}` — the project uses the `money` filter, see
`format_tags.py`). If you write any multi-line explanatory comment in the
template use `{% comment %}...{% endcomment %}`, never multi-line `{# #}`.
**Step 4: Run — Expected:** PASS (2 tests). Then:
- Full suite green.
- Template sanity check:
`grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'`
Expected: **no output**.
**Step 5: Commit**
```bash
git add core/views.py core/templates/core/report.html core/tests.py
git commit -m "feat: per-project Management/Salaried Cost line + regression guard"
```
---
## Task 7: UI — WorkerForm pay_type, Type indicator, Pay-Salary entry, badge CSS
**Files:**
- Modify: `core/forms.py:248` (`WorkerForm` — add `pay_type` to `fields` +
a Select widget)
- Modify: worker templates — `core/templates/core/workers/edit.html`
(render the `pay_type` field in the "Personal & Pay" section),
`core/templates/core/workers/list.html` and `.../detail.html`
(a "Type" indicator: "Daily" vs "Manager / Salaried")
- Modify: the Add-Adjustment modal template (the payroll dashboard modal —
locate with `grep -rn "name=\"pay_immediately\"\|id=\"adjustmentModal\"\|Add Adjustment" core/templates`) — add a "Salary" `<option>` to the type
`<select>` and ensure the project field + a "Pay Salary" entry point
(a button that opens the modal pre-set to type=Salary) exist
- Modify: `static/css/custom.css` — add `--badge-salary-bg/-fg` (dark +
light theme blocks) and a `.badge-type-salary` rule, matching the existing
`--badge-*` token pattern (search `--badge-bonus`)
- Test: `core/tests.py` (`ManagerSalariedPayUITests`)
**Step 1: Write the failing tests** (light, server-side only)
```python
class ManagerSalariedPayUITests(TestCase):
def setUp(self):
self.admin = User.objects.create_user('msu_admin', password='x', is_staff=True)
self.client.force_login(self.admin)
def test_workerform_has_pay_type(self):
from core.forms import WorkerForm
self.assertIn('pay_type', WorkerForm().fields)
def test_worker_edit_page_renders_pay_type(self):
resp = self.client.get(reverse('worker_edit', args=['new']) if False else '/workers/new/')
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'pay_type')
def test_worker_list_shows_manager_label_for_fixed(self):
Worker.objects.create(
name='UI Mgr', id_number='MSU-M', monthly_salary=Decimal('40000'),
pay_type='fixed')
resp = self.client.get('/workers/?status=all')
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Manager') # the "Manager / Salaried" label
```
> Confirm the worker-new URL (the design/CLAUDE.md routes table says
> `/workers/new/``worker_edit`). Adjust the second test's path/reverse to
> the project's actual pattern if `reverse('worker_edit', ...)` differs; keep
> the assertion that the page renders and contains the `pay_type` control.
> If `/workers/` requires a status param to show inactive/all, keep `?status=all`.
**Step 2: Run — Expected:** FAIL.
**Step 3: Implement**
- `WorkerForm.Meta.fields`: add `'pay_type'` (put it right after
`'monthly_salary'` so it reads naturally in the Personal & Pay section).
Add to `widgets`:
`'pay_type': forms.Select(attrs={'class': 'form-select'}),`
- `workers/edit.html`: render `{{ form.pay_type }}` with its label in the
Personal & Pay block next to monthly salary (match the surrounding
field-group markup).
- `workers/list.html` + `workers/detail.html`: where the row/profile shows
worker attributes, add a small indicator —
`{% if worker.is_salaried %}Manager / Salaried{% else %}Daily{% endif %}`
(use a badge span consistent with the page's existing chip styling).
- Add-Adjustment modal: add `<option value="Salary">Salary</option>` to the
type select; ensure the project field shows for Salary (the modal already
toggles project visibility per type — extend that JS list to include
`Salary`, mirroring `Bonus`). Add a "Pay Salary" button (e.g. on the
payroll dashboard near "Add Adjustment") that opens the same modal with
type preset to `Salary` — reuse the existing modal-open JS pattern; do not
build a new modal.
- `static/css/custom.css`: add tokens mirroring `--badge-bonus-*`:
```css
--badge-salary-bg: <pick a distinct hue, e.g. teal>;
--badge-salary-fg: <readable fg>;
```
in BOTH the `:root` (dark) and `[data-theme="light"]` blocks, plus a
`.badge-type-salary { background: var(--badge-salary-bg); color: var(--badge-salary-fg); }`
rule next to the other `.badge-type-*` rules. (`type_slug` turns
`'Salary'``salary` automatically.)
**Step 4: Run — Expected:** PASS (3 tests). Then:
- Full suite green.
- Template sanity check (same grep as Task 6) — no output.
**Step 5: Commit**
```bash
git add core/forms.py core/templates static/css/custom.css core/tests.py
git commit -m "feat: pay_type UI (worker form/list/detail), Salary modal option + badge"
```
---
## Task 8: Documentation + full-suite gate + HARD STOP
**Files:**
- Modify: `CLAUDE.md`
- Modify: `docs/plans/parked-work.md`
- No test code; this task is docs + the final gate.
**Step 1: Update `CLAUDE.md`**
- "Key Models" → `Worker` line: note the new `pay_type`
(`'daily'` default | `'fixed'` = manager/salaried; `is_salaried` property).
- `Absence` / Payroll Constants area: add `'Salary'` to the documented
`ADDITIVE_TYPES` list so it stays accurate.
- Add a short subsection "Manager / Salaried pay" summarising: managers are
`Worker(pay_type='fixed')`; excluded from attendance/absence pickers ONLY
(NOT payroll modal pickers); paid via the `Salary` adjustment type through
`add_adjustment`; immediate path mirrors New Loan (isolated PayrollRecord),
unpaid path nets via `_process_single_payment`; "Manager / Salaried" is a
Path-A display-only label (model/table stay `Worker`).
- Add to the UI-vs-DB / Path-A naming section: "Manager / Salaried" is a
display label; the model is `Worker`, the discriminator is `pay_type`.
**Step 2: Update `docs/plans/parked-work.md`**
Move/append a "Recently shipped" entry (or, if not yet pushed, a
"⏸ Paused — implemented locally, awaiting Konrad's verification" entry)
describing the Manager/Salaried Pay feature, the design+plan commits, and the
**HARD STOP before push** state.
**Step 3: Full suite gate**
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
Expected: **ALL green** (≈173 baseline + ~18 new ≈ 191; exact count may vary).
Run the template sanity grep one final time — no output.
**Step 4: Commit**
```bash
git add CLAUDE.md docs/plans/parked-work.md
git commit -m "docs: document Manager/Salaried pay; park pending Konrad local verify"
```
**Step 5: 🛑 HARD STOP — DO NOT PUSH OR DEPLOY**
Stop here. Do **not** `git push`, do **not** touch the Flatlogic VM, do **not**
run any deploy step. Report completion to Konrad and hand him the manual
verification checklist from the design doc
(`docs/plans/2026-05-15-manager-salaried-pay-design.md` → "Verification
(manual, local — Konrad)"). Only after Konrad runs it locally and explicitly
says "push it" does anything leave the local machine. All commits from Tasks
18 remain **local-only on `ai-dev`**, consistent with the existing local-only
doc commits.
---
## Definition of done
- ~18 new tests added, full suite green locally, no template-comment breakage.
- `pay_type` migration defaults every existing worker to `'daily'`.
- Managers excluded from attendance + absence pickers only; still selectable in
all payroll modals.
- `Salary` type works (project-required; pay-now isolated PR like New Loan;
unpaid → pending → nets via `_process_single_payment`).
- Regression test proves daily-worker report numbers are byte-for-byte
unchanged with a manager present.
- `_process_single_payment` logic untouched.
- Docs updated. **Nothing pushed.** Konrad verifies locally before deploy.