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,11 +114,44 @@
|
|||||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||||
<div class="card shadow-sm border-0 h-100">
|
<div class="card shadow-sm border-0 h-100">
|
||||||
<div class="card-header py-3 bg-white">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
{# --- Overall view (default): the existing line chart --- #}
|
||||||
|
<div id="monthlyOverallView">
|
||||||
<canvas id="monthlyChart" height="200"></canvas>
|
<canvas id="monthlyChart" height="200"></canvas>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
@ -641,6 +674,7 @@
|
|||||||
{{ chart_labels_json|json_script:"chartLabelsJson" }}
|
{{ chart_labels_json|json_script:"chartLabelsJson" }}
|
||||||
{{ chart_totals_json|json_script:"chartTotalsJson" }}
|
{{ chart_totals_json|json_script:"chartTotalsJson" }}
|
||||||
{{ project_chart_json|json_script:"projectChartJson" }}
|
{{ project_chart_json|json_script:"projectChartJson" }}
|
||||||
|
{{ worker_chart_json|json_script:"workerChartJson" }}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@ -652,6 +686,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const chartLabels = JSON.parse(document.getElementById('chartLabelsJson').textContent);
|
const chartLabels = JSON.parse(document.getElementById('chartLabelsJson').textContent);
|
||||||
const chartTotals = JSON.parse(document.getElementById('chartTotalsJson').textContent);
|
const chartTotals = JSON.parse(document.getElementById('chartTotalsJson').textContent);
|
||||||
const projectChartData = JSON.parse(document.getElementById('projectChartJson').textContent);
|
const projectChartData = JSON.parse(document.getElementById('projectChartJson').textContent);
|
||||||
|
const workerChartData = JSON.parse(document.getElementById('workerChartJson').textContent);
|
||||||
|
|
||||||
// === HELPER: Format currency ===
|
// === HELPER: Format currency ===
|
||||||
function fmt(val) {
|
function fmt(val) {
|
||||||
@ -768,6 +803,204 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
console.warn('Project chart failed to render:', e);
|
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)
|
// OVERTIME MODAL — Build table rows using DOM methods (no innerHTML)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
@ -404,9 +404,14 @@
|
|||||||
tdProj.appendChild(strong);
|
tdProj.appendChild(strong);
|
||||||
tr.appendChild(tdProj);
|
tr.appendChild(tdProj);
|
||||||
|
|
||||||
// Workers
|
// Workers — each name gets a small pill badge for readability
|
||||||
var tdWork = document.createElement('td');
|
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);
|
tr.appendChild(tdWork);
|
||||||
|
|
||||||
// Supervisor
|
// Supervisor
|
||||||
@ -603,12 +608,12 @@
|
|||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{# When filtering by a specific worker, show only that worker. Otherwise show all workers. #}
|
{# When filtering by a specific worker, show only that worker. Otherwise show all workers. #}
|
||||||
{% if filtered_worker_obj %}
|
{% 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 %}
|
{% else %}
|
||||||
{% for w in log.workers.all %}
|
{% 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 %}
|
{% endfor %}
|
||||||
<span class="badge bg-secondary ms-1">{{ log.workers.count }}</span>
|
<span class="badge rounded-pill bg-secondary">{{ log.workers.count }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
|
|||||||
@ -900,6 +900,98 @@ def payroll_dashboard(request):
|
|||||||
'data': monthly_data,
|
'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 ---
|
# --- Loans ---
|
||||||
loan_filter = request.GET.get('loan_status', 'active')
|
loan_filter = request.GET.get('loan_status', 'active')
|
||||||
if loan_filter == 'history':
|
if loan_filter == 'history':
|
||||||
@ -948,6 +1040,7 @@ def payroll_dashboard(request):
|
|||||||
'chart_labels_json': chart_labels,
|
'chart_labels_json': chart_labels,
|
||||||
'chart_totals_json': chart_totals,
|
'chart_totals_json': chart_totals,
|
||||||
'project_chart_json': project_chart_data,
|
'project_chart_json': project_chart_data,
|
||||||
|
'worker_chart_json': worker_chart_data,
|
||||||
'overtime_data_json': all_ot_data,
|
'overtime_data_json': all_ot_data,
|
||||||
'today': today, # For pre-filling date fields in modals
|
'today': today, # For pre-filling date fields in modals
|
||||||
'active_loans_count': active_loans_count,
|
'active_loans_count': active_loans_count,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user