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 @@ + 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

+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + {% for loan in loans %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
Date IssuedWorkerOriginal AmountBalance (Outstanding)ReasonStatus
{{ 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 %} +
+ No loans found in this category. +
+
+
+
+
+ + + +{% 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 @@

Payroll Dashboard

+
+ Manage Loans + +
@@ -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 %}
{% csrf_token %} -
+ {% 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)
- +
@@ -60,17 +60,84 @@ + {% empty %} + + + {% endfor %} - - + +
Date{{ log.notes|default:"-"|truncatechars:50 }} R {{ record.worker.day_rate|intcomma }}
No work logs in this period.
TotalR {{ record.amount|intcomma }}Base Pay SubtotalR {{ base_pay|intcomma }}
+ + {% if adjustments %} +
Adjustments (Bonuses, Deductions, Loans)
+
+ + + + + + + + + + + {% for adj in adjustments %} + + + + + + + {% endfor %} + +
DateTypeDescriptionAmount
{{ 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 %} +
+
+ {% endif %} + + +
+
+ + + + + + {% if adjustments %} + + + + + {% endif %} + + + + +
Base Pay:R {{ base_pay|intcomma }}
Adjustments Net: + {% if record.amount >= base_pay %} + + R {{ record.amount|sub:base_pay|intcomma }} + {% else %} + - R {{ base_pay|sub:record.amount|intcomma }} + {% 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