diff --git a/core/forms.py b/core/forms.py
index 48fd1f5..a473d5f 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -255,7 +255,7 @@ class WorkerForm(forms.ModelForm):
class Meta:
model = Worker
fields = [
- 'name', 'id_number', 'phone_number', 'monthly_salary',
+ 'name', 'id_number', 'phone_number', 'monthly_salary', 'pay_type',
'tax_number', 'uif_number', 'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes',
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
@@ -267,6 +267,10 @@ class WorkerForm(forms.ModelForm):
'id_number': forms.TextInput(attrs={'class': 'form-control'}),
'phone_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+27...'}),
'monthly_salary': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
+ # pay_type: 'daily' = normal field worker (paid per logged work day);
+ # 'fixed' = manager / salaried staff (paid a monthly Salary
+ # adjustment, never logged on attendance).
+ 'pay_type': forms.Select(attrs={'class': 'form-select'}),
# Banking & Tax
'tax_number': forms.TextInput(attrs={'class': 'form-control'}),
'uif_number': forms.TextInput(attrs={'class': 'form-control'}),
diff --git a/core/templates/core/email/payslip_email.html b/core/templates/core/email/payslip_email.html
index 79e3856..150c09b 100644
--- a/core/templates/core/email/payslip_email.html
+++ b/core/templates/core/email/payslip_email.html
@@ -29,7 +29,7 @@
@@ -58,6 +58,11 @@
diff --git a/core/templates/core/workers/detail.html b/core/templates/core/workers/detail.html
index f48b528..d43f341 100644
--- a/core/templates/core/workers/detail.html
+++ b/core/templates/core/workers/detail.html
@@ -62,6 +62,16 @@
+ Pay Type
+
+ {# Manager/salaried staff are paid a monthly Salary, #}
+ {# not per logged work day. #}
+ {% if worker.is_salaried %}
+ Manager / Salaried
+ {% else %}
+ Daily
+ {% endif %}
+
Monthly Salary R {{ worker.monthly_salary|money }}
Daily Rate R {{ worker.daily_rate|money }}
Employment Date {{ worker.employment_date|date:"d M Y" }}
diff --git a/core/templates/core/workers/edit.html b/core/templates/core/workers/edit.html
index 56951ad..0e79385 100644
--- a/core/templates/core/workers/edit.html
+++ b/core/templates/core/workers/edit.html
@@ -65,6 +65,19 @@
{{ form.monthly_salary }}
{% if form.monthly_salary.errors %}{{ form.monthly_salary.errors|first }}
{% endif %}
+ {# Pay Type — Daily-rated field worker vs Fixed-salary manager. #}
+ {# A "Fixed salary" worker is paid a monthly Salary adjustment #}
+ {# and is never logged on attendance / absences. #}
+
+
+ Pay Type
+
+
+ {{ form.pay_type }}
+ {% if form.pay_type.errors %}
{{ form.pay_type.errors|first }}
{% endif %}
+
Employment Date
{{ form.employment_date }}
diff --git a/core/templates/core/workers/list.html b/core/templates/core/workers/list.html
index 585be27..15a9a0c 100644
--- a/core/templates/core/workers/list.html
+++ b/core/templates/core/workers/list.html
@@ -75,6 +75,7 @@
Name
ID Number
+ Type
Phone
Salary
Days Worked
@@ -91,6 +92,15 @@
{{ w.id_number }}
+
+ {# Pay-type indicator — managers/salaried staff are #}
+ {# paid a monthly Salary, not per logged work day. #}
+ {% if w.is_salaried %}
+ Manager / Salaried
+ {% else %}
+ Daily
+ {% endif %}
+
{{ w.phone_number|default:'—' }}
R {{ w.monthly_salary|money }}
{{ w.days_worked }}
diff --git a/core/tests.py b/core/tests.py
index f4f28fb..0c384ad 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -3710,3 +3710,100 @@ class ManagerSalariedPayReportTests(TestCase):
self.assertIsNotNone(sal.payroll_record)
self.assertEqual(sal.payroll_record, ded.payroll_record) # same PayrollRecord
self.assertEqual(sal.payroll_record.amount_paid, Decimal('39000.00')) # 40000 - 1000
+
+
+# =============================================================================
+# === TASK 7 — MANAGER / SALARIED PAY: UI SURFACE + CLEAN SALARY PAYSLIP ===
+# Server-side, light (no browser). Proves:
+# - WorkerForm exposes the pay_type field (Part A) and the worker-create
+# page renders it (Part B).
+# - The friendly worker list labels a fixed-salary worker as a
+# "Manager / Salaried" type (Part C).
+# - An immediate Salary payment's payslip uses the clean single-line
+# layout — NO "0 days worked" / "Base Pay Subtotal" / "No work logs"
+# generic-branch artefacts (Part F, absorbed from Task 5 review #2).
+# =============================================================================
+
+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_new_page_renders_pay_type(self):
+ # URL name 'worker_new' → /workers/new/ (core/urls.py).
+ resp = self.client.get(reverse('worker_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')
+ # worker_list status filter param confirmed in CLAUDE.md routes table.
+ resp = self.client.get(reverse('worker_list') + '?status=all')
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, 'Manager')
+
+ def test_salary_immediate_payslip_has_no_zero_days_line(self):
+ # Absorbed Task-5 review #2: an immediate Salary payment's payslip
+ # must use the clean single-line layout — NOT the generic
+ # work-log/base-pay branch (which for log_count==0 renders an
+ # empty "Work Log Details" table with "No work logs in this
+ # period." + a "Base Pay Subtotal" of R 0.00). The PDF/email
+ # templates phrase this as "Base Pay (0 days worked)".
+ proj = Project.objects.create(name='UI Sal Proj')
+ mgr = Worker.objects.create(
+ name='UI Sal Mgr', id_number='MSU-S', monthly_salary=Decimal('40000'),
+ pay_type='fixed')
+ self.client.post(reverse('add_adjustment'), {
+ 'workers': [mgr.id], 'type': 'Salary', 'amount': '40000.00',
+ 'description': 'Salary - May 2026',
+ 'date': _date.today().strftime('%Y-%m-%d'),
+ 'project': proj.id, 'pay_immediately': '1',
+ })
+ pr = PayrollRecord.objects.get(worker=mgr)
+ resp = self.client.get(reverse('payslip_detail', args=[pr.id]))
+ self.assertEqual(resp.status_code, 200)
+ body = resp.content.decode()
+ # The clean Salary layout shows the Salary detail; it must NOT
+ # fall through to the empty generic work-log/base-pay branch.
+ self.assertIn('Salary', body)
+ self.assertNotIn('No work logs in this period.', body)
+ self.assertNotIn('Base Pay Subtotal', body)
+
+ def test_salary_immediate_payslip_pdf_context_is_clean(self):
+ # Directly prove the PDF/email context flag: a lone Salary
+ # adjustment with log_count==0 must NOT render the generic
+ # "Base Pay (N days worked)" line in payslip_pdf.html.
+ from django.template.loader import render_to_string
+ proj = Project.objects.create(name='UI Sal Proj2')
+ mgr = Worker.objects.create(
+ name='UI Sal Mgr2', id_number='MSU-S2', monthly_salary=Decimal('40000'),
+ pay_type='fixed')
+ self.client.post(reverse('add_adjustment'), {
+ 'workers': [mgr.id], 'type': 'Salary', 'amount': '40000.00',
+ 'description': 'Salary - May 2026',
+ 'date': _date.today().strftime('%Y-%m-%d'),
+ 'project': proj.id, 'pay_immediately': '1',
+ })
+ pr = PayrollRecord.objects.get(worker=mgr)
+ adjs = list(pr.adjustments.all())
+ self.assertEqual(len(adjs), 1)
+ self.assertEqual(adjs[0].type, 'Salary')
+ sal = adjs[0]
+ ctx = {
+ 'record': pr, 'logs_count': 0, 'logs_amount': Decimal('0.00'),
+ 'adjustments': pr.adjustments.all(),
+ 'deductive_types': ['Deduction', 'Loan Repayment', 'Advance Repayment'],
+ 'is_advance': False, 'advance_amount': None, 'advance_description': '',
+ 'is_loan': False, 'loan_amount': None, 'loan_description': '',
+ 'is_salary': True, 'salary_amount': sal.amount,
+ 'salary_description': sal.description,
+ }
+ html = render_to_string('core/pdf/payslip_pdf.html', ctx)
+ self.assertNotIn('days worked', html) # generic Base Pay line absent
+ self.assertIn('Salary', html)
diff --git a/core/views.py b/core/views.py
index 3e449ff..7f97aef 100644
--- a/core/views.py
+++ b/core/views.py
@@ -3878,10 +3878,12 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
total_amount = payroll_record.amount_paid
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
- # Advance-only or Loan-only payments use a cleaner payslip layout
- # showing just the amount instead of "0 days worked + adjustment".
+ # Advance-only, Loan-only or Salary-only payments use a cleaner
+ # payslip layout showing just the amount instead of the generic
+ # "Base Pay (0 days worked) — R 0.00" line followed by the adjustment.
advance_adj = None
loan_adj = None
+ salary_adj = None
if log_count == 0:
adjs_list = list(payroll_record.adjustments.all())
if len(adjs_list) == 1:
@@ -3889,13 +3891,18 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
advance_adj = adjs_list[0]
elif adjs_list[0].type == 'New Loan':
loan_adj = adjs_list[0]
+ elif adjs_list[0].type == 'Salary':
+ salary_adj = adjs_list[0]
is_advance = advance_adj is not None
is_loan = loan_adj is not None
+ is_salary = salary_adj is not None
if is_advance:
subject = f"Advance Payslip for {worker.name} - {payroll_record.date}"
elif is_loan:
subject = f"Loan Payslip for {worker.name} - {payroll_record.date}"
+ elif is_salary:
+ subject = f"Salary Payslip for {worker.name} - {payroll_record.date}"
else:
subject = f"Payslip for {worker.name} - {payroll_record.date}"
@@ -3912,6 +3919,9 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
'is_loan': is_loan,
'loan_amount': loan_adj.amount if loan_adj else None,
'loan_description': loan_adj.description if loan_adj else '',
+ 'is_salary': is_salary,
+ 'salary_amount': salary_adj.amount if salary_adj else None,
+ 'salary_description': salary_adj.description if salary_adj else '',
}
# 1. Render HTML email body
@@ -5172,15 +5182,20 @@ def payslip_detail(request, pk):
adjustments_net = record.amount_paid - base_pay
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
- # Advance-only or Loan-only payments use a cleaner layout.
+ # Advance-only, Loan-only or Salary-only payments use a cleaner
+ # layout (one detail line + total) instead of an empty work-log
+ # table with a "Base Pay Subtotal — R 0.00" footer row.
adjs_list = list(adjustments)
advance_adj = None
loan_adj = None
+ salary_adj = None
if logs.count() == 0 and len(adjs_list) == 1:
if adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
elif adjs_list[0].type == 'New Loan':
loan_adj = adjs_list[0]
+ elif adjs_list[0].type == 'Salary':
+ salary_adj = adjs_list[0]
context = {
'record': record,
@@ -5194,6 +5209,8 @@ def payslip_detail(request, pk):
'advance_adj': advance_adj,
'is_loan': loan_adj is not None,
'loan_adj': loan_adj,
+ 'is_salary': salary_adj is not None,
+ 'salary_adj': salary_adj,
}
return render(request, 'core/payslip.html', context)
diff --git a/static/css/custom.css b/static/css/custom.css
index 16295bd..ad6b6c6 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -89,6 +89,10 @@
--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1;
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2;
+ /* Salary (manager / fixed monthly pay) — a distinct teal, not used by
+ any other type; reads as "regular salary/pay", separate from the
+ forest-green Bonus and the slate-blue Advance. */
+ --badge-salary-bg: #1f7a70; --badge-salary-fg: #d6f1ec;
/* === PAYROLL DASHBOARD action-button tokens (dark theme) ===
Soft-fill pastels for the 4 action buttons at the top of /payroll/
@@ -162,6 +166,8 @@
--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418;
--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56;
--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550;
+ /* Salary (light theme) — pale teal fill, deep-teal text for contrast. */
+ --badge-salary-bg: #c3e8e2; --badge-salary-fg: #18534b;
/* === PAYROLL DASHBOARD action-button tokens (light theme) === */
--btn-action-lookup-bg: #c7d9e8; --btn-action-lookup-fg: #243b56;
@@ -1935,7 +1941,8 @@ body, .card, .modal-content, .form-control, .form-select,
.badge-type-new-loan,
.badge-type-loan-repayment,
.badge-type-advance-payment,
-.badge-type-advance-repayment {
+.badge-type-advance-repayment,
+.badge-type-salary {
display: inline-block;
padding: 0.3rem 0.7rem;
border-radius: 999px;
@@ -1952,6 +1959,7 @@ body, .card, .modal-content, .form-control, .form-select,
.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); }
.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); }
.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); }
+.badge-type-salary { background: var(--badge-salary-bg); color: var(--badge-salary-fg); }
/* --- Status flags that borrow a type's colour for semantic consistency.
The Pending tab's "Loan" worker flag (Has-an-active-loan-or-advance),
@@ -2075,6 +2083,7 @@ body, .card, .modal-content, .form-control, .form-select,
.adj-group-header[data-type="Loan Repayment"] { border-left: 4px solid var(--badge-loan-rep-bg); }
.adj-group-header[data-type="Advance Payment"] { border-left: 4px solid var(--badge-advance-bg); }
.adj-group-header[data-type="Advance Repayment"] { border-left: 4px solid var(--badge-advance-rep-bg); }
+.adj-group-header[data-type="Salary"] { border-left: 4px solid var(--badge-salary-bg); }
/* --- Chevron rotates to indicate collapsed / expanded state.
Bootstrap sets aria-expanded="false" on the toggle when collapsed;