diff --git a/docs/plans/2026-05-15-manager-salaried-pay-plan.md b/docs/plans/2026-05-15-manager-salaried-pay-plan.md new file mode 100644 index 0000000..775ff29 --- /dev/null +++ b/docs/plans/2026-05-15-manager-salaried-pay-plan.md @@ -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" `