35 KiB
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 as325c59donai-dev, NOT pushed). Read it in full. - Project guide:
CLAUDE.mdat 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.exeuseset USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests
- Run a single test class:
- Test gotcha (already bit a prior session): in
core/tests.pyusedatetime.date.today()/datetime.date(...), NOTtimezone.now().date().tests.pyimportsdatetime(and a_datealias around line 1693); it does NOT importtimezonein 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)
- The
pay_type='fixed'exclusion is applied ONLY to attendance + absence pickers (Tasks 3 & 4). It must NEVER be added toPayrollAdjustmentForm(forms.py:170), the Team edit form (forms.py:427), or any payroll modal worker picker — managers MUST remain selectable there. _process_single_payment(views.py:3696) logic is not modified. The only money-path edits are: adding'Salary'toADDITIVE_TYPES, and a newSalarybranch inadd_adjustment.- The migration defaults
pay_typeto'daily'for every existing worker. - Pay-Immediately Salary nuance (intentional): the immediate path creates
an isolated
PayrollRecordexactly like the existingNew Loanpay-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_paymentlater 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. - 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 todaily_rateat:92-95) - Create:
core/migrations/0016_worker_pay_type.py(auto-generated; the latest existing migration is0015_absence_project) - Test:
core/tests.py(new classManagerSalariedPayModelTestsat 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 withgrep -n "^ADDITIVE_TYPES" core/views.py) - Create:
core/migrations/0017_alter_payrolladjustment_type.py(auto-generated; depends on0016_worker_pay_type). Adding a value toPayrollAdjustment.TYPE_CHOICESDOES require a migration in this codebase — Django's migration autodetector trackschoicesin field state, so it emits a no-opAlterField(choices metadata only; no data or schema change). Precedent:0012_alter_payrolladjustment_type_display_labels.pywas generated for the exact same kind ofTYPE_CHOICESedit. Skipping it leavesmakemigrations --checknon-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_mapprefetch 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— thePrefetch('workers', queryset=Worker.objects .filter(active=True), to_attr='active_workers_cached'): change the inner queryset toWorker.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
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.workersadmin default),core/forms.py:696-702(supervisor branch) - Modify:
core/forms.py:842(AbsenceEditForm.workeradmin),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 aninstance=to construct, mirror how the existing absence-edit tests instantiate it (checkAbsenceEditDeleteTestsincore/tests.py) and adjust the twoAbsenceEditFormtests 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'toproject_required_types) - Modify:
core/views.py— newSalarybranch placed AFTER theAdvance Paymentbranch (ends ~:4386) and BEFORE the generic "ALL OTHER TYPES" block (:4388) - Modify:
core/forms.py:179(PayrollAdjustmentForm.cleanproject_required_typestuple — 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)
_dateis thedatetime.datealias already imported incore/tests.py(~line 1693). If your new class is above that import, addfrom datetime import date as _datelocally or usedatetime.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 asalaried_cost_by_projectrollup fromSalary-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_contextactually returns for the labour-cost-by-project structure (inspectviews.py:~2502and the keys the function returns near itsreturn {...}). Keep the intent: (a) WorkLog-derived keys identical before/after a manager exists; (b) a newsalaried_cost_by_projectkey 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— addpay_typetofields+ a Select widget) - Modify: worker templates —
core/templates/core/workers/edit.html(render thepay_typefield in the "Personal & Pay" section),core/templates/core/workers/list.htmland.../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-salaryrule, 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 ifreverse('worker_edit', ...)differs; keep the assertion that the page renders and contains thepay_typecontrol. 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 towidgets:'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 includeSalary, mirroringBonus). Add a "Pay Salary" button (e.g. on the payroll dashboard near "Add Adjustment") that opens the same modal with type preset toSalary— reuse the existing modal-open JS pattern; do not build a new modal. static/css/custom.css: add tokens mirroring--badge-bonus-*:
in BOTH the--badge-salary-bg: <pick a distinct hue, e.g. teal>; --badge-salary-fg: <readable fg>;: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_slugturns'Salary'→salaryautomatically.)
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" →
Workerline: note the newpay_type('daily'default |'fixed'= manager/salaried;is_salariedproperty). Absence/ Payroll Constants area: add'Salary'to the documentedADDITIVE_TYPESlist 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 theSalaryadjustment type throughadd_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 stayWorker). - Add to the UI-vs-DB / Path-A naming section: "Manager / Salaried" is a
display label; the model is
Worker, the discriminator ispay_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
1–8 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_typemigration defaults every existing worker to'daily'.- Managers excluded from attendance + absence pickers only; still selectable in all payroll modals.
Salarytype 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_paymentlogic untouched.- Docs updated. Nothing pushed. Konrad verifies locally before deploy.