Add overdue badges and filters to pending payments table

- Red 'Overdue' badge on workers with unpaid work from completed pay periods
- Yellow 'Loan' badge on workers with active loans/advances
- Filter bar above table: team dropdown, overdue-only toggle, exclude loans
- All three filters combine (team + overdue + loan) for flexible views
- Overdue detection uses team pay schedule cutoff from get_pay_period()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-24 22:53:47 +02:00
parent 695b7cb3f1
commit 1b6ade87af
2 changed files with 80 additions and 2 deletions

View File

@ -216,10 +216,33 @@
{# === PENDING PAYMENTS TAB === #}
{# =============================================== #}
{% if active_tab == 'pending' %}
{# === PENDING PAYMENTS FILTER BAR === #}
{# Lets admin filter by team, show only overdue workers, or exclude workers with loans #}
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap" id="pendingFilters">
<div class="d-flex align-items-center gap-2">
<label class="text-muted small mb-0" for="pendingTeamFilter">Team:</label>
<select id="pendingTeamFilter" class="form-select form-select-sm" style="width: auto;">
<option value="">All Teams</option>
{% for team in all_teams %}
<option value="{{ team.name }}">{{ team.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="pendingOverdueOnly">
<label class="form-check-label text-muted small" for="pendingOverdueOnly">Overdue only</label>
</div>
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="pendingExcludeLoans">
<label class="form-check-label text-muted small" for="pendingExcludeLoans">Exclude workers with loans</label>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<table class="table table-hover mb-0" id="pendingTable">
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
@ -234,9 +257,17 @@
</thead>
<tbody>
{% for wd in workers_data %}
<tr>
<tr data-team="{{ wd.team_name }}"
data-overdue="{{ wd.is_overdue|yesno:'true,false' }}"
data-has-loan="{{ wd.has_loan|yesno:'true,false' }}">
<td class="ps-4 align-middle">
<strong>{{ wd.worker.name }}</strong>
{% if wd.is_overdue %}
<span class="badge bg-danger ms-1" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
{% endif %}
{% if wd.has_loan %}
<span class="badge bg-warning text-dark ms-1" title="Has active loan or advance">Loan</span>
{% endif %}
</td>
<td class="align-middle">{{ wd.unpaid_count }}</td>
<td class="align-middle">R {{ wd.day_rate }}</td>
@ -764,6 +795,34 @@ document.addEventListener('DOMContentLoaded', function() {
return td;
}
// =================================================================
// PENDING PAYMENTS TABLE — Team / Overdue / Loan Filters
// Shows/hides rows based on filter selections. Pure client-side.
// =================================================================
var pendingTable = document.getElementById('pendingTable');
var pendingTeamFilter = document.getElementById('pendingTeamFilter');
var pendingOverdueOnly = document.getElementById('pendingOverdueOnly');
var pendingExcludeLoans = document.getElementById('pendingExcludeLoans');
if (pendingTable && pendingTeamFilter) {
function applyPendingFilters() {
var team = pendingTeamFilter.value;
var overdueOnly = pendingOverdueOnly ? pendingOverdueOnly.checked : false;
var excludeLoans = pendingExcludeLoans ? pendingExcludeLoans.checked : false;
var rows = pendingTable.querySelectorAll('tbody tr[data-team]');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var teamMatch = !team || row.dataset.team === team;
var overdueMatch = !overdueOnly || row.dataset.overdue === 'true';
var loanMatch = !excludeLoans || row.dataset.hasLoan !== 'true';
row.style.display = (teamMatch && overdueMatch && loanMatch) ? '' : 'none';
}
}
pendingTeamFilter.addEventListener('change', applyPendingFilters);
if (pendingOverdueOnly) pendingOverdueOnly.addEventListener('change', applyPendingFilters);
if (pendingExcludeLoans) pendingExcludeLoans.addEventListener('change', applyPendingFilters);
}
// =================================================================
// CHART.JS — Monthly Totals (Line Chart)
// Wrapped in try-catch so a Chart.js failure doesn't prevent

View File

@ -906,6 +906,21 @@ def payroll_dashboard(request):
# Only include workers who have something pending
if log_count > 0 or pending_adjs:
# --- Overdue detection ---
# A worker is "overdue" if they have unpaid work from a completed pay period.
# Uses their team's pay schedule to determine the cutoff date.
team = get_worker_active_team(worker)
team_name = team.name if team else ''
earliest_unpaid = min((l.date for l in unpaid_logs), default=None) if unpaid_logs else None
is_overdue = False
if earliest_unpaid and team and team.pay_frequency and team.pay_start_date:
period_start, period_end = get_pay_period(team)
if period_start:
cutoff = period_start - datetime.timedelta(days=1)
is_overdue = earliest_unpaid <= cutoff
has_loan = Loan.objects.filter(worker=worker, active=True).exists()
workers_data.append({
'worker': worker,
'unpaid_count': log_count,
@ -916,6 +931,10 @@ def payroll_dashboard(request):
'logs': unpaid_logs,
'ot_data': ot_data_worker,
'day_rate': float(worker.daily_rate),
'team_name': team_name,
'is_overdue': is_overdue,
'has_loan': has_loan,
'earliest_unpaid': earliest_unpaid,
})
outstanding_total += max(total_payable, Decimal('0.00'))
unpaid_wages_total += log_amount