From 99fb2f4e10fbed7c458304e006cddd16d4ec83fe Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 3 Feb 2026 23:42:44 +0000 Subject: [PATCH] ver 8 loans added --- core/__pycache__/models.cpython-311.pyc | Bin 6010 -> 8937 bytes core/__pycache__/urls.cpython-311.pyc | Bin 1268 -> 1562 bytes core/__pycache__/views.cpython-311.pyc | Bin 21302 -> 27923 bytes .../migrations/0005_loan_payrolladjustment.py | 40 ++++ .../0006_alter_payrolladjustment_type.py | 18 ++ ...005_loan_payrolladjustment.cpython-311.pyc | Bin 0 -> 3197 bytes ...ter_payrolladjustment_type.cpython-311.pyc | Bin 0 -> 1016 bytes core/models.py | 36 ++++ core/templates/base.html | 1 + core/templates/core/loan_list.html | 116 +++++++++++ core/templates/core/payroll_dashboard.html | 94 ++++++++- core/templates/core/payslip.html | 77 +++++++- core/urls.py | 10 +- core/views.py | 180 ++++++++++++++++-- 14 files changed, 537 insertions(+), 35 deletions(-) create mode 100644 core/migrations/0005_loan_payrolladjustment.py create mode 100644 core/migrations/0006_alter_payrolladjustment_type.py create mode 100644 core/migrations/__pycache__/0005_loan_payrolladjustment.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0006_alter_payrolladjustment_type.cpython-311.pyc create mode 100644 core/templates/core/loan_list.html diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index b0be599f16f4995bc2707e22b4956a7a89676277..a8b9e8edaafe16869761224bb4e21513f69c4879 100644 GIT binary patch delta 2907 zcmaJ@U2NOd6~5G;B~z63XD5#1mp%>(|i?D6__Vu~hHLDqpdT{>a@T*GV3=R!W(~_66liQCi^Nj;u4zs?ns1lXsF>H;896O&}8@PSK>H41Cb`m~!lOK20H9L5c z9)K9BgLYx9g9zOSUWC2g7JM_Y^v-u?*!4u^K4 zPk1uGoK`5vvc%B^xNG9}o~qBWfT0Ly(eV!em|){?OE|QWafCx_3wJv{KJ;MP7BV#< z<4g&e%GoVJM4z~E`jfZq5pd1agc(Pe0Z-(Gl_@9k!bbW&_n_atyl6+3YLO);vQ#;@ z#dob7clfS5uYNFM^Zhlx-{Jc!Lt8>O7VEw??cyz1w6DXyyE` zz#jh}VDn~=_6%<{PVl1?OyL&MNS0Gc)-z31?$2D-3zXo?;|sz>D+j=Aq!86p^{&H) zZQ`ae$p&{Ngdm>rG`fLze30W`hk~2UqFH`dPWZW;KqCjYnn_k*Ns)$)gr~r?cbul-S>0BoOqOKsVC3wmVd^D)K@$~KHANs-2>QNQE^5;XyKT--mZ*{| znc_>@QcjyIF`9UG72@KIo|_SMQ&i?l#>+EGQ7veyM2pauA{l@EU({Ygcm<&X033s4 z?6%})3_Yir6nCt_*Y*9!qk~88(PaRn>pO6LUY=#-_u)kEq;jx5U^nSJ-#o%1xY3n;qmXHO>i+~~A+pd^>B&1tf#n{x%&S0wN!l-Oj$&gVA#$P;RuhG-MH6R0uX z@m0LGnU%N&OCV2 z<_9Kf{Di|#RED;FoPUf%4&Dpv?2B_sNeSzG`){M{|2noO>^J8f%Wdy&{R0HxuaWt; tdtAp=ZY~+&fYzUfmE)^FeTerTKXr<64m*IWI340}ybVG-W>Jn#d=?sJ2mEm60t)D40Q0c=J5Q>)ec*o6C3~F);>B zjuePsteU(@pwBoKsHG?rM5uuXc@Pl?B1}PqHHZiS5o{nL3`B%aE*4Z_iv%$vC(jjJ z%@{fP0gL43B%vjYjIom!a7j*95Q$a+8wu79HZl#QJRU@_1BqMAdHLlt4{7YmCJBa6}DLC0jp&OzyY4URgS+0DbFe4Bb7jEWL%x2_c O;9#`>03wQ{fT94Xu00k2 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 20b523f95c07178c40901876a29745a5c47e0ff7..a816d126d57a0c78bbd8276d21e534fdf7e88da3 100644 GIT binary patch delta 544 zcmeyuIg3YqIWI340}wC%vZ!pMQKt&&zco~^KFwlZ9Cf!tp z$?_~m{cf;uG`Ms~Ot4&FeSt;)B8&bN7JZ1A%!G;sSr=FgFR~b3VKD@YUEow&;CI2& b^8$<4MHa6sEM6ZZdHIIiq+|#Df_$C0>H0 zG#PKP6eN~pykrD%I42)toH@CL=>xN$Chz3U%x;r8SbVv}fKnjo;_%7wEHd?6j7%RG r=tW2|uyQuIbO?2X%wW5~B7c!ZezFAX7J(1KT>MN8+#pya0(2Sx$iq3k diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 9d83914db226339a24d06ede2665ad22035f00c0..afbb35076db991bdda7147f1a1cfae7898dd5fa0 100644 GIT binary patch delta 10074 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 VsS+?Yx;{^ppsfRk-w$Mo{|B@Ek_`X= delta 4905 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 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 0000000000000000000000000000000000000000..4f52b2f5b617583a8144da38fa08b3060dbac1eb GIT binary patch literal 3197 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 literal 0 HcmV?d00001 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