diff --git a/core/templates/core/work_log_payroll.html b/core/templates/core/work_log_payroll.html new file mode 100644 index 0000000..602cac8 --- /dev/null +++ b/core/templates/core/work_log_payroll.html @@ -0,0 +1,156 @@ +{# === WORK LOG PAYROLL — FULL PAGE === #} +{# Shareable, bookmark-able view for one work log's payroll status. #} +{# Same data source as the modal; different presentation. #} + +{% extends "base.html" %} +{% load format_tags %} + +{% block title %}Work Log {{ log.date|date:"d M Y" }} | FoxFitt{% endblock %} + +{% block content %} +
+ + {# --- Breadcrumb --- #} + + + {# --- Page header --- #} +
+
+

Work Log Payroll

+

Who was paid for this day's work and who is still outstanding.

+
+ + Back to history + +
+ + {# --- Attendance card --- #} +
+
+
Attendance
+
+
+
Workers present: {{ worker_rows|length }}
+
Overtime hours: {{ log.overtime_amount|default:0 }}
+
+
+
Supervisor: + {% if log.supervisor %}{{ log.supervisor.get_full_name|default:log.supervisor.username }}{% else %}—{% endif %} +
+ {% if pay_period.0 %} +
Pay period: {{ pay_period.0|date:"d M" }} – {{ pay_period.1|date:"d M Y" }}
+ {% else %} +
Pay period: + no schedule + {% if log.team %}configure{% endif %} +
+ {% endif %} +
+
+
+
+ + {# --- Unpriced OT banner --- #} + {% if overtime_needs_pricing %} +
+ + Overtime on this log hasn't been priced yet. + Price now. +
+ {% endif %} + + {# --- Workers table --- #} +
+
+
Workers on this log
+
+ + + + + + + + + + + + {% for row in worker_rows %} + + + + + + + + {% endfor %} + +
WorkerStatusEarnedPayslipPaid on
+ + {{ row.worker.name }} + + {% if not row.worker.active %}Inactive{% endif %} + + {% if row.status == 'Paid' %} + Paid + {% elif row.status == 'Priced, not paid' %} + Priced, not paid + {% else %} + Unpaid + {% endif %} + R {{ row.earned|money }} + {% if row.payroll_record %} + #{{ row.payroll_record.pk }} + {% else %}—{% endif %} + {{ row.paid_date|date:"d M Y"|default:"—" }}
+
+
+
+ + {# --- Adjustments card (only when present) --- #} + {% if adjustments %} +
+
+
Adjustments on this log
+
+ + + + {% for adj in adjustments %} + + + + + + + {% endfor %} + +
TypeWorkerAmountPayslip
{{ adj.type }}{{ adj.worker.name }}R {{ adj.amount|money }} + {% if adj.payroll_record %} + #{{ adj.payroll_record.pk }} + {% else %}unpaid{% endif %} +
+
+
+
+ {% endif %} + + {# --- Totals footer --- #} +
+
Total earned: R {{ total_earned|money }}
+
Paid: R {{ total_paid|money }}
+
Outstanding: R {{ total_outstanding|money }}
+
+ +
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 07b6c28..c3619d1 100644 --- a/core/tests.py +++ b/core/tests.py @@ -213,3 +213,42 @@ class WorkLogPayrollAjaxTests(TestCase): self.client.login(username='admin', password='pass') resp = self.client.get('/history/99999/payroll/ajax/') self.assertEqual(resp.status_code, 404) + + +# === TESTS FOR THE WORK LOG PAYROLL FULL-PAGE VIEW === +# These cover the HTML page at /history// that shares the same context +# builder as the AJAX endpoint. Admin sees a 200 HTML page; supervisor 403. + +class WorkLogPayrollDetailTests(TestCase): + """Tests for the full-page /history// view.""" + + def setUp(self): + self.admin = User.objects.create_user( + username='admin', password='pass', is_staff=True + ) + self.supervisor = User.objects.create_user( + username='sup', password='pass', is_staff=False + ) + project = Project.objects.create(name='P2') + team = Team.objects.create(name='T2', supervisor=self.admin) + worker = Worker.objects.create(name='Wanda', id_number='X', monthly_salary=Decimal('4000')) + self.log = WorkLog.objects.create( + date=datetime.date(2026, 4, 10), + project=project, team=team, supervisor=self.admin, + ) + self.log.workers.add(worker) + + def test_admin_gets_full_page(self): + self.client.login(username='admin', password='pass') + url = reverse('work_log_payroll_detail', args=[self.log.id]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'FoxFitt') + self.assertContains(resp, 'History') + self.assertContains(resp, 'Wanda') + + def test_supervisor_forbidden(self): + self.client.login(username='sup', password='pass') + url = reverse('work_log_payroll_detail', args=[self.log.id]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 403) diff --git a/core/views.py b/core/views.py index b25deee..e6bab95 100644 --- a/core/views.py +++ b/core/views.py @@ -883,12 +883,22 @@ def work_log_payroll_ajax(request, log_id): @login_required def work_log_payroll_detail(request, log_id): - """Render the full payroll-status page for a single work log.""" - # Stub — implemented in Task 4 + """Full-page payroll-status view for a single work log. Admin-only. + + Shares the exact same context builder as the AJAX endpoint, so the + full page and the modal can never drift out of sync. + """ + # Admin-only: this page shows salary-level data. if not is_admin(request.user): return HttpResponseForbidden("Admin access required.") - log = get_object_or_404(WorkLog, id=log_id) - return HttpResponse(f"

stub for log {log.id}

") + + # Fetch the log with related objects pre-loaded to avoid extra queries. + log = get_object_or_404( + WorkLog.objects.select_related('project', 'team', 'supervisor'), + id=log_id, + ) + context = _build_work_log_payroll_context(log) + return render(request, 'core/work_log_payroll.html', context) # === CSV EXPORT ===