38686-vm/docs/plans/2026-05-15-manager-salaried-pay-plan.md

822 lines
35 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.

# 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`)
- Create: `core/migrations/0017_alter_payrolladjustment_type.py` (auto-generated;
depends on `0016_worker_pay_type`). **Adding a value to
`PayrollAdjustment.TYPE_CHOICES` DOES require a migration in this codebase** —
Django's migration autodetector tracks `choices` in field state, so it emits
a no-op `AlterField` (choices metadata only; no data or schema change).
Precedent: `0012_alter_payrolladjustment_type_display_labels.py` was generated
for the exact same kind of `TYPE_CHOICES` edit. Skipping it leaves
`makemigrations --check` non-clean — a deploy-hygiene defect.
- 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). Note: "DB value == label" only
means the displayed text equals the stored string; it does **not** mean the
change is migration-free — see the migration bullet above.
```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).`
Then generate the required choices migration:
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations core`
Expected: creates `core/migrations/0017_alter_payrolladjustment_type.py` — a
single `AlterField` on `payrolladjustment.type` that changes only `choices`
(depends on `0016_worker_pay_type`; no data migration, no other model touched).
**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; the migration is a no-op for behavior). Finally
confirm a clean tree:
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations --check --dry-run core`
must report "No changes detected" (exit 0).
**Step 5: Commit**
```bash
git add core/models.py core/views.py core/tests.py \
core/migrations/0017_alter_payrolladjustment_type.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.