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:
parent
4791ef8192
commit
b7baf88cfc
@ -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)
|
||||
// =================================================================
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user