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:
parent
81009be0c6
commit
60ee21dd61
@ -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 |
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
155
core/views.py
155
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.
|
||||
|
||||
145
docs/plans/2026-04-20-worker-lookup-design.md
Normal file
145
docs/plans/2026-04-20-worker-lookup-design.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user