Add worker name pills on history page + per-worker payroll chart

Work History:
- Worker names now display as rounded pill badges instead of comma-
  separated text, making them easier to scan (both server-rendered
  list view and JS calendar detail view)

Payroll Dashboard:
- New "By Worker" toggle on the Monthly Payroll chart card
- Dropdown to select an active worker with payment history
- Stacked bar chart shows monthly breakdown: base pay, overtime,
  bonuses (positive), deductions, loan repayments, advances (negative)
- All data pre-computed server-side with 2 aggregate queries and
  embedded as JSON — switching workers is instant, no AJAX needed
- Only workers with actual payment history appear in the dropdown
- Legend items auto-hide when a component has no data for that worker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-24 19:21:56 +02:00
parent 4791ef8192
commit b7baf88cfc
3 changed files with 338 additions and 7 deletions

View File

@ -114,10 +114,43 @@
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Monthly Payroll Totals</h6>
{# === CHART TOGGLE: Overall vs By Worker === #}
{# Two small buttons to switch between the total line chart #}
{# and a per-worker stacked bar chart breakdown. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold" style="color: var(--primary-dark);">Monthly Payroll</h6>
<div class="btn-group btn-group-sm" role="group" aria-label="Chart view toggle">
<button type="button" class="btn btn-sm btn-accent" id="btnOverall">
<i class="fas fa-chart-line fa-sm me-1"></i>Overall
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnByWorker">
<i class="fas fa-user fa-sm me-1"></i>By Worker
</button>
</div>
</div>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="200"></canvas>
{# --- Overall view (default): the existing line chart --- #}
<div id="monthlyOverallView">
<canvas id="monthlyChart" height="200"></canvas>
</div>
{# --- By Worker view (hidden): worker dropdown + stacked bar --- #}
<div id="monthlyWorkerView" style="display: none;">
<div class="mb-3">
<select id="workerChartSelect" class="form-select form-select-sm">
<option value="">-- Select Worker --</option>
</select>
</div>
<div id="workerChartContainer">
<canvas id="workerChart" height="200"></canvas>
</div>
{# Placeholder shown when no worker is selected #}
<div id="workerChartPlaceholder" class="text-center py-4 text-muted">
<i class="fas fa-chart-bar fa-2x mb-2 d-block opacity-50"></i>
<span>Select a worker to see their payment breakdown</span>
</div>
</div>
</div>
</div>
</div>
@ -641,6 +674,7 @@
{{ chart_labels_json|json_script:"chartLabelsJson" }}
{{ chart_totals_json|json_script:"chartTotalsJson" }}
{{ project_chart_json|json_script:"projectChartJson" }}
{{ worker_chart_json|json_script:"workerChartJson" }}
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -652,6 +686,7 @@ document.addEventListener('DOMContentLoaded', function() {
const chartLabels = JSON.parse(document.getElementById('chartLabelsJson').textContent);
const chartTotals = JSON.parse(document.getElementById('chartTotalsJson').textContent);
const projectChartData = JSON.parse(document.getElementById('projectChartJson').textContent);
const workerChartData = JSON.parse(document.getElementById('workerChartJson').textContent);
// === HELPER: Format currency ===
function fmt(val) {
@ -768,6 +803,204 @@ document.addEventListener('DOMContentLoaded', function() {
console.warn('Project chart failed to render:', e);
}
// =================================================================
// CHART TOGGLE — Switch between "Overall" line chart and
// "By Worker" stacked bar chart in the Monthly Payroll card.
// =================================================================
var btnOverall = document.getElementById('btnOverall');
var btnByWorker = document.getElementById('btnByWorker');
var monthlyOverallView = document.getElementById('monthlyOverallView');
var monthlyWorkerView = document.getElementById('monthlyWorkerView');
if (btnOverall && btnByWorker) {
// Switch to "Overall" — show the line chart, hide the worker view
btnOverall.addEventListener('click', function() {
monthlyOverallView.style.display = '';
monthlyWorkerView.style.display = 'none';
btnOverall.className = 'btn btn-sm btn-accent';
btnByWorker.className = 'btn btn-sm btn-outline-secondary';
});
// Switch to "By Worker" — hide line chart, show worker dropdown + bar chart
btnByWorker.addEventListener('click', function() {
monthlyOverallView.style.display = 'none';
monthlyWorkerView.style.display = '';
btnByWorker.className = 'btn btn-sm btn-accent';
btnOverall.className = 'btn btn-sm btn-outline-secondary';
});
}
// =================================================================
// CHART.JS — Per-Worker Breakdown (Stacked Bar Chart)
// Shows base pay, overtime, bonuses as positive bars going up,
// and deductions, loan repayments, advance payments going down.
// =================================================================
var workerChartSelect = document.getElementById('workerChartSelect');
var workerChartContainer = document.getElementById('workerChartContainer');
var workerChartPlaceholder = document.getElementById('workerChartPlaceholder');
var workerChartInstance = null; // Track so we can destroy before re-creating
if (workerChartSelect) {
// Hide the chart canvas initially (show placeholder instead)
if (workerChartContainer) workerChartContainer.style.display = 'none';
// Populate the worker dropdown from pre-computed data
// (already sorted alphabetically by the server)
Object.keys(workerChartData).forEach(function(workerId) {
var opt = document.createElement('option');
opt.value = workerId;
opt.textContent = workerChartData[workerId].name;
workerChartSelect.appendChild(opt);
});
// When a worker is selected, render their stacked bar chart
workerChartSelect.addEventListener('change', function() {
var workerId = this.value;
var canvasEl = document.getElementById('workerChart');
// No worker selected — show placeholder, hide chart
if (!workerId) {
if (workerChartPlaceholder) workerChartPlaceholder.style.display = '';
if (workerChartContainer) workerChartContainer.style.display = 'none';
if (workerChartInstance) {
workerChartInstance.destroy();
workerChartInstance = null;
}
return;
}
// Worker selected — hide placeholder, show chart canvas
if (workerChartPlaceholder) workerChartPlaceholder.style.display = 'none';
if (workerChartContainer) workerChartContainer.style.display = '';
// Destroy the previous chart before creating a new one
if (workerChartInstance) {
workerChartInstance.destroy();
workerChartInstance = null;
}
var data = workerChartData[workerId];
if (!data) return;
// Pull out monthly values into separate arrays (one per component)
var basePay = data.months.map(function(m) { return m.base; });
var overtime = data.months.map(function(m) { return m.overtime; });
var bonus = data.months.map(function(m) { return m.bonus; });
var newLoan = data.months.map(function(m) { return m.new_loan; });
// Deductive items shown as negative values (bars go below zero)
var deduction = data.months.map(function(m) { return -m.deduction; });
var loanRepayment = data.months.map(function(m) { return -m.loan_repayment; });
var advance = data.months.map(function(m) { return -m.advance; });
// Build datasets — only include types that have non-zero data
// so the legend doesn't show irrelevant items
var datasets = [];
if (basePay.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Base Pay',
data: basePay,
backgroundColor: '#10b981',
stack: 'positive',
});
}
if (overtime.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Overtime',
data: overtime,
backgroundColor: '#f59e0b',
stack: 'positive',
});
}
if (bonus.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Bonus',
data: bonus,
backgroundColor: '#3b82f6',
stack: 'positive',
});
}
if (newLoan.some(function(v) { return v > 0; })) {
datasets.push({
label: 'New Loan',
data: newLoan,
backgroundColor: '#8b5cf6',
stack: 'positive',
});
}
if (deduction.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Deductions',
data: deduction,
backgroundColor: '#ef4444',
stack: 'negative',
});
}
if (loanRepayment.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Loan Repayments',
data: loanRepayment,
backgroundColor: '#991b1b',
stack: 'negative',
});
}
if (advance.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Advance Payments',
data: advance,
backgroundColor: '#7c3aed',
stack: 'negative',
});
}
try {
workerChartInstance = new Chart(canvasEl, {
type: 'bar',
data: {
labels: chartLabels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
var val = context.parsed.y;
var prefix = val >= 0 ? '+' : '';
return context.dataset.label + ': ' + prefix + fmt(val);
}
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 12,
padding: 8,
font: { size: 11 }
}
}
},
scales: {
x: { stacked: true },
y: {
stacked: true,
ticks: {
callback: function(val) { return 'R ' + val.toLocaleString(); }
}
}
}
}
});
} catch (e) {
console.warn('Worker chart failed to render:', e);
}
});
}
// =================================================================
// OVERTIME MODAL — Build table rows using DOM methods (no innerHTML)
// =================================================================

View File

@ -404,9 +404,14 @@
tdProj.appendChild(strong);
tr.appendChild(tdProj);
// Workers
// Workers — each name gets a small pill badge for readability
var tdWork = document.createElement('td');
tdWork.textContent = entry.workers.join(', ');
entry.workers.forEach(function(name) {
var pill = document.createElement('span');
pill.className = 'badge rounded-pill bg-light text-dark fw-normal border me-1 mb-1';
pill.textContent = name;
tdWork.appendChild(pill);
});
tr.appendChild(tdWork);
// Supervisor
@ -603,12 +608,12 @@
<td class="align-middle">
{# When filtering by a specific worker, show only that worker. Otherwise show all workers. #}
{% if filtered_worker_obj %}
{{ filtered_worker_obj.name }}
<span class="badge rounded-pill bg-light text-dark fw-normal border">{{ filtered_worker_obj.name }}</span>
{% else %}
{% for w in log.workers.all %}
{{ w.name }}{% if not forloop.last %}, {% endif %}
<span class="badge rounded-pill bg-light text-dark fw-normal border me-1 mb-1">{{ w.name }}</span>
{% endfor %}
<span class="badge bg-secondary ms-1">{{ log.workers.count }}</span>
<span class="badge rounded-pill bg-secondary">{{ log.workers.count }}</span>
{% endif %}
</td>
<td class="align-middle">

View File

@ -900,6 +900,98 @@ def payroll_dashboard(request):
'data': monthly_data,
})
# === CHART DATA: Per-Worker Monthly Breakdown ===
# Pre-compute payment breakdown for each active worker over the last 6 months.
# This powers the "By Worker" toggle on the Monthly Payroll Totals chart.
# Only ~14 workers x 6 months = tiny dataset, so we embed it all as JSON
# and switching between workers is instant (no server round-trips).
# Starting date for the 6-month window (first day of the oldest chart month)
six_months_ago_date = datetime.date(chart_months[0][0], chart_months[0][1], 1)
# Query 1: Total amount paid per worker per month.
# Uses database-level grouping — one query for ALL workers at once.
worker_monthly_paid_qs = PayrollRecord.objects.filter(
worker__active=True,
date__gte=six_months_ago_date,
).values(
'worker_id',
month=TruncMonth('date'),
).annotate(total=Sum('amount_paid'))
# Build a fast lookup dict: {(worker_id, year, month): total_paid}
worker_paid_lookup = {}
for row in worker_monthly_paid_qs:
key = (row['worker_id'], row['month'].year, row['month'].month)
worker_paid_lookup[key] = float(row['total'])
# Query 2: Paid adjustment totals grouped by worker, type, and month.
# "Paid" means the adjustment has a linked PayrollRecord.
# We group by the PayrollRecord's date (not the adjustment date)
# so it lines up with when the payment actually happened.
worker_monthly_adj_qs = PayrollAdjustment.objects.filter(
payroll_record__isnull=False,
worker__active=True,
payroll_record__date__gte=six_months_ago_date,
).values(
'worker_id',
'type',
month=TruncMonth('payroll_record__date'),
).annotate(total=Sum('amount'))
# Build a fast lookup dict: {(worker_id, year, month, type): total_amount}
worker_adj_lookup = {}
for row in worker_monthly_adj_qs:
key = (row['worker_id'], row['month'].year, row['month'].month, row['type'])
worker_adj_lookup[key] = float(row['total'])
# Build the final data structure for JavaScript.
# For each worker with payment history, create 6 monthly entries showing
# how their pay breaks down into base pay, overtime, bonuses, etc.
#
# Base pay is reverse-engineered from the net total:
# amount_paid = base + overtime + bonus + new_loan - deduction - loan_repayment - advance
# So: base = amount_paid - overtime - bonus - new_loan + deduction + loan_repayment + advance
worker_chart_data = {}
for worker in Worker.objects.filter(active=True).order_by('name'):
months_data = []
has_any_data = False
for y, m in chart_months:
total_paid = worker_paid_lookup.get((worker.id, y, m), 0)
overtime = worker_adj_lookup.get((worker.id, y, m, 'Overtime'), 0)
bonus = worker_adj_lookup.get((worker.id, y, m, 'Bonus'), 0)
new_loan = worker_adj_lookup.get((worker.id, y, m, 'New Loan'), 0)
deduction = worker_adj_lookup.get((worker.id, y, m, 'Deduction'), 0)
loan_repayment = worker_adj_lookup.get((worker.id, y, m, 'Loan Repayment'), 0)
advance = worker_adj_lookup.get((worker.id, y, m, 'Advance Payment'), 0)
# Reverse-engineer base pay from the net total
base_pay = total_paid - overtime - bonus - new_loan + deduction + loan_repayment + advance
# Clamp to zero — a negative base can happen if adjustments exceed day-rate earnings
base_pay = max(base_pay, 0)
if total_paid > 0:
has_any_data = True
months_data.append({
'base': round(base_pay, 2),
'overtime': round(overtime, 2),
'bonus': round(bonus, 2),
'new_loan': round(new_loan, 2),
'deduction': round(deduction, 2),
'loan_repayment': round(loan_repayment, 2),
'advance': round(advance, 2),
'total': round(total_paid, 2),
})
# Only include workers who actually received at least one payment
if has_any_data:
worker_chart_data[str(worker.id)] = {
'name': worker.name,
'months': months_data,
}
# --- Loans ---
loan_filter = request.GET.get('loan_status', 'active')
if loan_filter == 'history':
@ -948,6 +1040,7 @@ def payroll_dashboard(request):
'chart_labels_json': chart_labels,
'chart_totals_json': chart_totals,
'project_chart_json': project_chart_data,
'worker_chart_json': worker_chart_data,
'overtime_data_json': all_ot_data,
'today': today, # For pre-filling date fields in modals
'active_loans_count': active_loans_count,