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:
parent
5720ca95ad
commit
9276e588a0
156
core/templates/core/work_log_payroll.html
Normal file
156
core/templates/core/work_log_payroll.html
Normal 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 %}
|
||||
@ -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)
|
||||
|
||||
@ -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 ===
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user