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:
parent
19c662ec7d
commit
0257b454af
17
CLAUDE.md
17
CLAUDE.md
@ -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 |
|
||||
|
||||
23
core/migrations/0004_add_loan_type_and_advance_repayment.py
Normal file
23
core/migrations/0004_add_loan_type_and_advance_repayment.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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')
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
176
core/views.py
176
core/views.py
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user