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 @@
PAYMENT TO BENEFICIARY
{{ record.worker.name }}
-
{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% elif is_salary %}Salary Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
@@ -58,6 +58,11 @@ Loan Payment: {{ loan_description }} R {{ loan_amount|floatformat:2 }} + {% elif is_salary %} + + Salary: {{ salary_description }} + R {{ salary_amount|floatformat:2 }} + {% else %} diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 9bc9e3c..4f16c6b 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -41,6 +41,13 @@ + {# Pay Salary — opens the SAME Add-Adjustment modal pre-set to #} + {# type=Salary (project field + Pay-Immediately checkbox shown). #} + {# Discoverable shortcut for paying a manager / salaried worker. #} + @@ -1099,10 +1106,10 @@ - {# Pay Immediately — only shown for New Loan type #} - {# When checked, the loan is paid to the worker right away and #} - {# a payslip is emailed to Spark. When unchecked, the loan sits #} - {# in pending payments and gets included in the next pay cycle. #} + {# Pay Immediately — shown for New Loan AND Salary types. #} + {# When checked, the amount is paid to the worker right away #} + {# and a payslip is emailed to Spark. When unchecked, it sits #} + {# in pending payments and is included in the next pay cycle. #}

- {% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip + {% if is_advance %}Advance{% elif is_loan %}Loan{% elif is_salary %}Salary{% endif %} Payslip

{{ record.date|date:"F j, Y" }}
Payer: Fox Fitt
@@ -127,6 +127,42 @@
+ {% elif is_salary %} + {# === SALARY PAYMENT DETAIL === #} + {# Manager / fixed-salary monthly pay. Clean single-line #} + {# layout — no empty work-log table, no R 0.00 base-pay row. #} +
Salary Details
+
+ + + + + + + + + + + + + + + + + +
DateTypeDescriptionAmount
{{ salary_adj.date|date:"M d, Y" }}SALARY{{ salary_adj.description|default:"Monthly salary" }}R {{ salary_adj.amount|floatformat:2 }}
+
+
+
+ + + + + +
Salary Amount:R {{ salary_adj.amount|floatformat:2 }}
+
+
+ {% else %}
Work Log Details (Attendance)
diff --git a/core/templates/core/pdf/payslip_pdf.html b/core/templates/core/pdf/payslip_pdf.html index 717a32c..c352a23 100644 --- a/core/templates/core/pdf/payslip_pdf.html +++ b/core/templates/core/pdf/payslip_pdf.html @@ -107,7 +107,7 @@
PAYMENT TO BENEFICIARY
{{ record.worker.name }}
-
{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% elif is_salary %}Salary Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
@@ -138,6 +138,12 @@ Loan Payment: {{ loan_description }} R {{ loan_amount|floatformat:2 }} + {% elif is_salary %} + + + Salary: {{ salary_description }} + R {{ salary_amount|floatformat:2 }} + {% else %} 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 @@
Personal & Pay
+
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. #} +
+ + {{ form.pay_type }} + {% if form.pay_type.errors %}
{{ form.pay_type.errors|first }}
{% endif %} +
{{ 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;