Full-page view at /history/<id>/ for work log payroll status

Extends base.html; breadcrumb, attendance card, workers table,
adjustments card (conditional), totals. Pay-period uses
get_pay_period() and falls back to 'no schedule' + configure link.
2 view-level tests: admin 200, supervisor 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 15:26:59 +02:00
parent 5720ca95ad
commit 9276e588a0
3 changed files with 209 additions and 4 deletions

View File

@ -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 %}
<div class="container py-4" style="max-width: 960px;">
{# --- Breadcrumb --- #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small mb-0">
<li class="breadcrumb-item"><a href="{% url 'work_history' %}" class="text-decoration-none">History</a></li>
<li class="breadcrumb-item active">
{{ log.date|date:"d M Y" }}
{% if log.project %} · <a href="{% url 'project_detail' log.project.id %}" class="text-decoration-none">{{ log.project.name }}</a>{% endif %}
{% if log.team %} · <a href="{% url 'team_detail' log.team.id %}" class="text-decoration-none">{{ log.team.name }}</a>{% endif %}
</li>
</ol>
</nav>
{# --- Page header --- #}
<div class="d-flex align-items-start justify-content-between mb-4">
<div>
<h3 class="mb-1"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h3>
<p class="text-muted mb-0 small">Who was paid for this day's work and who is still outstanding.</p>
</div>
<a href="{% url 'work_history' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to history
</a>
</div>
{# --- Attendance card --- #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Attendance</h6>
<div class="row g-3 small">
<div class="col-md-6">
<div><span class="text-muted">Workers present:</span> <strong>{{ worker_rows|length }}</strong></div>
<div><span class="text-muted">Overtime hours:</span> <strong>{{ log.overtime_amount|default:0 }}</strong></div>
</div>
<div class="col-md-6">
<div><span class="text-muted">Supervisor:</span> <strong>
{% if log.supervisor %}{{ log.supervisor.get_full_name|default:log.supervisor.username }}{% else %}—{% endif %}
</strong></div>
{% if pay_period.0 %}
<div><span class="text-muted">Pay period:</span> <strong>{{ pay_period.0|date:"d M" }} {{ pay_period.1|date:"d M Y" }}</strong></div>
{% else %}
<div><span class="text-muted">Pay period:</span>
<span class="text-muted fst-italic">no schedule</span>
{% if log.team %}<a href="{% url 'team_edit' log.team.id %}" class="small ms-1">configure</a>{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# --- Unpriced OT banner --- #}
{% if overtime_needs_pricing %}
<div class="alert alert-warning py-2 px-3 mb-3 small">
<i class="fas fa-triangle-exclamation me-1"></i>
Overtime on this log hasn't been priced yet.
<a href="{% url 'payroll_dashboard' %}" class="alert-link">Price now</a>.
</div>
{% endif %}
{# --- Workers table --- #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Workers on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th class="text-end">Earned</th>
<th>Payslip</th>
<th>Paid on</th>
</tr>
</thead>
<tbody>
{% for row in worker_rows %}
<tr>
<td>
<a href="{% url 'worker_detail' row.worker.id %}"
class="text-decoration-none {% if not row.worker.active %}text-decoration-line-through{% endif %}">
{{ row.worker.name }}
</a>
{% if not row.worker.active %}<span class="badge bg-secondary ms-1">Inactive</span>{% endif %}
</td>
<td>
{% if row.status == 'Paid' %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Paid</span>
{% elif row.status == 'Priced, not paid' %}
<span class="badge bg-info text-dark">Priced, not paid</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="fas fa-clock me-1"></i>Unpaid</span>
{% endif %}
</td>
<td class="text-end">R {{ row.earned|money }}</td>
<td>
{% if row.payroll_record %}
<a href="{% url 'payslip_detail' row.payroll_record.pk %}" class="text-decoration-none">#{{ row.payroll_record.pk }}</a>
{% else %}—{% endif %}
</td>
<td>{{ row.paid_date|date:"d M Y"|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# --- Adjustments card (only when present) --- #}
{% if adjustments %}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Adjustments on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Type</th><th>Worker</th><th class="text-end">Amount</th><th>Payslip</th></tr></thead>
<tbody>
{% for adj in adjustments %}
<tr>
<td>{{ adj.type }}</td>
<td><a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">{{ adj.worker.name }}</a></td>
<td class="text-end">R {{ adj.amount|money }}</td>
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.pk %}" class="text-decoration-none">#{{ adj.payroll_record.pk }}</a>
{% else %}<span class="text-muted">unpaid</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# --- Totals footer --- #}
<div class="d-flex gap-4 pt-2 small">
<div><span class="text-muted">Total earned:</span> <strong>R {{ total_earned|money }}</strong></div>
<div><span class="text-muted">Paid:</span> <strong>R {{ total_paid|money }}</strong></div>
<div><span class="text-muted">Outstanding:</span> <strong>R {{ total_outstanding|money }}</strong></div>
</div>
</div>
{% endblock %}

View File

@ -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/<id>/ 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/<id>/ 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)

View File

@ -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"<p>stub for log {log.id}</p>")
# 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 ===