diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index b0be599..a8b9e8e 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index 20b523f..a816d12 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 9d83914..afbb350 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/migrations/0005_loan_payrolladjustment.py b/core/migrations/0005_loan_payrolladjustment.py
new file mode 100644
index 0000000..246be8c
--- /dev/null
+++ b/core/migrations/0005_loan_payrolladjustment.py
@@ -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')),
+ ],
+ ),
+ ]
diff --git a/core/migrations/0006_alter_payrolladjustment_type.py b/core/migrations/0006_alter_payrolladjustment_type.py
new file mode 100644
index 0000000..08aba59
--- /dev/null
+++ b/core/migrations/0006_alter_payrolladjustment_type.py
@@ -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),
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0005_loan_payrolladjustment.cpython-311.pyc b/core/migrations/__pycache__/0005_loan_payrolladjustment.cpython-311.pyc
new file mode 100644
index 0000000..4f52b2f
Binary files /dev/null and b/core/migrations/__pycache__/0005_loan_payrolladjustment.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0006_alter_payrolladjustment_type.cpython-311.pyc b/core/migrations/__pycache__/0006_alter_payrolladjustment_type.cpython-311.pyc
new file mode 100644
index 0000000..079bfee
Binary files /dev/null and b/core/migrations/__pycache__/0006_alter_payrolladjustment_type.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 4b96c36..1f48574 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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}"
\ No newline at end of file
diff --git a/core/templates/base.html b/core/templates/base.html
index b795b33..65cf9e5 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -34,6 +34,7 @@
Log Work
History
Payroll
+ Loans
Manage
Admin Panel
diff --git a/core/templates/core/loan_list.html b/core/templates/core/loan_list.html
new file mode 100644
index 0000000..8ced55b
--- /dev/null
+++ b/core/templates/core/loan_list.html
@@ -0,0 +1,116 @@
+{% extends 'base.html' %}
+
+{% block title %}Loan Management - Fox Fitt{% endblock %}
+
+{% block content %}
+
+
+
Loan Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date Issued |
+ Worker |
+ Original Amount |
+ Balance (Outstanding) |
+ Reason |
+ Status |
+
+
+
+ {% for loan in loans %}
+
+ | {{ loan.date|date:"M d, Y" }} |
+ {{ loan.worker.name }} |
+ R {{ loan.amount }} |
+
+ R {{ loan.balance }}
+ |
+ {{ loan.reason|default:"-" }} |
+
+ {% if loan.is_active %}
+ Active
+ {% else %}
+ Repaid
+ {% endif %}
+ |
+
+ {% empty %}
+
+ |
+ No loans found in this category.
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html
index 0c91997..76a864b 100644
--- a/core/templates/core/payroll_dashboard.html
+++ b/core/templates/core/payroll_dashboard.html
@@ -7,6 +7,12 @@
@@ -17,7 +23,7 @@
Outstanding Payments
R {{ outstanding_total|intcomma }}
-
Total pending for active workers
+
Total pending (including adjustments)
@@ -79,8 +85,8 @@
| Worker Name |
- Unpaid Logs |
- Total Owed |
+ Breakdown |
+ Net Payable |
Action |
@@ -90,20 +96,39 @@
{{ item.worker.name }}
ID: {{ item.worker.id_no }}
+ {% if item.adjustments %}
+
+ {% for adj in item.adjustments %}
+
+ {{ adj.get_type_display }}: R {{ adj.amount }}
+
+ {% endfor %}
+
+ {% endif %}
|
- {{ item.unpaid_count }} days
+
+ Work: {{ item.unpaid_count }} days (R {{ item.unpaid_amount|intcomma }})
+
+ Adjustments: R {{ item.adj_amount|intcomma }}
+
+
|
-
- R {{ item.unpaid_amount|intcomma }}
+ |
+ R {{ item.total_payable|intcomma }}
|
+ {% if item.total_payable > 0 %}
+ {% else %}
+
+ {% endif %}
|
{% endfor %}
@@ -138,7 +163,7 @@
Date |
Payslip ID |
Worker |
- Amount |
+ Net Amount |
Action |
@@ -172,4 +197,55 @@
{% endif %}
-{% endblock %}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/core/templates/core/payslip.html b/core/templates/core/payslip.html
index 14ba973..55950ce 100644
--- a/core/templates/core/payslip.html
+++ b/core/templates/core/payslip.html
@@ -41,9 +41,9 @@
- Work Log Details
+ Work Log Details (Attendance)
-
+
| Date |
@@ -60,17 +60,84 @@
{{ log.notes|default:"-"|truncatechars:50 }} |
R {{ record.worker.day_rate|intcomma }} |
+ {% empty %}
+
+ | No work logs in this period. |
+
{% endfor %}
- | Total |
- R {{ record.amount|intcomma }} |
+ Base Pay Subtotal |
+ R {{ base_pay|intcomma }} |
+
+ {% if adjustments %}
+ Adjustments (Bonuses, Deductions, Loans)
+
+
+
+
+ | Date |
+ Type |
+ Description |
+ Amount |
+
+
+
+ {% for adj in adjustments %}
+
+ | {{ adj.date|date:"M d, Y" }} |
+
+ {{ adj.get_type_display }}
+ |
+ {{ adj.description }} |
+
+ {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}
+ - R {{ adj.amount|intcomma }}
+ {% else %}
+ + R {{ adj.amount|intcomma }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ | Base Pay: |
+ R {{ base_pay|intcomma }} |
+
+ {% if adjustments %}
+
+ | Adjustments Net: |
+
+ {% if record.amount >= base_pay %}
+ + R {{ record.amount|sub:base_pay|intcomma }}
+ {% else %}
+ - R {{ base_pay|sub:record.amount|intcomma }}
+ {% endif %}
+ |
+
+ {% endif %}
+
+ | Net Payable: |
+ R {{ record.amount|intcomma }} |
+
+
+
+
+
This is a computer-generated document and does not require a signature.
@@ -79,4 +146,4 @@
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/core/urls.py b/core/urls.py
index f06bb24..0bc5561 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -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//", process_payment, name="process_payment"),
path("payroll/payslip//", payslip_detail, name="payslip_detail"),
-]
\ No newline at end of file
+ path("loans/", loan_list, name="loan_list"),
+ path("loans/add/", add_loan, name="add_loan"),
+ path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"),
+]
diff --git a/core/views.py b/core/views.py
index f4d7f32..7f3606f 100644
--- a/core/views.py
+++ b/core/views.py
@@ -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
- if count > 0:
- outstanding_total += amount
+ # 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 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
})
@@ -357,6 +376,9 @@ def payroll_dashboard(request):
# Analytics: Previous 2 months payments
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,
@@ -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')
\ No newline at end of file