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

35 KiB
Raw Permalink Blame History

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 :170must 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

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(...)):

    # === 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):

    @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

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

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.

        ('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

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).

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):
    self.fields['workers'].queryset = Worker.objects.filter(
        active=True,
        teams__in=supervised_teams,
    ).exclude(pay_type='fixed').distinct()
    
  • core/forms.py:114 (admin branch):
    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:791for 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

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

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

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

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':

project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary')

core/views.py:4247 — add 'Salary':

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:

        # === 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

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.

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:

    # --- 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

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)

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-*:
    --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

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

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.