Add Worker Lookup modal to payroll dashboard

New AJAX endpoint (worker_lookup_ajax) returns a comprehensive financial
report card for any active worker. Modal shows: amount payable, outstanding
loans, paid this month/year, loans this year, recent activity, active loans
table, current project + days, PPE sizing, drivers license, and notes.
Worker names across all dashboard tabs are now clickable links that open
the modal. Header button with searchable dropdown for quick access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-20 14:15:04 +02:00
parent 81009be0c6
commit 60ee21dd61
5 changed files with 618 additions and 4 deletions

View File

@ -26,7 +26,7 @@ core/ — Single main app: ALL business logic, models, views, forms,
forms.py — AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm + formset
models.py — All 10 database models
utils.py — render_to_pdf() helper (lazy xhtml2pdf import)
views.py — All 27 functions (~2470 lines, includes helpers)
views.py — All 28 functions (~2600 lines, includes helpers)
management/commands/ — setup_groups, setup_test_data, import_production_data
templates/ — base.html + 7 page templates + 2 email + 2 PDF + login
ai/ — Flatlogic AI proxy client (not used in app logic)
@ -108,6 +108,7 @@ python manage.py check # System check
- Batch Pay: "Batch Pay" button on payroll dashboard opens a modal with two radio modes — **"Until Last Paydate"** (default, splits at last completed pay period per team schedule) and **"Pay All"** (includes all unpaid items regardless of date). Preview fetches from `batch_pay_preview` with `?mode=schedule|all`. Workers without team pay schedules are skipped in schedule mode but included in Pay All mode. `batch_pay` POST endpoint processes each worker in independent atomic transactions; emails are sent after all payments complete. Uses `_process_single_payment()` shared helper (same logic as individual `process_payment`). Modal includes team filter dropdown and 3-option loan filter (All / With loans only / Without loans).
- Pending Payments Table: Shows overdue badges (red) for workers with unpaid work from completed pay periods, and loan badges (yellow) for workers with active loans/advances. Filter bar has: team dropdown, "Overdue only" checkbox, and loan dropdown (All Workers / With loans only / Without loans). Overdue detection uses `get_pay_period()` cutoff logic.
- Quick Adjust Button: Each pending payments row has an "Adjust" button (slider icon) that opens the Add Adjustment modal with that worker pre-checked and their most recent project pre-selected. The header "Add Adjustment" button resets the modal to a clean state. Uses `_quickAdjustOpen` flag to distinguish between the two open paths.
- Worker Lookup Modal: Clicking any worker name on the payroll dashboard (or using the "Worker Lookup" button) opens a modal with a comprehensive report card — amount payable, outstanding loans, paid this month/year, loans this year, recent activity (last payslip, loan, repayment, advance), active loans table, current project + days on project, PPE sizing, drivers license, and notes. Uses `worker_lookup_ajax` AJAX endpoint. Worker dropdown in modal allows switching workers without closing.
## URL Routes
| Path | View | Purpose |
@ -125,6 +126,7 @@ python manage.py check # System check
| `/payroll/adjustment/<id>/edit/` | `edit_adjustment` | Admin: edit unpaid adjustment |
| `/payroll/adjustment/<id>/delete/` | `delete_adjustment` | Admin: delete unpaid adjustment |
| `/payroll/preview/<worker_id>/` | `preview_payslip` | Admin: AJAX JSON payslip preview (includes active loans) |
| `/payroll/worker-lookup/<worker_id>/` | `worker_lookup_ajax` | Admin: AJAX JSON worker report card |
| `/payroll/repayment/<worker_id>/` | `add_repayment_ajax` | Admin: AJAX add loan/advance repayment from preview |
| `/payroll/payslip/<pk>/` | `payslip_detail` | Admin: view completed payslip |
| `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items |

View File

@ -13,6 +13,9 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-info shadow-sm" id="workerLookupBtn">
<i class="fas fa-id-card fa-sm me-1"></i> Worker Lookup
</button>
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
</button>
@ -265,7 +268,8 @@
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>
<a href="#" class="worker-lookup-link text-decoration-none text-dark fw-bold"
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
{% 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 %}
@ -362,7 +366,8 @@
{% for record in paid_records %}
<tr>
<td class="ps-4 align-middle">{{ record.date }}</td>
<td class="align-middle"><strong>{{ record.worker.name }}</strong></td>
<td class="align-middle"><a href="#" class="worker-lookup-link text-decoration-none text-dark fw-bold"
data-worker-id="{{ record.worker.id }}">{{ record.worker.name }}</a></td>
<td class="align-middle">R {{ record.amount_paid|floatformat:2 }}</td>
<td class="align-middle">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
@ -429,7 +434,8 @@
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 align-middle"><strong>{{ loan.worker.name }}</strong></td>
<td class="ps-4 align-middle"><a href="#" class="worker-lookup-link text-decoration-none text-dark fw-bold"
data-worker-id="{{ loan.worker.id }}">{{ loan.worker.name }}</a></td>
<td class="align-middle">
{% if loan.loan_type == 'advance' %}
<span class="badge bg-info text-dark">Advance</span>
@ -783,6 +789,38 @@
</div>
</div>
{# === WORKER LOOKUP MODAL === #}
{# Shows a comprehensive financial report card for any active worker. #}
{# Triggered by clicking a worker name or the "Worker Lookup" button. #}
<div class="modal fade" id="workerLookupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-id-card me-2"></i>Worker Lookup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Worker selector dropdown #}
<div class="mb-3">
<select id="workerLookupSelect" class="form-select">
<option value="">Select a worker...</option>
{% for w in all_workers %}
<option value="{{ w.id }}">{{ w.name }}</option>
{% endfor %}
</select>
</div>
{# Report card content — populated dynamically by JavaScript #}
<div id="workerLookupBody">
<div class="text-center py-5 text-muted">
<i class="fas fa-id-card fa-3x mb-3 opacity-25"></i>
<p>Select a worker to view their report card.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{# ================================================================== #}
{# === JAVASCRIPT === #}
@ -2655,6 +2693,277 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// ================================================================
// === WORKER LOOKUP ===
// Fetches a worker's financial report card via AJAX and displays
// it in the Worker Lookup modal. Can be triggered by clicking any
// worker name on the dashboard, or via the "Worker Lookup" button.
// ================================================================
var workerLookupModal = document.getElementById('workerLookupModal');
var workerLookupSelect = document.getElementById('workerLookupSelect');
var workerLookupBody = document.getElementById('workerLookupBody');
var workerLookupBtn = document.getElementById('workerLookupBtn');
// === HELPER: format a number as South African Rand ===
function formatRand(amount) {
return 'R ' + Number(amount).toLocaleString('en-ZA', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
// === HELPER: format a date string (YYYY-MM-DD) for display ===
function formatDate(dateStr) {
if (!dateStr) return '';
var parts = dateStr.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2]) + ' ' + months[parseInt(parts[1]) - 1] + ' ' + parts[0];
}
// === HELPER: create a text element safely (no innerHTML with user data) ===
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
// === LOAD WORKER DATA AND RENDER THE REPORT CARD ===
function loadWorkerLookup(workerId) {
// Show spinner while loading
workerLookupBody.textContent = '';
var spinner = el('div', 'text-center py-4');
var spinEl = el('div', 'spinner-border text-primary');
spinEl.setAttribute('role', 'status');
spinner.appendChild(spinEl);
spinner.appendChild(el('p', 'text-muted mt-2 small', 'Loading report card...'));
workerLookupBody.appendChild(spinner);
fetch('/payroll/worker-lookup/' + workerId + '/')
.then(function(resp) { return resp.json(); })
.then(function(data) {
renderWorkerLookup(data);
})
.catch(function() {
workerLookupBody.textContent = '';
workerLookupBody.appendChild(
el('div', 'alert alert-danger', 'Failed to load worker data. Please try again.')
);
});
}
// === RENDER THE FULL REPORT CARD FROM JSON DATA ===
function renderWorkerLookup(data) {
workerLookupBody.textContent = '';
// --- IDENTITY SECTION ---
var identity = el('div', 'mb-4');
identity.appendChild(el('h4', 'mb-1', data.name));
var detailLine1 = el('div', 'text-muted small');
detailLine1.appendChild(el('span', '', 'ID: ' + data.id_number));
if (data.phone) {
detailLine1.appendChild(document.createTextNode(' \u00B7 Phone: ' + data.phone));
}
identity.appendChild(detailLine1);
var detailLine2 = el('div', 'text-muted small');
if (data.employment_date) {
detailLine2.appendChild(document.createTextNode('Employed: ' + formatDate(data.employment_date)));
}
if (data.team) {
detailLine2.appendChild(document.createTextNode(' \u00B7 Team: ' + data.team));
}
identity.appendChild(detailLine2);
if (data.current_project) {
var projLine = el('div', 'text-muted small');
projLine.appendChild(document.createTextNode(
'Current Project: ' + data.current_project + ' \u00B7 ' + data.days_on_project + ' days on project'
));
identity.appendChild(projLine);
}
workerLookupBody.appendChild(identity);
// --- QUICK STATS (4 cards in a row) ---
var statsRow = el('div', 'row g-2 mb-4');
var stats = [
{ label: 'Amount Payable', value: data.amount_payable, color: '#0f172a' },
{ label: 'Outstanding Loans', value: data.outstanding_loans, color: '#f59e0b' },
{ label: 'Paid This Month', value: data.paid_this_month, color: '#10b981' },
{ label: 'Loans This Year', value: data.loans_this_year, color: '#ef4444' },
];
stats.forEach(function(stat) {
var col = el('div', 'col-6 col-md-3');
var card = el('div', 'card border-0 shadow-sm h-100');
var body = el('div', 'card-body text-center py-2 px-2');
var label = el('div', 'text-uppercase small fw-bold mb-1');
label.style.color = stat.color;
label.style.fontSize = '0.65rem';
label.textContent = stat.label;
body.appendChild(label);
body.appendChild(el('div', 'fw-bold', formatRand(stat.value)));
card.appendChild(body);
col.appendChild(card);
statsRow.appendChild(col);
});
workerLookupBody.appendChild(statsRow);
// --- RECENT ACTIVITY ---
var actSection = el('div', 'mb-4');
var actHeader = el('h6', 'text-muted small text-uppercase mb-2', 'Recent Activity');
actSection.appendChild(actHeader);
var activities = [
{ icon: 'fas fa-money-bill-wave', label: 'Last Payslip Paid', data: data.last_payslip, color: 'text-success' },
{ icon: 'fas fa-file-invoice-dollar', label: 'Last Loan Given', data: data.last_loan, color: 'text-primary' },
{ icon: 'fas fa-hand-holding-usd', label: 'Last Loan Repayment', data: data.last_repayment, color: 'text-info' },
{ icon: 'fas fa-bolt', label: 'Last Advance Paid', data: data.last_advance, color: 'text-warning' },
];
activities.forEach(function(act) {
var row = el('div', 'd-flex justify-content-between align-items-center py-2 border-bottom');
var left = el('div', '');
var icon = el('i', act.icon + ' me-2 ' + act.color);
left.appendChild(icon);
left.appendChild(document.createTextNode(act.label));
row.appendChild(left);
if (act.data) {
var right = el('div', 'text-end');
right.appendChild(el('span', 'fw-bold me-2', formatRand(act.data.amount)));
right.appendChild(el('span', 'text-muted small', formatDate(act.data.date)));
if (act.data.reason) {
right.appendChild(document.createTextNode(' '));
right.appendChild(el('span', 'text-muted small fst-italic', '(' + act.data.reason + ')'));
}
row.appendChild(right);
} else {
row.appendChild(el('span', 'text-muted small', 'None'));
}
actSection.appendChild(row);
});
workerLookupBody.appendChild(actSection);
// --- ACTIVE LOANS TABLE (only shown if the worker has active loans) ---
if (data.active_loans && data.active_loans.length > 0) {
var loanSection = el('div', 'mb-4');
loanSection.appendChild(el('h6', 'text-muted small text-uppercase mb-2', 'Active Loans'));
var table = el('table', 'table table-sm table-hover mb-0');
var thead = el('thead', 'table-light');
var headRow = el('tr', '');
['Type', 'Amount', 'Balance', 'Date', 'Reason'].forEach(function(h) {
headRow.appendChild(el('th', 'small', h));
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = el('tbody', '');
data.active_loans.forEach(function(loan) {
var tr = el('tr', '');
tr.appendChild(el('td', 'small', loan.type));
tr.appendChild(el('td', 'small', formatRand(loan.principal)));
tr.appendChild(el('td', 'small fw-bold', formatRand(loan.balance)));
tr.appendChild(el('td', 'small', formatDate(loan.date)));
tr.appendChild(el('td', 'small', loan.reason || '-'));
tbody.appendChild(tr);
});
table.appendChild(tbody);
var tableWrap = el('div', 'table-responsive');
tableWrap.appendChild(table);
loanSection.appendChild(tableWrap);
workerLookupBody.appendChild(loanSection);
}
// --- PAID THIS YEAR ---
var yearSection = el('div', 'mb-4 p-3 rounded');
yearSection.style.backgroundColor = '#f1f5f9';
var yearLabel = el('span', 'text-muted small text-uppercase', 'Paid This Year: ');
var yearValue = el('span', 'fw-bold', formatRand(data.paid_this_year));
yearSection.appendChild(yearLabel);
yearSection.appendChild(yearValue);
workerLookupBody.appendChild(yearSection);
// --- SIZING & INFO ---
var infoSection = el('div', 'mb-2');
infoSection.appendChild(el('h6', 'text-muted small text-uppercase mb-2', 'Sizing & Info'));
// Sizing line — only show if at least one size is filled in
var hasSizing = data.shoe_size || data.overall_top_size || data.pants_size || data.tshirt_size;
if (hasSizing) {
var sizeLine = el('div', 'small mb-1');
var sizeParts = [];
if (data.shoe_size) sizeParts.push('Shoe: ' + data.shoe_size);
if (data.overall_top_size) sizeParts.push('Overall Top: ' + data.overall_top_size);
if (data.pants_size) sizeParts.push('Pants: ' + data.pants_size);
if (data.tshirt_size) sizeParts.push('T-Shirt: ' + data.tshirt_size);
sizeLine.textContent = sizeParts.join(' \u00B7 ');
infoSection.appendChild(sizeLine);
}
// Drivers license
var licLine = el('div', 'small mb-1');
licLine.textContent = 'Drivers License: ' + (data.has_drivers_license ? '\u2705 Yes' : '\u274C No');
infoSection.appendChild(licLine);
// Notes
if (data.notes) {
var notesLabel = el('div', 'small text-muted mt-2', 'Notes:');
infoSection.appendChild(notesLabel);
var notesText = el('div', 'small p-2 rounded');
notesText.style.backgroundColor = '#f8f9fa';
notesText.textContent = data.notes;
infoSection.appendChild(notesText);
}
workerLookupBody.appendChild(infoSection);
}
// === EVENT LISTENERS ===
// "Worker Lookup" button in header — opens modal with empty selection
if (workerLookupBtn) {
workerLookupBtn.addEventListener('click', function() {
workerLookupSelect.value = '';
workerLookupBody.textContent = '';
var placeholder = el('div', 'text-center py-5 text-muted');
placeholder.appendChild(el('i', 'fas fa-id-card fa-3x mb-3 opacity-25'));
placeholder.appendChild(el('p', '', 'Select a worker to view their report card.'));
workerLookupBody.appendChild(placeholder);
var modal = new bootstrap.Modal(workerLookupModal);
modal.show();
});
}
// Dropdown change — load the selected worker's data
if (workerLookupSelect) {
workerLookupSelect.addEventListener('change', function() {
if (this.value) {
loadWorkerLookup(this.value);
}
});
}
// Clickable worker names — open modal with that worker pre-loaded
document.querySelectorAll('.worker-lookup-link').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var workerId = this.dataset.workerId;
workerLookupSelect.value = workerId;
loadWorkerLookup(workerId);
var modal = new bootstrap.Modal(workerLookupModal);
modal.show();
});
});
}); // end DOMContentLoaded
</script>

View File

@ -50,6 +50,9 @@ urlpatterns = [
# Preview a worker's payslip (AJAX — returns JSON)
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
# Worker lookup — AJAX report card for a single worker (returns JSON)
path('payroll/worker-lookup/<int:worker_id>/', views.worker_lookup_ajax, name='worker_lookup_ajax'),
# Add a repayment from the payslip preview modal (AJAX — returns JSON)
path('payroll/repayment/<int:worker_id>/', views.add_repayment_ajax, name='add_repayment_ajax'),

View File

@ -2155,6 +2155,161 @@ def preview_payslip(request, worker_id):
})
# =============================================================================
# === WORKER LOOKUP (AJAX) ===
# Returns a comprehensive financial report card for a single worker.
# Called via AJAX GET from the Worker Lookup modal on the payroll dashboard.
# Shows: amount payable, outstanding loans, recent payments, active loans,
# current project, PPE sizing, drivers license, and notes.
# =============================================================================
@login_required
def worker_lookup_ajax(request, worker_id):
"""AJAX endpoint — returns a comprehensive financial report card for a worker."""
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
worker = get_object_or_404(Worker, id=worker_id)
today = timezone.now().date()
# === AMOUNT PAYABLE ===
# Same logic as preview_payslip: find unpaid work logs for this worker
# A log is "unpaid" if no PayrollRecord links both this log and this worker
unpaid_log_count = 0
for log in worker.work_logs.prefetch_related('payroll_records').all():
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
if worker.id not in paid_worker_ids:
unpaid_log_count += 1
log_amount = float(unpaid_log_count * worker.daily_rate)
# Net adjustment total: additive types increase pay, deductive types decrease it
pending_adjs = worker.adjustments.filter(payroll_record__isnull=True)
adj_total = 0.0
for adj in pending_adjs:
if adj.type in ADDITIVE_TYPES:
adj_total += float(adj.amount)
elif adj.type in DEDUCTIVE_TYPES:
adj_total -= float(adj.amount)
amount_payable = log_amount + adj_total
# === OUTSTANDING LOANS ===
# Total remaining balance across all active loans and advances
active_loans = worker.loans.filter(active=True).order_by('-date')
outstanding_loans = float(
active_loans.aggregate(total=Sum('remaining_balance'))['total'] or 0
)
# === PAID THIS MONTH ===
# Sum of all PayrollRecord amounts in the current calendar month
paid_this_month = float(PayrollRecord.objects.filter(
worker=worker, date__year=today.year, date__month=today.month
).aggregate(total=Sum('amount_paid'))['total'] or 0)
# === LOANS THIS YEAR ===
# Total principal of all loans issued to this worker in the current year
loans_this_year = float(Loan.objects.filter(
worker=worker, date__year=today.year
).aggregate(total=Sum('principal_amount'))['total'] or 0)
# === PAID THIS YEAR ===
# Sum of all PayrollRecord amounts in the current year
paid_this_year = float(PayrollRecord.objects.filter(
worker=worker, date__year=today.year
).aggregate(total=Sum('amount_paid'))['total'] or 0)
# === RECENT ACTIVITY ===
# Most recent of each type — used to show "last payslip", "last loan", etc.
last_payslip = PayrollRecord.objects.filter(
worker=worker).order_by('-date').first()
last_loan = Loan.objects.filter(
worker=worker).order_by('-date').first()
last_repayment = PayrollAdjustment.objects.filter(
worker=worker, type='Loan Repayment',
payroll_record__isnull=False).order_by('-date').first()
last_advance = PayrollAdjustment.objects.filter(
worker=worker, type='Advance Payment',
payroll_record__isnull=False).order_by('-date').first()
# === CURRENT PROJECT ===
# The project from the worker's most recent work log, plus how many
# days they've worked on that project in total
latest_log = worker.work_logs.select_related('project').order_by('-date').first()
current_project = None
days_on_project = 0
if latest_log and latest_log.project:
current_project = latest_log.project.name
days_on_project = worker.work_logs.filter(project=latest_log.project).count()
# === TEAM ===
team = get_worker_active_team(worker)
# === ACTIVE LOANS LIST ===
# Full details for the loans table in the modal
loans_list = []
for loan in active_loans:
loans_list.append({
'type': loan.get_loan_type_display(),
'principal': float(loan.principal_amount),
'balance': float(loan.remaining_balance),
'date': loan.date.strftime('%Y-%m-%d'),
'reason': loan.reason or '',
})
return JsonResponse({
# Identity
'worker_id': worker.id,
'name': worker.name,
'id_number': worker.id_number,
'phone': worker.phone_number,
'employment_date': worker.employment_date.strftime('%Y-%m-%d') if worker.employment_date else '',
'team': team.name if team else '',
'current_project': current_project or '',
'days_on_project': days_on_project,
# Quick stats (4 cards)
'amount_payable': amount_payable,
'outstanding_loans': outstanding_loans,
'paid_this_month': paid_this_month,
'loans_this_year': loans_this_year,
'paid_this_year': paid_this_year,
# Recent activity
'last_payslip': {
'date': last_payslip.date.strftime('%Y-%m-%d'),
'amount': float(last_payslip.amount_paid),
} if last_payslip else None,
'last_loan': {
'date': last_loan.date.strftime('%Y-%m-%d'),
'amount': float(last_loan.principal_amount),
'reason': last_loan.reason or '',
} if last_loan else None,
'last_repayment': {
'date': last_repayment.date.strftime('%Y-%m-%d'),
'amount': float(last_repayment.amount),
} if last_repayment else None,
'last_advance': {
'date': last_advance.date.strftime('%Y-%m-%d'),
'amount': float(last_advance.amount),
} if last_advance else None,
# Active loans table
'active_loans': loans_list,
# Sizing & info
'shoe_size': worker.shoe_size,
'overall_top_size': worker.overall_top_size,
'pants_size': worker.pants_size,
'tshirt_size': worker.tshirt_size,
'has_drivers_license': worker.has_drivers_license,
'notes': worker.notes,
})
# =============================================================================
# === ADD REPAYMENT (AJAX) ===
# Creates a Loan Repayment or Advance Repayment adjustment for a single worker.

View File

@ -0,0 +1,145 @@
# Worker Lookup Modal — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a "Worker Lookup" modal to the payroll dashboard that shows a comprehensive financial report card for any active worker — payable amount, loans, recent payments, sizing, and notes.
**Architecture:** New AJAX endpoint (`worker_lookup_ajax`) returns JSON with all worker data. Modal HTML + JS in the dashboard template dynamically renders the data using safe DOM methods (textContent, createElement). Worker names across all dashboard tabs become clickable links that open the modal. A "Worker Lookup" button in the header lets you search any active worker.
**Tech Stack:** Django views (JSON), Bootstrap 5 modal, vanilla JavaScript (matching existing patterns)
---
### Task 1: Add the AJAX backend endpoint
**Files:**
- Modify: `core/views.py` (add new view after `preview_payslip` at ~line 2155)
- Modify: `core/urls.py` (add new URL pattern at ~line 54)
**Step 1: Add URL pattern in `core/urls.py`**
Add after the `preview_payslip` URL (line 51):
```python
# Worker lookup — AJAX report card for a single worker (returns JSON)
path('payroll/worker-lookup/<int:worker_id>/', views.worker_lookup_ajax, name='worker_lookup_ajax'),
```
**Step 2: Add `worker_lookup_ajax` view in `core/views.py`**
Add after `preview_payslip` function (after line 2155). The view:
1. Checks admin access (`is_admin`)
2. Fetches the Worker object with all model fields (sizing, license, notes)
3. Calculates Amount Payable:
- Unpaid WorkLogs x daily_rate (same logic as `preview_payslip` lines 2069-2083)
- Net pending adjustments (additive minus deductive)
4. Outstanding Loans: `Loan.filter(worker=worker, active=True).aggregate(Sum('remaining_balance'))`
5. Paid This Month: `PayrollRecord.filter(worker=worker, date__year=now.year, date__month=now.month).aggregate(Sum('amount_paid'))`
6. Loans This Year: `Loan.filter(worker=worker, date__year=now.year).aggregate(Sum('principal_amount'))`
7. Paid This Year: `PayrollRecord.filter(worker=worker, date__year=now.year).aggregate(Sum('amount_paid'))`
8. Last Payslip: `PayrollRecord.filter(worker=worker).order_by('-date').first()` -> date + amount
9. Last Loan Given: `Loan.filter(worker=worker).order_by('-date').first()` -> date + amount + reason
10. Last Loan Repayment: `PayrollAdjustment.filter(worker=worker, type='Loan Repayment', payroll_record__isnull=False).order_by('-date').first()` -> date + amount
11. Last Advance: `PayrollAdjustment.filter(worker=worker, type='Advance Payment', payroll_record__isnull=False).order_by('-date').first()` -> date + amount
12. Active Loans list: `Loan.filter(worker=worker, active=True).order_by('-date')` -> type, principal, balance, date, reason
13. Current Project: most recent WorkLog -> project name + count of logs on that project
14. Team: `get_worker_active_team(worker)` -> team name
**Step 3: Verify imports**
Ensure `Sum` is imported from `django.db.models` at the top of `views.py`. Check for existing `from django.db.models import ...` line and add `Sum` if missing.
---
### Task 2: Add the modal HTML to the dashboard template
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html`
**Step 1: Add "Worker Lookup" button in the page header (line 15)**
Add a new button in the header button group, before Batch Pay.
**Step 2: Add the Worker Lookup modal HTML**
Add after the `previewPayslipModal` (after line ~783). The modal contains:
- Worker dropdown in header
- A body div (`#workerLookupBody`) that gets populated by JS
- Placeholder text as default content
**Step 3: Pass `active_workers_list` from the view**
In `core/views.py`, in the `payroll_dashboard` view, add to the context dict:
```python
'active_workers_list': Worker.objects.filter(active=True).order_by('name'),
```
---
### Task 3: Make worker names clickable across all tabs
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html`
Replace `<strong>worker.name</strong>` with clickable links using class `worker-lookup-link` and `data-worker-id` at:
- **Pending Payments tab** (line 268)
- **Payment History tab** (line 365)
- **Loans & Advances tab** (line 432)
---
### Task 4: Add JavaScript to fetch data and render the modal
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html` (JS section at bottom)
**Step 1: Add the `loadWorkerLookup(workerId)` function**
This function:
1. Shows a loading spinner in `#workerLookupBody`
2. Fetches `/payroll/worker-lookup/<workerId>/` via fetch API
3. Builds the report card using safe DOM methods (createElement, textContent — no innerHTML with user data)
4. Renders sections: Identity, Quick Stats (4 cards), Recent Activity, Active Loans table, Paid This Year, Sizing & Info
Format currency as `R X,XXX.XX` using `toLocaleString('en-ZA', {minimumFractionDigits: 2})`.
**Step 2: Add event listeners**
- Worker Lookup button click -> open modal with empty dropdown
- Dropdown change -> call `loadWorkerLookup(selectedId)`
- `.worker-lookup-link` click -> set dropdown value, load data, open modal
---
### Task 5: Update CLAUDE.md
**Files:**
- Modify: `CLAUDE.md`
- Add Worker Lookup documentation to Development Workflow section
- Add URL route to the URL Routes table
- Update view count from "27 functions" to "28 functions"
---
### Task 6: Test locally
1. Start dev server: `run_dev.bat`
2. Go to `/payroll/` -> verify "Worker Lookup" button appears in header
3. Click "Worker Lookup" -> modal opens with dropdown -> select a worker -> report card loads
4. Click a worker name in Pending Payments -> modal opens with that worker's data
5. Click a worker name in Payment History -> same
6. Click a worker name in Loans & Advances -> same
7. Switch workers via dropdown while modal is open -> data refreshes
8. Verify all sections render correctly:
- Quick stats show correct amounts
- Recent activity shows dates and amounts (or "None")
- Active loans table shows if worker has loans
- Sizing and notes display at bottom
9. Verify existing functionality is unbroken:
- Preview payslip modal still works
- Quick adjust button still works
- Pay/Batch pay still works