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)
|
- **WorkLog** — daily attendance: date, project, team, workers (M2M), supervisor, overtime, `priced_workers` (M2M)
|
||||||
- **PayrollRecord** — completed payments linked to WorkLogs (M2M) and Worker (FK)
|
- **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
|
- **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
|
- **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling
|
||||||
|
|
||||||
## Key Business Rules
|
## Key Business Rules
|
||||||
@ -58,15 +58,16 @@ staticfiles/ — Collected static assets (Bootstrap, admin)
|
|||||||
|
|
||||||
## Payroll Constants
|
## Payroll Constants
|
||||||
Defined at top of views.py — used in dashboard calculations and payment processing:
|
Defined at top of views.py — used in dashboard calculations and payment processing:
|
||||||
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan']` — increase worker's net pay
|
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
|
||||||
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Payment']` — decrease net pay
|
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay
|
||||||
|
|
||||||
## PayrollAdjustment Type Handling
|
## PayrollAdjustment Type Handling
|
||||||
- **Bonus / Deduction** — standalone, require a linked Project
|
- **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
|
- **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
|
- **Loan Repayment** — links to `Loan` (loan_type='loan') via `adj.loan` FK; loan balance changes during payment processing
|
||||||
- **Advance Payment** — requires a linked Project; reduces net pay
|
- **Advance Repayment** — links to `Loan` (loan_type='advance') via `adj.loan` FK; deducts from advance balance during payment processing
|
||||||
|
|
||||||
## Outstanding Payments Logic (Dashboard)
|
## Outstanding Payments Logic (Dashboard)
|
||||||
The dashboard's outstanding amount uses **per-worker** checking, not per-log:
|
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
|
- POST-only mutation views pattern: check `request.method != 'POST'` → redirect
|
||||||
- Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements
|
- 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
|
- 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
|
## URL Routes
|
||||||
| Path | View | Purpose |
|
| Path | View | Purpose |
|
||||||
@ -114,7 +116,8 @@ python manage.py check # System check
|
|||||||
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |
|
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |
|
||||||
| `/payroll/adjustment/<id>/edit/` | `edit_adjustment` | Admin: edit unpaid adjustment |
|
| `/payroll/adjustment/<id>/edit/` | `edit_adjustment` | Admin: edit unpaid adjustment |
|
||||||
| `/payroll/adjustment/<id>/delete/` | `delete_adjustment` | Admin: delete 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 |
|
| `/payroll/payslip/<pk>/` | `payslip_detail` | Admin: view completed payslip |
|
||||||
| `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items |
|
| `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items |
|
||||||
| `/import-data/` | `import_data` | Setup: run import command from browser |
|
| `/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}"
|
return f"{self.worker.name} - {self.date}"
|
||||||
|
|
||||||
class Loan(models.Model):
|
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')
|
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)
|
principal_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
remaining_balance = models.DecimalField(max_digits=10, decimal_places=2)
|
remaining_balance = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
date = models.DateField(default=timezone.now)
|
date = models.DateField(default=timezone.now)
|
||||||
@ -111,7 +122,8 @@ class Loan(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
class PayrollAdjustment(models.Model):
|
||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
@ -121,6 +133,7 @@ class PayrollAdjustment(models.Model):
|
|||||||
('Loan Repayment', 'Loan Repayment'),
|
('Loan Repayment', 'Loan Repayment'),
|
||||||
('New Loan', 'New Loan'),
|
('New Loan', 'New Loan'),
|
||||||
('Advance Payment', 'Advance Payment'),
|
('Advance Payment', 'Advance Payment'),
|
||||||
|
('Advance Repayment', 'Advance Repayment'),
|
||||||
]
|
]
|
||||||
|
|
||||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
|
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}" href="{% url 'work_history' %}">
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if user.is_staff %}
|
{% if user.is_staff %}
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
|
<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 class="h5 mb-0 font-weight-bold text-gray-800">R {{ active_loans_balance|floatformat:2 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-25"></i>
|
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-25"></i>
|
||||||
@ -180,7 +180,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -217,7 +217,7 @@
|
|||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{# Show each pending adjustment as a badge #}
|
{# Show each pending adjustment as a badge #}
|
||||||
{% for adj in wd.adjustments %}
|
{% 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;"
|
style="cursor: pointer;"
|
||||||
data-adj-id="{{ adj.id }}"
|
data-adj-id="{{ adj.id }}"
|
||||||
data-adj-type="{{ adj.type }}"
|
data-adj-type="{{ adj.type }}"
|
||||||
@ -226,7 +226,7 @@
|
|||||||
data-adj-description="{{ adj.description }}"
|
data-adj-description="{{ adj.description }}"
|
||||||
data-adj-project="{{ adj.project_id|default:'' }}"
|
data-adj-project="{{ adj.project_id|default:'' }}"
|
||||||
data-adj-worker="{{ adj.worker.name }}">
|
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 }}
|
{{ adj.type }}
|
||||||
{% if adj.project %}({{ adj.project.name }}){% endif %}
|
{% if adj.project %}({{ adj.project.name }}){% endif %}
|
||||||
</span>
|
</span>
|
||||||
@ -300,7 +300,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% for adj in record.adjustments.all %}
|
{% 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 }}
|
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
|
||||||
</span>
|
</span>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@ -335,11 +335,11 @@
|
|||||||
<div class="mb-3 d-flex gap-2">
|
<div class="mb-3 d-flex gap-2">
|
||||||
<a href="?status=loans&loan_status=active"
|
<a href="?status=loans&loan_status=active"
|
||||||
class="btn btn-sm {% if loan_filter == 'active' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
class="btn btn-sm {% if loan_filter == 'active' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
||||||
Active Loans
|
Active
|
||||||
</a>
|
</a>
|
||||||
<a href="?status=loans&loan_status=history"
|
<a href="?status=loans&loan_status=history"
|
||||||
class="btn btn-sm {% if loan_filter == 'history' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
class="btn btn-sm {% if loan_filter == 'history' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
||||||
Loan History
|
History
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
@ -349,6 +349,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="ps-4">Worker</th>
|
<th scope="col" class="ps-4">Worker</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
<th scope="col">Principal</th>
|
<th scope="col">Principal</th>
|
||||||
<th scope="col">Balance</th>
|
<th scope="col">Balance</th>
|
||||||
<th scope="col">Date</th>
|
<th scope="col">Date</th>
|
||||||
@ -360,6 +361,13 @@
|
|||||||
{% for loan in loans %}
|
{% for loan in loans %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4 align-middle"><strong>{{ loan.worker.name }}</strong></td>
|
<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.principal_amount|floatformat:2 }}</td>
|
||||||
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
|
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
|
||||||
<td class="align-middle">{{ loan.date }}</td>
|
<td class="align-middle">{{ loan.date }}</td>
|
||||||
@ -374,9 +382,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -649,7 +657,7 @@
|
|||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" id="previewPayslipBody">
|
<div class="modal-body" id="previewPayslipBody">
|
||||||
@ -1164,8 +1172,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// only enforces the project when the type actually needs one.
|
// only enforces the project when the type actually needs one.
|
||||||
function toggleProjectField() {
|
function toggleProjectField() {
|
||||||
if (!addAdjType || !addAdjProjectGroup) return;
|
if (!addAdjType || !addAdjProjectGroup) return;
|
||||||
// Loan and Loan Repayment don't need a project
|
// Loan types, repayments, and advances don't need a project
|
||||||
var noProjectTypes = ['New Loan', 'Loan Repayment'];
|
var noProjectTypes = ['New Loan', 'Loan Repayment', 'Advance Payment', 'Advance Repayment'];
|
||||||
var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1;
|
var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1;
|
||||||
addAdjProjectGroup.style.display = needsProject ? '' : 'none';
|
addAdjProjectGroup.style.display = needsProject ? '' : 'none';
|
||||||
if (addAdjProject) {
|
if (addAdjProject) {
|
||||||
@ -1312,30 +1320,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// PREVIEW PAYSLIP — Fetch JSON and build DOM (no innerHTML for data)
|
// 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.
|
||||||
// =================================================================
|
// =================================================================
|
||||||
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');
|
|
||||||
|
|
||||||
|
// Helper: fetch preview data and build the modal content
|
||||||
|
function refreshPreview(workerId, modalBody) {
|
||||||
// Show loading spinner (safe hardcoded content)
|
// Show loading spinner (safe hardcoded content)
|
||||||
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
|
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
|
||||||
const loadingDiv = document.createElement('div');
|
var loadingDiv = document.createElement('div');
|
||||||
loadingDiv.className = 'text-center py-4';
|
loadingDiv.className = 'text-center py-4';
|
||||||
const spinner = document.createElement('div');
|
var spinner = document.createElement('div');
|
||||||
spinner.className = 'spinner-border text-primary';
|
spinner.className = 'spinner-border text-primary';
|
||||||
spinner.setAttribute('role', 'status');
|
spinner.setAttribute('role', 'status');
|
||||||
loadingDiv.appendChild(spinner);
|
loadingDiv.appendChild(spinner);
|
||||||
const loadingText = document.createElement('p');
|
var loadingText = document.createElement('p');
|
||||||
loadingText.className = 'text-muted mt-2 small';
|
loadingText.className = 'text-muted mt-2 small';
|
||||||
loadingText.textContent = 'Loading preview...';
|
loadingText.textContent = 'Loading preview...';
|
||||||
loadingDiv.appendChild(loadingText);
|
loadingDiv.appendChild(loadingText);
|
||||||
modalBody.appendChild(loadingDiv);
|
modalBody.appendChild(loadingDiv);
|
||||||
|
|
||||||
// Show modal
|
|
||||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
|
|
||||||
|
|
||||||
// Fetch preview data
|
// Fetch preview data
|
||||||
fetch('/payroll/preview/' + workerId + '/')
|
fetch('/payroll/preview/' + workerId + '/')
|
||||||
.then(function(resp) {
|
.then(function(resp) {
|
||||||
@ -1349,14 +1353,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// === Build payslip preview using DOM methods ===
|
// === Build payslip preview using DOM methods ===
|
||||||
|
|
||||||
// Worker header
|
// Worker header
|
||||||
const header = document.createElement('div');
|
var header = document.createElement('div');
|
||||||
header.className = 'border-bottom pb-3 mb-3';
|
header.className = 'border-bottom pb-3 mb-3';
|
||||||
const h4 = document.createElement('h4');
|
var h4 = document.createElement('h4');
|
||||||
h4.className = 'mb-1';
|
h4.className = 'mb-1';
|
||||||
h4.textContent = data.worker_name;
|
h4.textContent = data.worker_name;
|
||||||
header.appendChild(h4);
|
header.appendChild(h4);
|
||||||
if (data.worker_id_number) {
|
if (data.worker_id_number) {
|
||||||
const idP = document.createElement('p');
|
var idP = document.createElement('p');
|
||||||
idP.className = 'text-muted small mb-0';
|
idP.className = 'text-muted small mb-0';
|
||||||
idP.textContent = 'ID: ' + data.worker_id_number;
|
idP.textContent = 'ID: ' + data.worker_id_number;
|
||||||
header.appendChild(idP);
|
header.appendChild(idP);
|
||||||
@ -1364,44 +1368,44 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
modalBody.appendChild(header);
|
modalBody.appendChild(header);
|
||||||
|
|
||||||
// Earnings section
|
// Earnings section
|
||||||
const earningsH6 = document.createElement('h6');
|
var earningsH6 = document.createElement('h6');
|
||||||
earningsH6.className = 'fw-bold mb-2';
|
earningsH6.className = 'fw-bold mb-2';
|
||||||
earningsH6.textContent = 'Earnings';
|
earningsH6.textContent = 'Earnings';
|
||||||
modalBody.appendChild(earningsH6);
|
modalBody.appendChild(earningsH6);
|
||||||
|
|
||||||
const earningsRow = document.createElement('div');
|
var earningsRow = document.createElement('div');
|
||||||
earningsRow.className = 'd-flex justify-content-between mb-1';
|
earningsRow.className = 'd-flex justify-content-between mb-1';
|
||||||
const earningsLabel = document.createElement('span');
|
var earningsLabel = document.createElement('span');
|
||||||
earningsLabel.textContent = data.days_worked + ' day(s) × ' + fmt(data.day_rate);
|
earningsLabel.textContent = data.days_worked + ' day(s) \u00d7 ' + fmt(data.day_rate);
|
||||||
earningsRow.appendChild(earningsLabel);
|
earningsRow.appendChild(earningsLabel);
|
||||||
const earningsVal = document.createElement('strong');
|
var earningsVal = document.createElement('strong');
|
||||||
earningsVal.textContent = fmt(data.log_amount);
|
earningsVal.textContent = fmt(data.log_amount);
|
||||||
earningsRow.appendChild(earningsVal);
|
earningsRow.appendChild(earningsVal);
|
||||||
modalBody.appendChild(earningsRow);
|
modalBody.appendChild(earningsRow);
|
||||||
|
|
||||||
// Adjustments section
|
// Adjustments section
|
||||||
if (data.adjustments && data.adjustments.length > 0) {
|
if (data.adjustments && data.adjustments.length > 0) {
|
||||||
const adjHr = document.createElement('hr');
|
var adjHr = document.createElement('hr');
|
||||||
modalBody.appendChild(adjHr);
|
modalBody.appendChild(adjHr);
|
||||||
|
|
||||||
const adjH6 = document.createElement('h6');
|
var adjH6 = document.createElement('h6');
|
||||||
adjH6.className = 'fw-bold mb-2';
|
adjH6.className = 'fw-bold mb-2';
|
||||||
adjH6.textContent = 'Adjustments';
|
adjH6.textContent = 'Adjustments';
|
||||||
modalBody.appendChild(adjH6);
|
modalBody.appendChild(adjH6);
|
||||||
|
|
||||||
data.adjustments.forEach(function(adj) {
|
data.adjustments.forEach(function(adj) {
|
||||||
const row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'd-flex justify-content-between mb-1';
|
row.className = 'd-flex justify-content-between mb-1';
|
||||||
const label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
|
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
|
||||||
if (adj.description) {
|
if (adj.description) {
|
||||||
const descSmall = document.createElement('small');
|
var descSmall = document.createElement('small');
|
||||||
descSmall.className = 'text-muted ms-1';
|
descSmall.className = 'text-muted ms-1';
|
||||||
descSmall.textContent = '— ' + adj.description;
|
descSmall.textContent = '\u2014 ' + adj.description;
|
||||||
label.appendChild(descSmall);
|
label.appendChild(descSmall);
|
||||||
}
|
}
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
const val = document.createElement('span');
|
var val = document.createElement('span');
|
||||||
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
|
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
|
||||||
val.textContent = adj.sign + fmt(adj.amount);
|
val.textContent = adj.sign + fmt(adj.amount);
|
||||||
row.appendChild(val);
|
row.appendChild(val);
|
||||||
@ -1410,47 +1414,197 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Net pay
|
// Net pay
|
||||||
const netHr = document.createElement('hr');
|
var netHr = document.createElement('hr');
|
||||||
netHr.className = 'my-3';
|
netHr.className = 'my-3';
|
||||||
modalBody.appendChild(netHr);
|
modalBody.appendChild(netHr);
|
||||||
|
|
||||||
const netRow = document.createElement('div');
|
var netRow = document.createElement('div');
|
||||||
netRow.className = 'd-flex justify-content-between';
|
netRow.className = 'd-flex justify-content-between';
|
||||||
const netLabel = document.createElement('h5');
|
var netLabel = document.createElement('h5');
|
||||||
netLabel.className = 'fw-bold';
|
netLabel.className = 'fw-bold';
|
||||||
netLabel.textContent = 'Net Pay';
|
netLabel.textContent = 'Net Pay';
|
||||||
netRow.appendChild(netLabel);
|
netRow.appendChild(netLabel);
|
||||||
const netVal = document.createElement('h5');
|
var netVal = document.createElement('h5');
|
||||||
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
|
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
|
||||||
netVal.textContent = fmt(data.net_pay);
|
netVal.textContent = fmt(data.net_pay);
|
||||||
netRow.appendChild(netVal);
|
netRow.appendChild(netVal);
|
||||||
modalBody.appendChild(netRow);
|
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
|
// Work log details
|
||||||
if (data.logs && data.logs.length > 0) {
|
if (data.logs && data.logs.length > 0) {
|
||||||
const logsHr = document.createElement('hr');
|
var wlHr = document.createElement('hr');
|
||||||
modalBody.appendChild(logsHr);
|
modalBody.appendChild(wlHr);
|
||||||
|
|
||||||
const logsH6 = document.createElement('h6');
|
var logsH6b = document.createElement('h6');
|
||||||
logsH6.className = 'fw-bold mb-2';
|
logsH6b.className = 'fw-bold mb-2';
|
||||||
logsH6.textContent = 'Work Log Details';
|
logsH6b.textContent = 'Work Log Details';
|
||||||
modalBody.appendChild(logsH6);
|
modalBody.appendChild(logsH6b);
|
||||||
|
|
||||||
const table = document.createElement('table');
|
var table = document.createElement('table');
|
||||||
table.className = 'table table-sm mb-0';
|
table.className = 'table table-sm mb-0';
|
||||||
const thead = document.createElement('thead');
|
var thead = document.createElement('thead');
|
||||||
const headRow = document.createElement('tr');
|
var headRow = document.createElement('tr');
|
||||||
['Date', 'Project'].forEach(function(h) {
|
['Date', 'Project'].forEach(function(h) {
|
||||||
const th = document.createElement('th');
|
var th = document.createElement('th');
|
||||||
th.textContent = h;
|
th.textContent = h;
|
||||||
headRow.appendChild(th);
|
headRow.appendChild(th);
|
||||||
});
|
});
|
||||||
thead.appendChild(headRow);
|
thead.appendChild(headRow);
|
||||||
table.appendChild(thead);
|
table.appendChild(thead);
|
||||||
|
|
||||||
const tbody = document.createElement('tbody');
|
var tbody = document.createElement('tbody');
|
||||||
data.logs.forEach(function(log) {
|
data.logs.forEach(function(log) {
|
||||||
const tr = document.createElement('tr');
|
var tr = document.createElement('tr');
|
||||||
tr.appendChild(createTd(log.date));
|
tr.appendChild(createTd(log.date));
|
||||||
tr.appendChild(createTd(log.project));
|
tr.appendChild(createTd(log.project));
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@ -1462,17 +1616,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.catch(function() {
|
.catch(function() {
|
||||||
// Show error (safe hardcoded content)
|
// Show error (safe hardcoded content)
|
||||||
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
|
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
|
||||||
const errDiv = document.createElement('div');
|
var errDiv = document.createElement('div');
|
||||||
errDiv.className = 'text-center py-4 text-danger';
|
errDiv.className = 'text-center py-4 text-danger';
|
||||||
const errIcon = document.createElement('i');
|
var errIcon3 = document.createElement('i');
|
||||||
errIcon.className = 'fas fa-exclamation-triangle fs-3';
|
errIcon3.className = 'fas fa-exclamation-triangle fs-3';
|
||||||
errDiv.appendChild(errIcon);
|
errDiv.appendChild(errIcon3);
|
||||||
const errText = document.createElement('p');
|
var errText = document.createElement('p');
|
||||||
errText.className = 'mt-2';
|
errText.className = 'mt-2';
|
||||||
errText.textContent = 'Could not load preview.';
|
errText.textContent = 'Could not load preview.';
|
||||||
errDiv.appendChild(errText);
|
errDiv.appendChild(errText);
|
||||||
modalBody.appendChild(errDiv);
|
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() {
|
||||||
|
var workerId = this.dataset.workerId;
|
||||||
|
var modalBody = document.getElementById('previewPayslipBody');
|
||||||
|
|
||||||
|
// Show modal first, then fetch data
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
|
||||||
|
refreshPreview(workerId, modalBody);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -28,10 +28,10 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
|
<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>
|
<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>
|
||||||
<div class="col-md-6 text-md-end mt-3 mt-md-0">
|
<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="fw-bold">{{ record.date|date:"F j, Y" }}</div>
|
||||||
<div class="text-muted small">Payer: Fox Fitt</div>
|
<div class="text-muted small">Payer: Fox Fitt</div>
|
||||||
</div>
|
</div>
|
||||||
@ -54,6 +54,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 === -->
|
<!-- === WORK LOG TABLE — each day worked === -->
|
||||||
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
|
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
@ -153,6 +190,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- === FOOTER === -->
|
<!-- === FOOTER === -->
|
||||||
<div class="text-center text-muted small mt-5 pt-4 border-top">
|
<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)
|
# Preview a worker's payslip (AJAX — returns JSON)
|
||||||
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
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)
|
# View a completed payslip (print-friendly page)
|
||||||
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
|
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 ===
|
# === PAYROLL CONSTANTS ===
|
||||||
# These define which adjustment types ADD to a worker's pay vs SUBTRACT from it.
|
# 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.
|
# "New Loan" and "Advance Payment" are additive — the worker receives money upfront.
|
||||||
# "Loan Repayment" and "Advance Payment" are deductive — they reduce net pay.
|
# "Loan Repayment" and "Advance Repayment" are deductive — they reduce net pay.
|
||||||
ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan']
|
ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']
|
||||||
DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Payment']
|
DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Repayment']
|
||||||
|
|
||||||
|
|
||||||
# === PERMISSION HELPERS ===
|
# === PERMISSION HELPERS ===
|
||||||
@ -1138,8 +1138,8 @@ def process_payment(request, worker_id):
|
|||||||
adj.payroll_record = payroll_record
|
adj.payroll_record = payroll_record
|
||||||
adj.save()
|
adj.save()
|
||||||
|
|
||||||
# If this is a loan repayment, deduct from the loan balance
|
# If this is a loan or advance repayment, deduct from the balance
|
||||||
if adj.type == 'Loan Repayment' and adj.loan:
|
if adj.type in ('Loan Repayment', 'Advance Repayment') and adj.loan:
|
||||||
adj.loan.remaining_balance -= adj.amount
|
adj.loan.remaining_balance -= adj.amount
|
||||||
if adj.loan.remaining_balance <= 0:
|
if adj.loan.remaining_balance <= 0:
|
||||||
adj.loan.remaining_balance = Decimal('0.00')
|
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
|
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
|
||||||
from .utils import render_to_pdf
|
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
|
# Context for both the HTML email body and the PDF attachment
|
||||||
email_context = {
|
email_context = {
|
||||||
@ -1163,6 +1174,9 @@ def process_payment(request, worker_id):
|
|||||||
'logs_amount': logs_amount,
|
'logs_amount': logs_amount,
|
||||||
'adjustments': payroll_record.adjustments.all(),
|
'adjustments': payroll_record.adjustments.all(),
|
||||||
'deductive_types': DEDUCTIVE_TYPES,
|
'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
|
# 1. Render HTML email body
|
||||||
@ -1312,7 +1326,7 @@ def add_adjustment(request):
|
|||||||
except Project.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
pass
|
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:
|
if adj_type in project_required_types and not project:
|
||||||
messages.error(request, 'A project must be selected for this adjustment type.')
|
messages.error(request, 'A project must be selected for this adjustment type.')
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
@ -1326,23 +1340,44 @@ def add_adjustment(request):
|
|||||||
|
|
||||||
loan = None
|
loan = None
|
||||||
|
|
||||||
|
# === LOAN REPAYMENT — find the worker's active loan ===
|
||||||
if adj_type == 'Loan Repayment':
|
if adj_type == 'Loan Repayment':
|
||||||
# Find the worker's active loan
|
loan = worker.loans.filter(active=True, loan_type='loan').first()
|
||||||
loan = worker.loans.filter(active=True).first()
|
|
||||||
if not loan:
|
if not loan:
|
||||||
messages.warning(request, f'{worker.name} has no active loan — skipped.')
|
messages.warning(request, f'{worker.name} has no active loan — skipped.')
|
||||||
continue
|
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':
|
if adj_type == 'New Loan':
|
||||||
# Create a new Loan object first
|
|
||||||
loan = Loan.objects.create(
|
loan = Loan.objects.create(
|
||||||
worker=worker,
|
worker=worker,
|
||||||
|
loan_type='loan',
|
||||||
principal_amount=amount,
|
principal_amount=amount,
|
||||||
remaining_balance=amount,
|
remaining_balance=amount,
|
||||||
date=adj_date,
|
date=adj_date,
|
||||||
reason=description,
|
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(
|
PayrollAdjustment.objects.create(
|
||||||
worker=worker,
|
worker=worker,
|
||||||
type=adj_type,
|
type=adj_type,
|
||||||
@ -1378,9 +1413,9 @@ def edit_adjustment(request, adj_id):
|
|||||||
messages.error(request, 'Cannot edit a paid adjustment.')
|
messages.error(request, 'Cannot edit a paid adjustment.')
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
# Can't edit Advance Payments
|
# Can't edit repayment adjustments (managed by the loan system)
|
||||||
if adj.type == 'Advance Payment':
|
if adj.type in ('Loan Repayment', 'Advance Repayment'):
|
||||||
messages.warning(request, 'Advance payments cannot be edited.')
|
messages.warning(request, 'Repayment adjustments cannot be edited directly.')
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
@ -1415,8 +1450,8 @@ def edit_adjustment(request, adj_id):
|
|||||||
|
|
||||||
adj.save()
|
adj.save()
|
||||||
|
|
||||||
# If it's a Loan adjustment, sync the loan details
|
# If it's a Loan or Advance adjustment, sync the loan details
|
||||||
if adj.type == 'New Loan' and adj.loan:
|
if adj.type in ('New Loan', 'Advance Payment') and adj.loan:
|
||||||
adj.loan.principal_amount = adj.amount
|
adj.loan.principal_amount = adj.amount
|
||||||
adj.loan.remaining_balance = adj.amount
|
adj.loan.remaining_balance = adj.amount
|
||||||
adj.loan.reason = adj.description
|
adj.loan.reason = adj.description
|
||||||
@ -1448,24 +1483,30 @@ def delete_adjustment(request, adj_id):
|
|||||||
adj_type = adj.type
|
adj_type = adj.type
|
||||||
worker_name = adj.worker.name
|
worker_name = adj.worker.name
|
||||||
|
|
||||||
if adj_type == 'New Loan' and adj.loan:
|
# === CASCADE DELETE for New Loan and Advance Payment ===
|
||||||
# Check if any paid repayments exist for this loan
|
# 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(
|
paid_repayments = PayrollAdjustment.objects.filter(
|
||||||
loan=adj.loan,
|
loan=adj.loan,
|
||||||
type='Loan Repayment',
|
type=repayment_type,
|
||||||
payroll_record__isnull=False,
|
payroll_record__isnull=False,
|
||||||
)
|
)
|
||||||
if paid_repayments.exists():
|
if paid_repayments.exists():
|
||||||
|
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
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')
|
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(
|
PayrollAdjustment.objects.filter(
|
||||||
loan=adj.loan,
|
loan=adj.loan,
|
||||||
type='Loan Repayment',
|
type=repayment_type,
|
||||||
payroll_record__isnull=True,
|
payroll_record__isnull=True,
|
||||||
).delete()
|
).delete()
|
||||||
adj.loan.delete()
|
adj.loan.delete()
|
||||||
@ -1523,6 +1564,22 @@ def preview_payslip(request, worker_id):
|
|||||||
'project': adj.project.name if adj.project else '',
|
'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({
|
return JsonResponse({
|
||||||
'worker_name': worker.name,
|
'worker_name': worker.name,
|
||||||
'worker_id_number': worker.id_number,
|
'worker_id_number': worker.id_number,
|
||||||
@ -1533,6 +1590,71 @@ def preview_payslip(request, worker_id):
|
|||||||
'adj_total': adj_total,
|
'adj_total': adj_total,
|
||||||
'net_pay': log_amount + adj_total,
|
'net_pay': log_amount + adj_total,
|
||||||
'logs': unpaid_logs,
|
'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)
|
# Calculate net adjustment amount (additive minus deductive)
|
||||||
adjustments_net = record.amount_paid - base_pay
|
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 = {
|
context = {
|
||||||
'record': record,
|
'record': record,
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
@ -1572,6 +1702,8 @@ def payslip_detail(request, pk):
|
|||||||
'adjustments_net': adjustments_net,
|
'adjustments_net': adjustments_net,
|
||||||
'adjustments_net_abs': abs(adjustments_net),
|
'adjustments_net_abs': abs(adjustments_net),
|
||||||
'deductive_types': DEDUCTIVE_TYPES,
|
'deductive_types': DEDUCTIVE_TYPES,
|
||||||
|
'is_advance': advance_adj is not None,
|
||||||
|
'advance_adj': advance_adj,
|
||||||
}
|
}
|
||||||
return render(request, 'core/payslip.html', context)
|
return render(request, 'core/payslip.html', context)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user