From 60ee21dd61f429f1c990deed6f1660ed38848549 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Mon, 20 Apr 2026 14:15:04 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 4 +- core/templates/core/payroll_dashboard.html | 315 +++++++++++++++++- core/urls.py | 3 + core/views.py | 155 +++++++++ docs/plans/2026-04-20-worker-lookup-design.md | 145 ++++++++ 5 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-04-20-worker-lookup-design.md diff --git a/CLAUDE.md b/CLAUDE.md index da41dd8..12d1e02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//edit/` | `edit_adjustment` | Admin: edit unpaid adjustment | | `/payroll/adjustment//delete/` | `delete_adjustment` | Admin: delete unpaid adjustment | | `/payroll/preview//` | `preview_payslip` | Admin: AJAX JSON payslip preview (includes active loans) | +| `/payroll/worker-lookup//` | `worker_lookup_ajax` | Admin: AJAX JSON worker report card | | `/payroll/repayment//` | `add_repayment_ajax` | Admin: AJAX add loan/advance repayment from preview | | `/payroll/payslip//` | `payslip_detail` | Admin: view completed payslip | | `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items | diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index b41e550..3cf71b1 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -13,6 +13,9 @@

Payroll Dashboard

+ @@ -265,7 +268,8 @@ data-overdue="{{ wd.is_overdue|yesno:'true,false' }}" data-has-loan="{{ wd.has_loan|yesno:'true,false' }}"> - {{ wd.worker.name }} + {{ wd.worker.name }} {% if wd.is_overdue %} Overdue {% endif %} @@ -362,7 +366,8 @@ {% for record in paid_records %} {{ record.date }} - {{ record.worker.name }} + {{ record.worker.name }} R {{ record.amount_paid|floatformat:2 }} {{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }} @@ -429,7 +434,8 @@ {% for loan in loans %} - {{ loan.worker.name }} + {{ loan.worker.name }} {% if loan.loan_type == 'advance' %} Advance @@ -783,6 +789,38 @@
+{# === WORKER LOOKUP MODAL === #} +{# Shows a comprehensive financial report card for any active worker. #} +{# Triggered by clicking a worker name or the "Worker Lookup" button. #} + + {# ================================================================== #} {# === 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 diff --git a/core/urls.py b/core/urls.py index 864164d..9d4020c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -50,6 +50,9 @@ urlpatterns = [ # Preview a worker's payslip (AJAX — returns JSON) path('payroll/preview//', views.preview_payslip, name='preview_payslip'), + # Worker lookup — AJAX report card for a single worker (returns JSON) + path('payroll/worker-lookup//', views.worker_lookup_ajax, name='worker_lookup_ajax'), + # Add a repayment from the payslip preview modal (AJAX — returns JSON) path('payroll/repayment//', views.add_repayment_ajax, name='add_repayment_ajax'), diff --git a/core/views.py b/core/views.py index ae55eff..cc8ff40 100644 --- a/core/views.py +++ b/core/views.py @@ -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. diff --git a/docs/plans/2026-04-20-worker-lookup-design.md b/docs/plans/2026-04-20-worker-lookup-design.md new file mode 100644 index 0000000..c16239e --- /dev/null +++ b/docs/plans/2026-04-20-worker-lookup-design.md @@ -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//', 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 `worker.name` 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//` 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