ver 8 loans added

This commit is contained in:
Flatlogic Bot 2026-02-03 23:42:44 +00:00
parent fa07cb69a8
commit 99fb2f4e10
14 changed files with 537 additions and 35 deletions

View File

@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2026-02-03 23:09
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_payrollrecord'),
]
operations = [
migrations.CreateModel(
name='Loan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, help_text='Principal amount borrowed', max_digits=10)),
('balance', models.DecimalField(decimal_places=2, default=0, help_text='Remaining amount to be repaid', max_digits=10)),
('date', models.DateField(default=django.utils.timezone.now)),
('reason', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loans', to='core.worker')),
],
),
migrations.CreateModel(
name='PayrollAdjustment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, help_text='Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)', max_digits=10)),
('date', models.DateField(default=django.utils.timezone.now)),
('description', models.CharField(max_length=255)),
('type', models.CharField(choices=[('BONUS', 'Bonus'), ('OVERTIME', 'Overtime'), ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment')], default='DEDUCTION', max_length=20)),
('loan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repayments', to='core.loan')),
('payroll_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments', to='core.payrollrecord')),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='core.worker')),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-03 23:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_loan_payrolladjustment'),
]
operations = [
migrations.AlterField(
model_name='payrolladjustment',
name='type',
field=models.CharField(choices=[('BONUS', 'Bonus'), ('OVERTIME', 'Overtime'), ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment'), ('LOAN', 'New Loan')], default='DEDUCTION', max_length=20),
),
]

View File

@ -66,3 +66,39 @@ class PayrollRecord(models.Model):
def __str__(self):
return f"Payment to {self.worker.name} on {self.date}"
class Loan(models.Model):
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Principal amount borrowed")
balance = models.DecimalField(max_digits=10, decimal_places=2, default=0, help_text="Remaining amount to be repaid")
date = models.DateField(default=timezone.now)
reason = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if not self.pk: # On creation
self.balance = self.amount
super().save(*args, **kwargs)
def __str__(self):
return f"Loan for {self.worker.name} - R{self.amount}"
class PayrollAdjustment(models.Model):
ADJUSTMENT_TYPES = [
('BONUS', 'Bonus'),
('OVERTIME', 'Overtime'),
('DEDUCTION', 'Deduction'),
('LOAN_REPAYMENT', 'Loan Repayment'),
('LOAN', 'New Loan'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments')
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)")
date = models.DateField(default=timezone.now)
description = models.CharField(max_length=255)
type = models.CharField(max_length=20, choices=ADJUSTMENT_TYPES, default='DEDUCTION')
def __str__(self):
return f"{self.get_type_display()} - {self.amount} for {self.worker.name}"

View File

@ -34,6 +34,7 @@
<li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'work_log_list' %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'loan_list' %}">Loans</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'manage_resources' %}">Manage</a></li>
<li class="nav-item ms-lg-3"><a class="btn btn-sm btn-outline-light" href="/admin/">Admin Panel</a></li>
</ul>

View File

@ -0,0 +1,116 @@
{% extends 'base.html' %}
{% block title %}Loan Management - Fox Fitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold mb-0">Loan Management</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addLoanModal">
+ New Loan
</button>
</div>
<!-- Filters -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body p-2">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link {% if filter_status == 'active' %}active{% endif %}" href="?status=active">Outstanding Loans</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_status == 'history' %}active{% endif %}" href="?status=history">Loan History (Repaid)</a>
</li>
</ul>
</div>
</div>
<!-- Loan Table -->
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Date Issued</th>
<th>Worker</th>
<th>Original Amount</th>
<th>Balance (Outstanding)</th>
<th>Reason</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 text-nowrap">{{ loan.date|date:"M d, Y" }}</td>
<td class="fw-medium">{{ loan.worker.name }}</td>
<td>R {{ loan.amount }}</td>
<td class="fw-bold {% if loan.balance > 0 %}text-danger{% else %}text-success{% endif %}">
R {{ loan.balance }}
</td>
<td><small class="text-muted">{{ loan.reason|default:"-" }}</small></td>
<td>
{% if loan.is_active %}
<span class="badge bg-warning text-dark">Active</span>
{% else %}
<span class="badge bg-success">Repaid</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
No loans found in this category.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add Loan Modal -->
<div class="modal fade" id="addLoanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Issue New Loan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'add_loan' %}" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Worker</label>
<select name="worker" class="form-select" required>
<option value="">Select a worker...</option>
{% for worker in workers %}
<option value="{{ worker.id }}">{{ worker.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Date Issued</label>
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
</div>
<div class="mb-3">
<label class="form-label">Reason / Notes</label>
<textarea name="reason" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Loan</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -7,6 +7,12 @@
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
<div>
<a href="{% url 'loan_list' %}" class="btn btn-outline-secondary me-2">Manage Loans</a>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
+ Add Adjustment
</button>
</div>
</div>
<!-- Analytics Section -->
@ -17,7 +23,7 @@
<div class="card-body">
<h6 class="text-uppercase text-muted fw-bold small">Outstanding Payments</h6>
<div class="display-6 fw-bold text-dark">R {{ outstanding_total|intcomma }}</div>
<p class="text-muted small mb-0">Total pending for active workers</p>
<p class="text-muted small mb-0">Total pending (including adjustments)</p>
</div>
</div>
</div>
@ -79,8 +85,8 @@
<thead class="bg-light">
<tr>
<th class="ps-4">Worker Name</th>
<th>Unpaid Logs</th>
<th>Total Owed</th>
<th>Breakdown</th>
<th>Net Payable</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
@ -90,20 +96,39 @@
<td class="ps-4">
<div class="fw-bold text-dark">{{ item.worker.name }}</div>
<div class="small text-muted">ID: {{ item.worker.id_no }}</div>
{% if item.adjustments %}
<div class="mt-1">
{% for adj in item.adjustments %}
<span class="badge bg-secondary opacity-75 small">
{{ adj.get_type_display }}: R {{ adj.amount }}
</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ item.unpaid_count }} days</span>
<div class="small">
<div>Work: {{ item.unpaid_count }} days (R {{ item.unpaid_amount|intcomma }})</div>
<div class="{% if item.adj_amount < 0 %}text-danger{% else %}text-success{% endif %}">
Adjustments: R {{ item.adj_amount|intcomma }}
</div>
</div>
</td>
<td class="fw-bold text-success">
R {{ item.unpaid_amount|intcomma }}
<td class="fw-bold fs-5 {% if item.total_payable < 0 %}text-danger{% else %}text-success{% endif %}">
R {{ item.total_payable|intcomma }}
</td>
<td class="text-end pe-4">
{% if item.total_payable > 0 %}
<form action="{% url 'process_payment' item.worker.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Confirm payment of R {{ item.unpaid_amount }} to {{ item.worker.name }}? This will email the receipt to accounting.')">
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now
</button>
</form>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
{% endif %}
</td>
</tr>
{% endfor %}
@ -138,7 +163,7 @@
<th class="ps-4">Date</th>
<th>Payslip ID</th>
<th>Worker</th>
<th>Amount</th>
<th>Net Amount</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
@ -172,4 +197,55 @@
{% endif %}
</div>
<!-- Add Adjustment Modal -->
<div class="modal fade" id="addAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Add Payroll Adjustment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'add_adjustment' %}" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Worker</label>
<select name="worker" class="form-select" required>
<option value="">Select a worker...</option>
{% for worker in all_workers %}
<option value="{{ worker.id }}">{{ worker.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select" required>
{% for code, label in adjustment_types %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
<div class="form-text">For deductions, enter a positive number. It will be subtracted automatically.</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" name="description" class="form-control" placeholder="e.g. Public Holiday Bonus" required>
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Adjustment</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -41,9 +41,9 @@
</div>
<!-- Work Details -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details</h6>
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<div class="table-responsive mb-4">
<table class="table table-bordered">
<table class="table table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
@ -60,17 +60,84 @@
<td>{{ log.notes|default:"-"|truncatechars:50 }}</td>
<td class="text-end">R {{ record.worker.day_rate|intcomma }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted">No work logs in this period.</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">Total</td>
<td class="text-end fw-bold">R {{ record.amount|intcomma }}</td>
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
<td class="text-end fw-bold">R {{ base_pay|intcomma }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Adjustments -->
{% if adjustments %}
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</h6>
<div class="table-responsive mb-4">
<table class="table table-bordered 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>
{% for adj in adjustments %}
<tr>
<td>{{ adj.date|date:"M d, Y" }}</td>
<td>
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
</td>
<td>{{ adj.description }}</td>
<td class="text-end {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}text-danger{% else %}text-success{% endif %}">
{% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}
- R {{ adj.amount|intcomma }}
{% else %}
+ R {{ adj.amount|intcomma }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Grand Total -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr>
<td class="text-end border-0 text-muted">Base Pay:</td>
<td class="text-end border-0" width="120">R {{ base_pay|intcomma }}</td>
</tr>
{% if adjustments %}
<tr>
<td class="text-end border-0 text-muted">Adjustments Net:</td>
<td class="text-end border-0">
{% if record.amount >= base_pay %}
+ R {{ record.amount|sub:base_pay|intcomma }}
{% else %}
- R {{ base_pay|sub:record.amount|intcomma }}
{% endif %}
</td>
</tr>
{% endif %}
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount|intcomma }}</td>
</tr>
</table>
</div>
</div>
<!-- Footer -->
<div class="text-center text-muted small mt-5 pt-4 border-top">
<p>This is a computer-generated document and does not require a signature.</p>

View File

@ -8,7 +8,10 @@ from .views import (
toggle_resource_status,
payroll_dashboard,
process_payment,
payslip_detail
payslip_detail,
loan_list,
add_loan,
add_adjustment
)
urlpatterns = [
@ -21,4 +24,7 @@ urlpatterns = [
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
path("loans/", loan_list, name="loan_list"),
path("loans/add/", add_loan, name="add_loan"),
path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"),
]

View File

@ -10,9 +10,10 @@ from django.core.mail import send_mail
from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse, HttpResponse
from .models import Worker, Project, Team, WorkLog, PayrollRecord
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment
from .forms import WorkLogForm
from datetime import timedelta
from decimal import Decimal
def home(request):
"""Render the landing screen with dashboard stats."""
@ -22,7 +23,7 @@ def home(request):
recent_logs = WorkLog.objects.order_by('-date')[:5]
# Analytics
# 1. Outstanding Payments
# 1. Outstanding Payments (Approximate, from logs only)
outstanding_total = 0
active_workers = Worker.objects.filter(is_active=True)
for worker in active_workers:
@ -317,22 +318,40 @@ def payroll_dashboard(request):
# Common Analytics
outstanding_total = 0
active_workers = Worker.objects.filter(is_active=True).order_by('name')
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments')
workers_data = [] # For pending payments
for worker in active_workers:
# Unpaid Work Logs
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
count = unpaid_logs.count()
amount = count * worker.day_rate
log_count = unpaid_logs.count()
log_amount = log_count * worker.day_rate
# Pending Adjustments (unlinked to any payroll record)
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_total = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_total += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_total -= adj.amount
total_payable = log_amount + adj_total
# Only show if there is something to pay or negative (e.g. loan repayment greater than work)
# Note: If total_payable is negative, it implies they owe money.
if log_count > 0 or pending_adjustments.exists():
outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total
if count > 0:
outstanding_total += amount
if status_filter in ['pending', 'all']:
workers_data.append({
'worker': worker,
'unpaid_count': count,
'unpaid_amount': amount,
'unpaid_count': log_count,
'unpaid_amount': log_amount,
'adj_amount': adj_total,
'total_payable': total_payable,
'adjustments': pending_adjustments,
'logs': unpaid_logs
})
@ -358,6 +377,9 @@ def payroll_dashboard(request):
two_months_ago = timezone.now().date() - timedelta(days=60)
recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0
# Active Loans for dropdowns/modals
all_workers = Worker.objects.filter(is_active=True).order_by('name')
context = {
'workers_data': workers_data,
'paid_records': paid_records,
@ -365,30 +387,57 @@ def payroll_dashboard(request):
'project_costs': project_costs,
'recent_payments_total': recent_payments_total,
'active_tab': status_filter,
'all_workers': all_workers,
'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES,
}
return render(request, 'core/payroll_dashboard.html', context)
def process_payment(request, worker_id):
"""Process payment for a worker, mark logs as paid, and email receipt."""
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt."""
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == 'POST':
# Find unpaid logs
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
count = unpaid_logs.count()
log_count = unpaid_logs.count()
logs_amount = log_count * worker.day_rate
if count > 0:
amount = count * worker.day_rate
# Find pending adjustments
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_amount = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_amount += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_amount -= adj.amount
total_amount = logs_amount + adj_amount
if log_count > 0 or pending_adjustments.exists():
# Create Payroll Record
payroll_record = PayrollRecord.objects.create(
worker=worker,
amount=amount,
amount=total_amount,
date=timezone.now().date()
)
# Link logs
payroll_record.work_logs.set(unpaid_logs)
# Link Adjustments and Handle Loans
for adj in pending_adjustments:
adj.payroll_record = payroll_record
adj.save()
# Update Loan Balance if it's a repayment
if adj.type == 'LOAN_REPAYMENT' and adj.loan:
adj.loan.balance -= adj.amount
if adj.loan.balance <= 0:
adj.loan.balance = 0
adj.loan.is_active = False
adj.loan.save()
payroll_record.save()
# Email Notification
@ -397,16 +446,18 @@ def process_payment(request, worker_id):
f"Payslip Generated\n\n"
f"Record ID: #{payroll_record.id}\n"
f"Worker: {worker.name}\n"
f"ID Number: {worker.id_no}\n"
f"Date: {payroll_record.date}\n"
f"Amount Paid: R {payroll_record.amount}\n\n"
f"This is an automated notification from Fox Fitt Payroll."
f"Total Paid: R {payroll_record.amount}\n\n"
f"Breakdown:\n"
f"Base Pay ({log_count} days): R {logs_amount}\n"
f"Adjustments: R {adj_amount}\n\n"
f"This is an automated notification."
)
recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com']
try:
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list)
messages.success(request, f"Payment of R {payroll_record.amount} processed for {worker.name}. Email sent to accounting.")
messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}")
except Exception as e:
messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}")
@ -420,9 +471,100 @@ def payslip_detail(request, pk):
# Get the logs included in this payment
logs = record.work_logs.all().order_by('date')
adjustments = record.adjustments.all().order_by('type')
# Calculate base pay from logs (re-verify logic)
# The record.amount is the final NET.
# We can reconstruct the display.
base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker)
adjustments_net = record.amount - base_pay
context = {
'record': record,
'logs': logs,
'adjustments': adjustments,
'base_pay': base_pay,
'adjustments_net': adjustments_net,
}
return render(request, 'core/payslip.html', context)
def loan_list(request):
"""List outstanding and historical loans."""
filter_status = request.GET.get('status', 'active') # active, history
if filter_status == 'history':
loans = Loan.objects.filter(is_active=False).order_by('-date')
else:
loans = Loan.objects.filter(is_active=True).order_by('-date')
context = {
'loans': loans,
'filter_status': filter_status,
'workers': Worker.objects.filter(is_active=True).order_by('name'), # For modal
}
return render(request, 'core/loan_list.html', context)
def add_loan(request):
"""Create a new loan."""
if request.method == 'POST':
worker_id = request.POST.get('worker')
amount = request.POST.get('amount')
reason = request.POST.get('reason')
date = request.POST.get('date') or timezone.now().date()
if worker_id and amount:
worker = get_object_or_404(Worker, pk=worker_id)
Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=reason
)
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
return redirect('loan_list')
def add_adjustment(request):
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment)."""
if request.method == 'POST':
worker_id = request.POST.get('worker')
adj_type = request.POST.get('type')
amount = request.POST.get('amount')
description = request.POST.get('description')
date = request.POST.get('date') or timezone.now().date()
loan_id = request.POST.get('loan_id') # Optional, for repayments
if worker_id and amount and adj_type:
worker = get_object_or_404(Worker, pk=worker_id)
# Validation for repayment OR Creation for New Loan
loan = None
if adj_type == 'LOAN_REPAYMENT':
if loan_id:
loan = get_object_or_404(Loan, pk=loan_id)
else:
# Try to find an active loan
loan = worker.loans.filter(is_active=True).first()
if not loan:
messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.")
return redirect('payroll_dashboard')
elif adj_type == 'LOAN':
# Create the Loan object tracking the debt
loan = Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=description
)
PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
amount=amount,
description=description,
date=date,
loan=loan
)
messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.")
return redirect('payroll_dashboard')