Add Advance Payment system + enhanced preview modal with inline repayments

Redesign Advance Payments to work like loans with tracked balances:
- Add loan_type field to Loan model ('loan' or 'advance')
- Move Advance Payment from DEDUCTIVE to ADDITIVE types (worker receives money)
- Add new Advance Repayment type for deducting from future salary
- Create/edit/delete handlers mirror New Loan behavior for advances
- Loans & Advances tab with type badges and filter buttons

Enhance Payslip Preview modal into "Worker Payment Hub":
- Show outstanding loans & advances with balances in preview
- Inline repayment form per loan (amount pre-filled, note, Deduct button)
- AJAX add_repayment_ajax endpoint creates adjustment without page reload
- Modal auto-refreshes after repayment showing updated net pay
- New refreshPreview() JS function enables re-fetching after AJAX

Other changes:
- Rename History to Work History in navbar
- Advance-specific payslip layout for pure advance payments
- Fix JS noProjectTypes to hide Project field for advance types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-05 10:46:58 +02:00
parent 19c662ec7d
commit 0257b454af
8 changed files with 578 additions and 201 deletions

View File

@ -42,7 +42,7 @@ staticfiles/ — Collected static assets (Bootstrap, admin)
- **WorkLog** — daily attendance: date, project, team, workers (M2M), supervisor, overtime, `priced_workers` (M2M)
- **PayrollRecord** — completed payments linked to WorkLogs (M2M) and Worker (FK)
- **PayrollAdjustment** — bonuses, deductions, overtime, loans; linked to project (FK, optional), worker, and optionally to a Loan or WorkLog
- **Loan** — worker advances with principal and remaining_balance tracking
- **Loan** — worker loans AND advances with principal, remaining_balance, `loan_type` ('loan' or 'advance')
- **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling
## Key Business Rules
@ -58,15 +58,16 @@ staticfiles/ — Collected static assets (Bootstrap, admin)
## Payroll Constants
Defined at top of views.py — used in dashboard calculations and payment processing:
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan']` — increase worker's net pay
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Payment']` — decrease net pay
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay
## PayrollAdjustment Type Handling
- **Bonus / Deduction** — standalone, require a linked Project
- **New Loan** — creates a `Loan` record; editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
- **New Loan** — creates a `Loan` record (`loan_type='loan'`); editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
- **Advance Payment** — creates a `Loan` record (`loan_type='advance'`); works exactly like New Loan but tagged as "advance" in the Loans tab. Worker receives money upfront (additive).
- **Overtime** — links to `WorkLog` via `adj.work_log` FK; managed by `price_overtime()` view
- **Loan Repayment** — links to `Loan` via `adj.loan` FK; loan balance changes during payment processing
- **Advance Payment** — requires a linked Project; reduces net pay
- **Loan Repayment** — links to `Loan` (loan_type='loan') via `adj.loan` FK; loan balance changes during payment processing
- **Advance Repayment** — links to `Loan` (loan_type='advance') via `adj.loan` FK; deducts from advance balance during payment processing
## Outstanding Payments Logic (Dashboard)
The dashboard's outstanding amount uses **per-worker** checking, not per-log:
@ -98,6 +99,7 @@ python manage.py check # System check
- POST-only mutation views pattern: check `request.method != 'POST'` → redirect
- Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements
- Atomic transactions: `process_payment()` uses `select_for_update()` to prevent duplicate payments
- Payslip Preview modal ("Worker Payment Hub"): shows earnings, adjustments, net pay, **plus** active loans/advances with inline repayment forms. Uses `refreshPreview()` JS function that re-fetches after AJAX repayment submission. Repayment POSTs to `add_repayment_ajax` which creates a PayrollAdjustment (balance deduction only happens during `process_payment()`)
## URL Routes
| Path | View | Purpose |
@ -114,7 +116,8 @@ python manage.py check # System check
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |
| `/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 |
| `/payroll/preview/<worker_id>/` | `preview_payslip` | Admin: AJAX JSON payslip preview (includes active loans) |
| `/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 |
| `/import-data/` | `import_data` | Setup: run import command from browser |

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-03-05 06:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_add_project_start_end_dates'),
]
operations = [
migrations.AddField(
model_name='loan',
name='loan_type',
field=models.CharField(choices=[('loan', 'Loan'), ('advance', 'Advance')], default='loan', max_length=10),
),
migrations.AlterField(
model_name='payrolladjustment',
name='type',
field=models.CharField(choices=[('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'New Loan'), ('Advance Payment', 'Advance Payment'), ('Advance Repayment', 'Advance Repayment')], max_length=50),
),
]

View File

@ -98,7 +98,18 @@ class PayrollRecord(models.Model):
return f"{self.worker.name} - {self.date}"
class Loan(models.Model):
# === LOAN TYPE ===
# 'loan' = traditional loan (created via "New Loan")
# 'advance' = salary advance (created via "Advance Payment")
# Both work the same way (tracked balance, repayments) but are
# labelled differently on payslips and in the Loans tab.
LOAN_TYPE_CHOICES = [
('loan', 'Loan'),
('advance', 'Advance'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
loan_type = models.CharField(max_length=10, choices=LOAN_TYPE_CHOICES, default='loan')
principal_amount = models.DecimalField(max_digits=10, decimal_places=2)
remaining_balance = models.DecimalField(max_digits=10, decimal_places=2)
date = models.DateField(default=timezone.now)
@ -111,7 +122,8 @@ class Loan(models.Model):
super().save(*args, **kwargs)
def __str__(self):
return f"{self.worker.name} - Loan - {self.date}"
label = 'Advance' if self.loan_type == 'advance' else 'Loan'
return f"{self.worker.name} - {label} - {self.date}"
class PayrollAdjustment(models.Model):
TYPE_CHOICES = [
@ -121,6 +133,7 @@ class PayrollAdjustment(models.Model):
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'New Loan'),
('Advance Payment', 'Advance Payment'),
('Advance Repayment', 'Advance Repayment'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')

View File

@ -52,7 +52,7 @@
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}" href="{% url 'work_history' %}">
<i class="fas fa-clock me-1"></i> History
<i class="fas fa-clock me-1"></i> Work History
</a>
</li>
{% if user.is_staff %}

View File

@ -69,7 +69,7 @@
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
Active Loans ({{ active_loans_count }})</div>
Active Loans & Advances ({{ active_loans_count }})</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-25"></i>
@ -180,7 +180,7 @@
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">
<i class="fas fa-hand-holding-usd me-1"></i> Loans
<i class="fas fa-hand-holding-usd me-1"></i> Loans & Advances
</a>
</li>
</ul>
@ -217,7 +217,7 @@
<td class="align-middle">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
@ -226,7 +226,7 @@
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.type }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
@ -300,7 +300,7 @@
</td>
<td class="align-middle">
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} me-1">
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
@ -335,11 +335,11 @@
<div class="mb-3 d-flex gap-2">
<a href="?status=loans&loan_status=active"
class="btn btn-sm {% if loan_filter == 'active' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
Active Loans
Active
</a>
<a href="?status=loans&loan_status=history"
class="btn btn-sm {% if loan_filter == 'history' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
Loan History
History
</a>
</div>
<div class="card shadow-sm border-0">
@ -349,6 +349,7 @@
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col">Type</th>
<th scope="col">Principal</th>
<th scope="col">Balance</th>
<th scope="col">Date</th>
@ -360,6 +361,13 @@
{% for loan in loans %}
<tr>
<td class="ps-4 align-middle"><strong>{{ loan.worker.name }}</strong></td>
<td class="align-middle">
{% if loan.loan_type == 'advance' %}
<span class="badge bg-info text-dark">Advance</span>
{% else %}
<span class="badge bg-primary">Loan</span>
{% endif %}
</td>
<td class="align-middle">R {{ loan.principal_amount|floatformat:2 }}</td>
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
<td class="align-middle">{{ loan.date }}</td>
@ -374,9 +382,9 @@
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<td colspan="7" class="text-center py-5 text-muted">
<i class="fas fa-hand-holding-usd fa-2x mb-3 d-block opacity-50"></i>
{% if loan_filter == 'active' %}No active loans.{% else %}No loan history.{% endif %}
{% if loan_filter == 'active' %}No active loans or advances.{% else %}No loan/advance history.{% endif %}
</td>
</tr>
{% endfor %}
@ -649,7 +657,7 @@
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-invoice me-2"></i>Payslip Preview</h5>
<h5 class="modal-title"><i class="fas fa-file-invoice me-2"></i>Payslip Preview & Repayments</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="previewPayslipBody">
@ -1164,8 +1172,8 @@ document.addEventListener('DOMContentLoaded', function() {
// only enforces the project when the type actually needs one.
function toggleProjectField() {
if (!addAdjType || !addAdjProjectGroup) return;
// Loan and Loan Repayment don't need a project
var noProjectTypes = ['New Loan', 'Loan Repayment'];
// Loan types, repayments, and advances don't need a project
var noProjectTypes = ['New Loan', 'Loan Repayment', 'Advance Payment', 'Advance Repayment'];
var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1;
addAdjProjectGroup.style.display = needsProject ? '' : 'none';
if (addAdjProject) {
@ -1312,167 +1320,324 @@ document.addEventListener('DOMContentLoaded', function() {
// =================================================================
// PREVIEW PAYSLIP — Fetch JSON and build DOM (no innerHTML for data)
// Refactored into refreshPreview() so we can re-fetch after adding
// a repayment from the inline form inside the modal.
// =================================================================
// Helper: fetch preview data and build the modal content
function refreshPreview(workerId, modalBody) {
// Show loading spinner (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
var loadingDiv = document.createElement('div');
loadingDiv.className = 'text-center py-4';
var spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadingDiv.appendChild(spinner);
var loadingText = document.createElement('p');
loadingText.className = 'text-muted mt-2 small';
loadingText.textContent = 'Loading preview...';
loadingDiv.appendChild(loadingText);
modalBody.appendChild(loadingDiv);
// Fetch preview data
fetch('/payroll/preview/' + workerId + '/')
.then(function(resp) {
if (!resp.ok) throw new Error('Network error');
return resp.json();
})
.then(function(data) {
// Clear loading
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
// === Build payslip preview using DOM methods ===
// Worker header
var header = document.createElement('div');
header.className = 'border-bottom pb-3 mb-3';
var h4 = document.createElement('h4');
h4.className = 'mb-1';
h4.textContent = data.worker_name;
header.appendChild(h4);
if (data.worker_id_number) {
var idP = document.createElement('p');
idP.className = 'text-muted small mb-0';
idP.textContent = 'ID: ' + data.worker_id_number;
header.appendChild(idP);
}
modalBody.appendChild(header);
// Earnings section
var earningsH6 = document.createElement('h6');
earningsH6.className = 'fw-bold mb-2';
earningsH6.textContent = 'Earnings';
modalBody.appendChild(earningsH6);
var earningsRow = document.createElement('div');
earningsRow.className = 'd-flex justify-content-between mb-1';
var earningsLabel = document.createElement('span');
earningsLabel.textContent = data.days_worked + ' day(s) \u00d7 ' + fmt(data.day_rate);
earningsRow.appendChild(earningsLabel);
var earningsVal = document.createElement('strong');
earningsVal.textContent = fmt(data.log_amount);
earningsRow.appendChild(earningsVal);
modalBody.appendChild(earningsRow);
// Adjustments section
if (data.adjustments && data.adjustments.length > 0) {
var adjHr = document.createElement('hr');
modalBody.appendChild(adjHr);
var adjH6 = document.createElement('h6');
adjH6.className = 'fw-bold mb-2';
adjH6.textContent = 'Adjustments';
modalBody.appendChild(adjH6);
data.adjustments.forEach(function(adj) {
var row = document.createElement('div');
row.className = 'd-flex justify-content-between mb-1';
var label = document.createElement('span');
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
if (adj.description) {
var descSmall = document.createElement('small');
descSmall.className = 'text-muted ms-1';
descSmall.textContent = '\u2014 ' + adj.description;
label.appendChild(descSmall);
}
row.appendChild(label);
var val = document.createElement('span');
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
val.textContent = adj.sign + fmt(adj.amount);
row.appendChild(val);
modalBody.appendChild(row);
});
}
// Net pay
var netHr = document.createElement('hr');
netHr.className = 'my-3';
modalBody.appendChild(netHr);
var netRow = document.createElement('div');
netRow.className = 'd-flex justify-content-between';
var netLabel = document.createElement('h5');
netLabel.className = 'fw-bold';
netLabel.textContent = 'Net Pay';
netRow.appendChild(netLabel);
var netVal = document.createElement('h5');
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
netVal.textContent = fmt(data.net_pay);
netRow.appendChild(netVal);
modalBody.appendChild(netRow);
// =============================================================
// OUTSTANDING LOANS & ADVANCES — shows active balances with
// inline repayment forms so the admin can deduct right here.
// =============================================================
if (data.active_loans && data.active_loans.length > 0) {
var loansHr = document.createElement('hr');
loansHr.className = 'my-3';
modalBody.appendChild(loansHr);
var loansH6 = document.createElement('h6');
loansH6.className = 'fw-bold mb-2';
loansH6.textContent = 'Outstanding Loans & Advances';
modalBody.appendChild(loansH6);
data.active_loans.forEach(function(loan) {
// Card container for each loan/advance
var card = document.createElement('div');
card.className = 'border rounded p-2 mb-2';
card.style.backgroundColor = '#f8f9fa';
// Row 1: Type badge + Balance
var topRow = document.createElement('div');
topRow.className = 'd-flex justify-content-between align-items-center mb-1';
var badge = document.createElement('span');
badge.className = 'badge ' + (loan.type === 'advance' ? 'bg-info text-dark' : 'bg-primary');
badge.textContent = loan.type_label;
topRow.appendChild(badge);
var balSpan = document.createElement('span');
balSpan.className = 'fw-bold text-danger';
balSpan.textContent = 'Balance: ' + fmt(loan.balance);
topRow.appendChild(balSpan);
card.appendChild(topRow);
// Row 2: Details (principal, date, reason)
var detailRow = document.createElement('div');
detailRow.className = 'small text-muted mb-2';
detailRow.textContent = 'Principal: ' + fmt(loan.principal) + ' | ' + loan.date;
if (loan.reason) {
detailRow.textContent += ' | ' + loan.reason;
}
card.appendChild(detailRow);
// Row 3: Inline repayment form
var formRow = document.createElement('div');
formRow.className = 'd-flex gap-2 align-items-center';
var amtInput = document.createElement('input');
amtInput.type = 'number';
amtInput.className = 'form-control form-control-sm';
amtInput.style.maxWidth = '130px';
amtInput.placeholder = 'Amount';
amtInput.step = '0.01';
amtInput.min = '0.01';
amtInput.max = loan.balance;
amtInput.value = loan.balance;
formRow.appendChild(amtInput);
var noteInput = document.createElement('input');
noteInput.type = 'text';
noteInput.className = 'form-control form-control-sm';
noteInput.placeholder = 'Note (optional)';
noteInput.style.flex = '1';
formRow.appendChild(noteInput);
var deductBtn = document.createElement('button');
deductBtn.type = 'button';
deductBtn.className = 'btn btn-sm btn-outline-danger';
deductBtn.title = 'Add ' + loan.type_label + ' Repayment';
var btnIcon = document.createElement('i');
btnIcon.className = 'fas fa-minus-circle me-1';
deductBtn.appendChild(btnIcon);
deductBtn.appendChild(document.createTextNode('Deduct'));
formRow.appendChild(deductBtn);
card.appendChild(formRow);
// Wire up the "Deduct" button — POST via AJAX, then refresh
deductBtn.addEventListener('click', function() {
var repayAmt = parseFloat(amtInput.value);
if (!repayAmt || repayAmt <= 0) {
amtInput.classList.add('is-invalid');
return;
}
if (repayAmt > loan.balance) {
repayAmt = loan.balance;
amtInput.value = loan.balance;
}
amtInput.classList.remove('is-invalid');
// Disable form while submitting
deductBtn.disabled = true;
amtInput.disabled = true;
noteInput.disabled = true;
deductBtn.textContent = 'Adding...';
fetch('/payroll/repayment/' + workerId + '/', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
},
body: JSON.stringify({
loan_id: loan.id,
amount: repayAmt,
description: noteInput.value
})
})
.then(function(resp) {
if (!resp.ok) return resp.json().then(function(e) { throw new Error(e.error || 'Error'); });
return resp.json();
})
.then(function(result) {
// Show brief success feedback
var msg = document.createElement('div');
msg.className = 'alert alert-success py-1 px-2 small mt-2 mb-0';
msg.textContent = result.message;
card.appendChild(msg);
// After a brief pause, re-fetch the entire preview
setTimeout(function() {
refreshPreview(workerId, modalBody);
}, 800);
})
.catch(function(err) {
// Re-enable form on error
deductBtn.disabled = false;
amtInput.disabled = false;
noteInput.disabled = false;
deductBtn.textContent = '';
var errIcon2 = document.createElement('i');
errIcon2.className = 'fas fa-minus-circle me-1';
deductBtn.appendChild(errIcon2);
deductBtn.appendChild(document.createTextNode('Deduct'));
var errMsg = document.createElement('div');
errMsg.className = 'alert alert-danger py-1 px-2 small mt-2 mb-0';
errMsg.textContent = err.message || 'Something went wrong.';
card.appendChild(errMsg);
setTimeout(function() {
if (errMsg.parentNode) errMsg.parentNode.removeChild(errMsg);
}, 3000);
});
});
modalBody.appendChild(card);
});
}
// Work log details
if (data.logs && data.logs.length > 0) {
var wlHr = document.createElement('hr');
modalBody.appendChild(wlHr);
var logsH6b = document.createElement('h6');
logsH6b.className = 'fw-bold mb-2';
logsH6b.textContent = 'Work Log Details';
modalBody.appendChild(logsH6b);
var table = document.createElement('table');
table.className = 'table table-sm mb-0';
var thead = document.createElement('thead');
var headRow = document.createElement('tr');
['Date', 'Project'].forEach(function(h) {
var th = document.createElement('th');
th.textContent = h;
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = document.createElement('tbody');
data.logs.forEach(function(log) {
var tr = document.createElement('tr');
tr.appendChild(createTd(log.date));
tr.appendChild(createTd(log.project));
tbody.appendChild(tr);
});
table.appendChild(tbody);
modalBody.appendChild(table);
}
})
.catch(function() {
// Show error (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
var errDiv = document.createElement('div');
errDiv.className = 'text-center py-4 text-danger';
var errIcon3 = document.createElement('i');
errIcon3.className = 'fas fa-exclamation-triangle fs-3';
errDiv.appendChild(errIcon3);
var errText = document.createElement('p');
errText.className = 'mt-2';
errText.textContent = 'Could not load preview.';
errDiv.appendChild(errText);
modalBody.appendChild(errDiv);
});
}
// Wire up preview buttons to call the reusable refreshPreview function
document.querySelectorAll('.preview-payslip-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const workerId = this.dataset.workerId;
const workerName = this.dataset.workerName;
const modalBody = document.getElementById('previewPayslipBody');
var workerId = this.dataset.workerId;
var modalBody = document.getElementById('previewPayslipBody');
// Show loading spinner (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
const loadingDiv = document.createElement('div');
loadingDiv.className = 'text-center py-4';
const spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadingDiv.appendChild(spinner);
const loadingText = document.createElement('p');
loadingText.className = 'text-muted mt-2 small';
loadingText.textContent = 'Loading preview...';
loadingDiv.appendChild(loadingText);
modalBody.appendChild(loadingDiv);
// Show modal
// Show modal first, then fetch data
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
// Fetch preview data
fetch('/payroll/preview/' + workerId + '/')
.then(function(resp) {
if (!resp.ok) throw new Error('Network error');
return resp.json();
})
.then(function(data) {
// Clear loading
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
// === Build payslip preview using DOM methods ===
// Worker header
const header = document.createElement('div');
header.className = 'border-bottom pb-3 mb-3';
const h4 = document.createElement('h4');
h4.className = 'mb-1';
h4.textContent = data.worker_name;
header.appendChild(h4);
if (data.worker_id_number) {
const idP = document.createElement('p');
idP.className = 'text-muted small mb-0';
idP.textContent = 'ID: ' + data.worker_id_number;
header.appendChild(idP);
}
modalBody.appendChild(header);
// Earnings section
const earningsH6 = document.createElement('h6');
earningsH6.className = 'fw-bold mb-2';
earningsH6.textContent = 'Earnings';
modalBody.appendChild(earningsH6);
const earningsRow = document.createElement('div');
earningsRow.className = 'd-flex justify-content-between mb-1';
const earningsLabel = document.createElement('span');
earningsLabel.textContent = data.days_worked + ' day(s) × ' + fmt(data.day_rate);
earningsRow.appendChild(earningsLabel);
const earningsVal = document.createElement('strong');
earningsVal.textContent = fmt(data.log_amount);
earningsRow.appendChild(earningsVal);
modalBody.appendChild(earningsRow);
// Adjustments section
if (data.adjustments && data.adjustments.length > 0) {
const adjHr = document.createElement('hr');
modalBody.appendChild(adjHr);
const adjH6 = document.createElement('h6');
adjH6.className = 'fw-bold mb-2';
adjH6.textContent = 'Adjustments';
modalBody.appendChild(adjH6);
data.adjustments.forEach(function(adj) {
const row = document.createElement('div');
row.className = 'd-flex justify-content-between mb-1';
const label = document.createElement('span');
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
if (adj.description) {
const descSmall = document.createElement('small');
descSmall.className = 'text-muted ms-1';
descSmall.textContent = '— ' + adj.description;
label.appendChild(descSmall);
}
row.appendChild(label);
const val = document.createElement('span');
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
val.textContent = adj.sign + fmt(adj.amount);
row.appendChild(val);
modalBody.appendChild(row);
});
}
// Net pay
const netHr = document.createElement('hr');
netHr.className = 'my-3';
modalBody.appendChild(netHr);
const netRow = document.createElement('div');
netRow.className = 'd-flex justify-content-between';
const netLabel = document.createElement('h5');
netLabel.className = 'fw-bold';
netLabel.textContent = 'Net Pay';
netRow.appendChild(netLabel);
const netVal = document.createElement('h5');
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
netVal.textContent = fmt(data.net_pay);
netRow.appendChild(netVal);
modalBody.appendChild(netRow);
// Work log details
if (data.logs && data.logs.length > 0) {
const logsHr = document.createElement('hr');
modalBody.appendChild(logsHr);
const logsH6 = document.createElement('h6');
logsH6.className = 'fw-bold mb-2';
logsH6.textContent = 'Work Log Details';
modalBody.appendChild(logsH6);
const table = document.createElement('table');
table.className = 'table table-sm mb-0';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['Date', 'Project'].forEach(function(h) {
const th = document.createElement('th');
th.textContent = h;
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
data.logs.forEach(function(log) {
const tr = document.createElement('tr');
tr.appendChild(createTd(log.date));
tr.appendChild(createTd(log.project));
tbody.appendChild(tr);
});
table.appendChild(tbody);
modalBody.appendChild(table);
}
})
.catch(function() {
// Show error (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
const errDiv = document.createElement('div');
errDiv.className = 'text-center py-4 text-danger';
const errIcon = document.createElement('i');
errIcon.className = 'fas fa-exclamation-triangle fs-3';
errDiv.appendChild(errIcon);
const errText = document.createElement('p');
errText.className = 'mt-2';
errText.textContent = 'Could not load preview.';
errDiv.appendChild(errText);
modalBody.appendChild(errDiv);
});
refreshPreview(workerId, modalBody);
});
});

View File

@ -28,10 +28,10 @@
<div class="col-md-6">
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
<h2 class="fw-bold text-dark mb-0 text-uppercase">{{ record.worker.name }}</h2>
<p class="text-muted small mb-0">Payslip No. #{{ record.id|stringformat:"06d" }}</p>
<p class="text-muted small mb-0">{% if is_advance %}Advance{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}</p>
</div>
<div class="col-md-6 text-md-end mt-3 mt-md-0">
<h3 class="fw-bold text-uppercase text-secondary mb-1">Payslip</h3>
<h3 class="fw-bold text-uppercase text-secondary mb-1">{% if is_advance %}Advance{% endif %} Payslip</h3>
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
<div class="text-muted small">Payer: Fox Fitt</div>
</div>
@ -54,6 +54,43 @@
</div>
</div>
{% if is_advance %}
<!-- === ADVANCE PAYMENT DETAIL === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Advance Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ advance_adj.date|date:"M d, Y" }}</td>
<td><span class="badge bg-info text-dark text-uppercase">Advance Payment</span></td>
<td>{{ advance_adj.description|default:"Salary advance" }}</td>
<td class="text-end text-success fw-bold">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === ADVANCE TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Amount Advanced:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
{% else %}
<!-- === WORK LOG TABLE — each day worked === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<div class="table-responsive mb-4">
@ -153,6 +190,7 @@
</table>
</div>
</div>
{% endif %}
<!-- === FOOTER === -->
<div class="text-center text-muted small mt-5 pt-4 border-top">

View File

@ -46,6 +46,9 @@ urlpatterns = [
# Preview a worker's payslip (AJAX — returns JSON)
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
# 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 a completed payslip (print-friendly page)
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),

View File

@ -30,10 +30,10 @@ from .forms import AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm,
# === PAYROLL CONSTANTS ===
# These define which adjustment types ADD to a worker's pay vs SUBTRACT from it.
# "New Loan" is additive because the worker receives money upfront.
# "Loan Repayment" and "Advance Payment" are deductive — they reduce net pay.
ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan']
DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Payment']
# "New Loan" and "Advance Payment" are additive — the worker receives money upfront.
# "Loan Repayment" and "Advance Repayment" are deductive — they reduce net pay.
ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']
DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Repayment']
# === PERMISSION HELPERS ===
@ -1138,8 +1138,8 @@ def process_payment(request, worker_id):
adj.payroll_record = payroll_record
adj.save()
# If this is a loan repayment, deduct from the loan balance
if adj.type == 'Loan Repayment' and adj.loan:
# If this is a loan or advance repayment, deduct from the balance
if adj.type in ('Loan Repayment', 'Advance Repayment') and adj.loan:
adj.loan.remaining_balance -= adj.amount
if adj.loan.remaining_balance <= 0:
adj.loan.remaining_balance = Decimal('0.00')
@ -1154,7 +1154,18 @@ def process_payment(request, worker_id):
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
from .utils import render_to_pdf
subject = f"Payslip for {worker.name} - {payroll_record.date}"
# === DETECT ADVANCE-ONLY PAYMENT ===
# If the payment has 0 work logs and consists of only an Advance Payment
# adjustment, use the special advance payslip layout (shows the advance
# as a positive amount instead of the confusing "0 days + deduction" format).
advance_adj = None
if log_count == 0:
adjs_list = list(payroll_record.adjustments.all())
if len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
is_advance = advance_adj is not None
subject = f"{'Advance ' if is_advance else ''}Payslip for {worker.name} - {payroll_record.date}"
# Context for both the HTML email body and the PDF attachment
email_context = {
@ -1163,6 +1174,9 @@ def process_payment(request, worker_id):
'logs_amount': logs_amount,
'adjustments': payroll_record.adjustments.all(),
'deductive_types': DEDUCTIVE_TYPES,
'is_advance': is_advance,
'advance_amount': advance_adj.amount if advance_adj else None,
'advance_description': advance_adj.description if advance_adj else '',
}
# 1. Render HTML email body
@ -1312,7 +1326,7 @@ def add_adjustment(request):
except Project.DoesNotExist:
pass
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment')
project_required_types = ('Overtime', 'Bonus', 'Deduction')
if adj_type in project_required_types and not project:
messages.error(request, 'A project must be selected for this adjustment type.')
return redirect('payroll_dashboard')
@ -1326,23 +1340,44 @@ def add_adjustment(request):
loan = None
# === LOAN REPAYMENT — find the worker's active loan ===
if adj_type == 'Loan Repayment':
# Find the worker's active loan
loan = worker.loans.filter(active=True).first()
loan = worker.loans.filter(active=True, loan_type='loan').first()
if not loan:
messages.warning(request, f'{worker.name} has no active loan — skipped.')
continue
# === ADVANCE REPAYMENT — find the worker's active advance ===
if adj_type == 'Advance Repayment':
loan = worker.loans.filter(active=True, loan_type='advance').first()
if not loan:
messages.warning(request, f'{worker.name} has no active advance — skipped.')
continue
# === NEW LOAN — create a Loan record (loan_type='loan') ===
if adj_type == 'New Loan':
# Create a new Loan object first
loan = Loan.objects.create(
worker=worker,
loan_type='loan',
principal_amount=amount,
remaining_balance=amount,
date=adj_date,
reason=description,
)
# === ADVANCE PAYMENT — create a Loan record (loan_type='advance') ===
# Works just like New Loan but tagged as 'advance' so it shows
# separately in the Loans tab and uses "Advance Repayment" to deduct.
if adj_type == 'Advance Payment':
loan = Loan.objects.create(
worker=worker,
loan_type='advance',
principal_amount=amount,
remaining_balance=amount,
date=adj_date,
reason=description or 'Salary advance',
)
PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
@ -1378,9 +1413,9 @@ def edit_adjustment(request, adj_id):
messages.error(request, 'Cannot edit a paid adjustment.')
return redirect('payroll_dashboard')
# Can't edit Advance Payments
if adj.type == 'Advance Payment':
messages.warning(request, 'Advance payments cannot be edited.')
# Can't edit repayment adjustments (managed by the loan system)
if adj.type in ('Loan Repayment', 'Advance Repayment'):
messages.warning(request, 'Repayment adjustments cannot be edited directly.')
return redirect('payroll_dashboard')
# Update fields
@ -1415,8 +1450,8 @@ def edit_adjustment(request, adj_id):
adj.save()
# If it's a Loan adjustment, sync the loan details
if adj.type == 'New Loan' and adj.loan:
# If it's a Loan or Advance adjustment, sync the loan details
if adj.type in ('New Loan', 'Advance Payment') and adj.loan:
adj.loan.principal_amount = adj.amount
adj.loan.remaining_balance = adj.amount
adj.loan.reason = adj.description
@ -1448,24 +1483,30 @@ def delete_adjustment(request, adj_id):
adj_type = adj.type
worker_name = adj.worker.name
if adj_type == 'New Loan' and adj.loan:
# Check if any paid repayments exist for this loan
# === CASCADE DELETE for New Loan and Advance Payment ===
# Both create Loan records that need cleanup when deleted.
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
# Determine which repayment type to look for
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
# Check if any paid repayments exist for this loan/advance
paid_repayments = PayrollAdjustment.objects.filter(
loan=adj.loan,
type='Loan Repayment',
type=repayment_type,
payroll_record__isnull=False,
)
if paid_repayments.exists():
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
messages.error(
request,
f'Cannot delete loan for {worker_name} — it has paid repayments.'
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
)
return redirect('payroll_dashboard')
# Delete all unpaid repayments for this loan, then the loan itself
# Delete all unpaid repayments for this loan/advance, then the loan itself
PayrollAdjustment.objects.filter(
loan=adj.loan,
type='Loan Repayment',
type=repayment_type,
payroll_record__isnull=True,
).delete()
adj.loan.delete()
@ -1523,6 +1564,22 @@ def preview_payslip(request, worker_id):
'project': adj.project.name if adj.project else '',
})
# === ACTIVE LOANS & ADVANCES ===
# Include the worker's outstanding balances so the admin can see the
# full picture and add repayments directly from the preview modal.
active_loans = worker.loans.filter(active=True).order_by('-date')
loans_list = []
for loan in active_loans:
loans_list.append({
'id': loan.id,
'type': loan.loan_type, # 'loan' or 'advance'
'type_label': loan.get_loan_type_display(), # 'Loan' or 'Advance'
'principal': float(loan.principal_amount),
'balance': float(loan.remaining_balance),
'date': loan.date.strftime('%Y-%m-%d'),
'reason': loan.reason or '',
})
return JsonResponse({
'worker_name': worker.name,
'worker_id_number': worker.id_number,
@ -1533,6 +1590,71 @@ def preview_payslip(request, worker_id):
'adj_total': adj_total,
'net_pay': log_amount + adj_total,
'logs': unpaid_logs,
'active_loans': loans_list,
})
# =============================================================================
# === ADD REPAYMENT (AJAX) ===
# Creates a Loan Repayment or Advance Repayment adjustment for a single worker.
# Called via AJAX POST from the Payslip Preview modal's inline repayment form.
# Returns JSON so the modal can refresh in-place without a page reload.
# =============================================================================
@login_required
def add_repayment_ajax(request, worker_id):
"""AJAX endpoint: add a repayment adjustment and return JSON response."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
worker = get_object_or_404(Worker, id=worker_id)
# Parse the POST body (sent as JSON from fetch())
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
loan_id = body.get('loan_id')
amount_str = body.get('amount', '0')
description = body.get('description', '')
# Validate: loan exists, belongs to this worker, and is active
try:
loan = Loan.objects.get(id=int(loan_id), worker=worker, active=True)
except (Loan.DoesNotExist, ValueError, TypeError):
return JsonResponse({'error': 'No active loan/advance found.'}, status=400)
# Validate: amount is positive
try:
amount = Decimal(str(amount_str))
if amount <= 0:
raise ValueError
except (ValueError, Exception):
return JsonResponse({'error': 'Please enter a valid amount greater than zero.'}, status=400)
# Cap the repayment at the remaining balance (prevent over-repaying)
if amount > loan.remaining_balance:
amount = loan.remaining_balance
# Pick the right repayment type based on loan type
repayment_type = 'Advance Repayment' if loan.loan_type == 'advance' else 'Loan Repayment'
# Create the adjustment (balance deduction happens later during process_payment)
PayrollAdjustment.objects.create(
worker=worker,
type=repayment_type,
amount=amount,
date=timezone.now().date(),
description=description or f'{loan.get_loan_type_display()} repayment',
loan=loan,
)
return JsonResponse({
'success': True,
'message': f'{repayment_type} of R {amount:.2f} added for {worker.name}.',
})
@ -1564,6 +1686,14 @@ def payslip_detail(request, pk):
# Calculate net adjustment amount (additive minus deductive)
adjustments_net = record.amount_paid - base_pay
# === DETECT ADVANCE-ONLY PAYMENT ===
# If payment has 0 work logs and a single Advance Payment adjustment,
# show a cleaner "advance payslip" layout instead of "0 days worked".
adjs_list = list(adjustments)
advance_adj = None
if logs.count() == 0 and len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
context = {
'record': record,
'logs': logs,
@ -1572,6 +1702,8 @@ def payslip_detail(request, pk):
'adjustments_net': adjustments_net,
'adjustments_net_abs': abs(adjustments_net),
'deductive_types': DEDUCTIVE_TYPES,
'is_advance': advance_adj is not None,
'advance_adj': advance_adj,
}
return render(request, 'core/payslip.html', context)