diff --git a/core/forms.py b/core/forms.py index 6fb8e06..98fe64a 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,10 +1,10 @@ # === FORMS === -# Django form classes for the attendance logging page. -# The AttendanceLogForm handles daily work log creation with support for -# date ranges, supervisor filtering, and conflict detection. +# Django form classes for the app. +# - AttendanceLogForm: daily work log creation with date ranges and conflict detection +# - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments from django import forms -from .models import WorkLog, Project, Team, Worker +from .models import WorkLog, Project, Team, Worker, PayrollAdjustment class AttendanceLogForm(forms.ModelForm): @@ -100,3 +100,51 @@ class AttendanceLogForm(forms.ModelForm): raise forms.ValidationError('End date cannot be before start date.') return cleaned_data + + +class PayrollAdjustmentForm(forms.ModelForm): + """ + Form for adding/editing payroll adjustments (bonuses, deductions, etc.). + + Business rule: A project is required for Overtime, Bonus, Deduction, and + Advance Payment types. Loan and Loan Repayment are worker-level (no project). + """ + + class Meta: + model = PayrollAdjustment + fields = ['type', 'project', 'worker', 'amount', 'date', 'description'] + widgets = { + 'type': forms.Select(attrs={'class': 'form-select'}), + 'project': forms.Select(attrs={'class': 'form-select'}), + 'worker': forms.Select(attrs={'class': 'form-select'}), + 'amount': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.01', + 'min': '0.01' + }), + 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 2, + 'placeholder': 'Reason for this adjustment...' + }), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].queryset = Project.objects.filter(active=True) + self.fields['project'].required = False + self.fields['worker'].queryset = Worker.objects.filter(active=True) + + def clean(self): + """Validate that project-required types have a project selected.""" + cleaned_data = super().clean() + adj_type = cleaned_data.get('type', '') + project = cleaned_data.get('project') + + # These types must have a project — they're tied to specific work + project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') + if adj_type in project_required_types and not project: + self.add_error('project', 'A project must be selected for this adjustment type.') + + return cleaned_data diff --git a/core/templates/base.html b/core/templates/base.html index 071c02a..032443c 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -57,7 +57,7 @@ {% if user.is_staff %} diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html new file mode 100644 index 0000000..775cd81 --- /dev/null +++ b/core/templates/core/payroll_dashboard.html @@ -0,0 +1,1157 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Payroll Dashboard | Fox Fitt{% endblock %} + +{% block content %} + + + +
+ + {# === PAGE HEADER === #} +
+

Payroll Dashboard

+
+ + +
+
+ + {# === ANALYTICS CARDS === #} +
+ {# Outstanding Total #} +
+
+
+
+
+
+ Outstanding Payments
+
R {{ outstanding_total|floatformat:2 }}
+
+
+ +
+
+
+
+
+ + {# Recent Payments #} +
+
+
+
+
+
+ Paid (Last 60 Days)
+
R {{ recent_payments_total|floatformat:2 }}
+
+
+ +
+
+
+
+
+ + {# Outstanding by Project #} +
+
+
+
+
+
+ Outstanding by Project
+
+ {% if outstanding_project_costs %} +
    + {% for pc in outstanding_project_costs %} +
  • {{ pc.name }}: R {{ pc.cost|floatformat:2 }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
+
+ +
+
+
+
+
+ + {# Loans #} +
+
+
+
+
+
+ Active Loans ({{ active_loans_count }})
+
+ R {{ active_loans_balance|floatformat:2 }} +
+
+
+ +
+
+
+
+
+
+ + {# === CHARTS === #} +
+
+
+
+
Monthly Payroll Totals
+
+
+ +
+
+
+
+
+
+
Cost by Project (Monthly)
+
+
+ +
+
+
+
+ + {# === TAB NAVIGATION === #} + + + {# =============================================== #} + {# === PENDING PAYMENTS TAB === #} + {# =============================================== #} + {% if active_tab == 'pending' %} +
+
+
+ + + + + + + + + + + + + + + {% for wd in workers_data %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
WorkerDaysDay RateLog AmountAdjustmentsNet AdjTotalActions
+ {{ wd.worker.name }} + {{ wd.unpaid_count }}R {{ wd.day_rate }}R {{ wd.unpaid_amount|floatformat:2 }} + {# Show each pending adjustment as a badge #} + {% for adj in wd.adjustments %} + + {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }} + {{ adj.type }} + {% if adj.project %}({{ adj.project.name }}){% endif %} + + {% endfor %} + {% if not wd.adjustments %} + - + {% endif %} + + {% if wd.adj_amount >= 0 %}+{% endif %}R {{ wd.adj_amount|floatformat:2 }} + R {{ wd.total_payable|floatformat:2 }} +
+ +
+ {% csrf_token %} + +
+
+
+ + No pending payments. All workers are paid up! +
+
+
+
+ {% endif %} + + {# =============================================== #} + {# === PAYMENT HISTORY TAB === #} + {# =============================================== #} + {% if active_tab == 'paid' %} +
+
+
+ + + + + + + + + + + + {% for record in paid_records %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
DateWorkerAmount PaidWork LogsAdjustments
{{ record.date }}{{ record.worker.name }}R {{ record.amount_paid|floatformat:2 }} + {{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }} + + {% for adj in record.adjustments.all %} + + {{ adj.type }}: R {{ adj.amount|floatformat:2 }} + + {% empty %} + - + {% endfor %} +
+ + No payment history yet. +
+
+
+
+ {% endif %} + + {# =============================================== #} + {# === LOANS TAB === #} + {# =============================================== #} + {% if active_tab == 'loans' %} +
+ + Active Loans + + + Loan History + +
+
+
+
+ + + + + + + + + + + + + {% for loan in loans %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
WorkerPrincipalBalanceDateReasonStatus
{{ loan.worker.name }}R {{ loan.principal_amount|floatformat:2 }}R {{ loan.remaining_balance|floatformat:2 }}{{ loan.date }}{{ loan.reason|default:"-" }} + {% if loan.active %} + Active + {% else %} + Paid Off + {% endif %} +
+ + {% if loan_filter == 'active' %}No active loans.{% else %}No loan history.{% endif %} +
+
+
+
+ {% endif %} + +
+ +{# ================================================================== #} +{# === MODALS === #} +{# ================================================================== #} + +{# --- ADD ADJUSTMENT MODAL --- #} + + +{# --- EDIT ADJUSTMENT MODAL --- #} + + +{# --- DELETE CONFIRMATION MODAL --- #} + + +{# --- PRICE OVERTIME MODAL --- #} + + +{# --- PREVIEW PAYSLIP MODAL --- #} + + + +{# ================================================================== #} +{# === JAVASCRIPT === #} +{# ================================================================== #} + +{# Django's json_script filter safely outputs JSON without XSS risk #} +{{ overtime_data_json|json_script:"otDataJson" }} +{{ team_workers_map_json|json_script:"teamWorkersJson" }} +{{ chart_labels_json|json_script:"chartLabelsJson" }} +{{ chart_totals_json|json_script:"chartTotalsJson" }} +{{ project_chart_json|json_script:"projectChartJson" }} + + + +{% endblock %} diff --git a/core/urls.py b/core/urls.py index a5e2f48..8676818 100644 --- a/core/urls.py +++ b/core/urls.py @@ -20,4 +20,26 @@ urlpatterns = [ # AJAX toggle — activates/deactivates workers, projects, teams from dashboard path('toggle///', views.toggle_active, name='toggle_active'), + + # === PAYROLL === + # Main payroll dashboard — shows pending payments, history, loans, and charts + path('payroll/', views.payroll_dashboard, name='payroll_dashboard'), + + # Process payment — pays a worker and links their unpaid logs + adjustments + path('payroll/pay//', views.process_payment, name='process_payment'), + + # Price overtime — creates Overtime adjustments from unpriced OT entries + path('payroll/price-overtime/', views.price_overtime, name='price_overtime'), + + # Add a new payroll adjustment (bonus, deduction, loan, etc.) + path('payroll/adjustment/add/', views.add_adjustment, name='add_adjustment'), + + # Edit an existing unpaid adjustment + path('payroll/adjustment//edit/', views.edit_adjustment, name='edit_adjustment'), + + # Delete an unpaid adjustment + path('payroll/adjustment//delete/', views.delete_adjustment, name='delete_adjustment'), + + # Preview a worker's payslip (AJAX — returns JSON) + path('payroll/preview//', views.preview_payslip, name='preview_payslip'), ] diff --git a/core/views.py b/core/views.py index 9ef58f4..f447a1d 100644 --- a/core/views.py +++ b/core/views.py @@ -3,18 +3,29 @@ # Each function here handles a URL and decides what to show the user. import csv +import json import datetime from decimal import Decimal from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone +from django.db import transaction from django.db.models import Sum, Count, Q, Prefetch +from django.db.models.functions import TruncMonth from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpResponseForbidden, HttpResponse from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment -from .forms import AttendanceLogForm +from .forms import AttendanceLogForm, PayrollAdjustmentForm + + +# === PAYROLL CONSTANTS === +# 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. +# "Loan Repayment" and "Advance Payment" are deductive — they reduce net pay. +ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan'] +DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Payment'] # === PERMISSION HELPERS === @@ -472,3 +483,619 @@ def toggle_active(request, model_name, item_id): }) except model.DoesNotExist: return JsonResponse({'error': 'Item not found'}, status=404) + + +# ============================================================================= +# === PAYROLL DASHBOARD === +# The main payroll page. Shows per-worker breakdown of what's owed, +# adjustment management, payment processing, and Chart.js analytics. +# Admin-only — supervisors cannot access this page. +# ============================================================================= + +@login_required +def payroll_dashboard(request): + if not is_admin(request.user): + messages.error(request, 'Only admins can access the payroll dashboard.') + return redirect('home') + + status_filter = request.GET.get('status', 'pending') + + # --- Per-worker pending payment data --- + # For each active worker, calculate: unpaid days × daily_rate + net adjustments + active_workers = Worker.objects.filter(active=True).prefetch_related( + Prefetch('work_logs', queryset=WorkLog.objects.prefetch_related( + 'payroll_records', 'priced_workers' + ).select_related('project')), + Prefetch('adjustments', queryset=PayrollAdjustment.objects.filter( + payroll_record__isnull=True + ).select_related('project', 'loan', 'work_log'), + to_attr='pending_adjustments_list'), + ).order_by('name') + + workers_data = [] + outstanding_total = Decimal('0.00') + all_ot_data = [] # For the Price Overtime modal + + for worker in active_workers: + # Find unpaid work logs for this worker. + # A log is "unpaid for this worker" if no PayrollRecord links + # to BOTH this log AND this worker. + unpaid_logs = [] + for log in worker.work_logs.all(): + paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()} + if worker.id not in paid_worker_ids: + unpaid_logs.append(log) + + log_count = len(unpaid_logs) + log_amount = log_count * worker.daily_rate + + # Find unpriced overtime in unpaid logs + ot_data_worker = [] + for log in unpaid_logs: + if log.overtime_amount > 0: + priced_ids = {w.id for w in log.priced_workers.all()} + if worker.id not in priced_ids: + ot_entry = { + 'worker_id': worker.id, + 'worker_name': worker.name, + 'log_id': log.id, + 'date': log.date.strftime('%Y-%m-%d'), + 'project': log.project.name, + 'overtime': float(log.overtime_amount), + 'ot_label': log.get_overtime_amount_display(), + } + ot_data_worker.append(ot_entry) + all_ot_data.append(ot_entry) + + # Calculate net adjustment amount + pending_adjs = worker.pending_adjustments_list + adj_total = Decimal('0.00') + for adj in pending_adjs: + if adj.type in ADDITIVE_TYPES: + adj_total += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + adj_total -= adj.amount + + total_payable = log_amount + adj_total + + # Only include workers who have something pending + if log_count > 0 or pending_adjs: + workers_data.append({ + 'worker': worker, + 'unpaid_count': log_count, + 'unpaid_amount': log_amount, + 'adj_amount': adj_total, + 'total_payable': total_payable, + 'adjustments': pending_adjs, + 'logs': unpaid_logs, + 'ot_data': ot_data_worker, + 'day_rate': float(worker.daily_rate), + }) + outstanding_total += max(total_payable, Decimal('0.00')) + + # --- Payment history --- + paid_records = PayrollRecord.objects.select_related( + 'worker' + ).order_by('-date', '-id') + + # --- Recent payments total (last 60 days) --- + sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60) + recent_payments_total = PayrollRecord.objects.filter( + date__gte=sixty_days_ago + ).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00') + + # --- Outstanding cost per project --- + # Check per-worker: a WorkLog is "unpaid for worker X" if no PayrollRecord + # links BOTH that log AND that worker. This handles partially-paid logs. + outstanding_project_costs = [] + for project in Project.objects.filter(active=True): + project_outstanding = Decimal('0.00') + # Unpaid work log costs — check each worker individually + for log in project.work_logs.prefetch_related('payroll_records', 'workers').all(): + paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()} + for w in log.workers.all(): + if w.id not in paid_worker_ids: + project_outstanding += w.daily_rate + # Unpaid adjustments for this project + unpaid_adjs = PayrollAdjustment.objects.filter( + payroll_record__isnull=True + ).filter(Q(project=project) | Q(work_log__project=project)) + for adj in unpaid_adjs: + if adj.type in ADDITIVE_TYPES: + project_outstanding += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + project_outstanding -= adj.amount + if project_outstanding != 0: + outstanding_project_costs.append({ + 'name': project.name, + 'cost': project_outstanding, + }) + + # --- Chart data: last 6 months --- + today = timezone.now().date() + chart_months = [] + for i in range(5, -1, -1): + m = today.month - i + y = today.year + while m <= 0: + m += 12 + y -= 1 + chart_months.append((y, m)) + + chart_labels = [ + datetime.date(y, m, 1).strftime('%b %Y') for y, m in chart_months + ] + + # Monthly payroll totals + paid_by_month_qs = PayrollRecord.objects.annotate( + month=TruncMonth('date') + ).values('month').annotate(total=Sum('amount_paid')).order_by('month') + paid_by_month = { + (r['month'].year, r['month'].month): float(r['total']) + for r in paid_by_month_qs + } + chart_totals = [paid_by_month.get((y, m), 0) for y, m in chart_months] + + # Per-project monthly costs (for stacked bar chart) + project_chart_data = [] + for project in Project.objects.filter(active=True): + monthly_data = [] + for y, m in chart_months: + month_cost = Decimal('0.00') + month_logs = project.work_logs.filter( + date__year=y, date__month=m + ).prefetch_related('workers') + for log in month_logs: + for w in log.workers.all(): + month_cost += w.daily_rate + # Include paid adjustments for this project in this month + paid_adjs = PayrollAdjustment.objects.filter( + payroll_record__isnull=False, + date__year=y, date__month=m, + ).filter(Q(project=project) | Q(work_log__project=project)) + for adj in paid_adjs: + if adj.type in ADDITIVE_TYPES: + month_cost += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + month_cost -= adj.amount + monthly_data.append(float(month_cost)) + if any(v > 0 for v in monthly_data): + project_chart_data.append({ + 'name': project.name, + 'data': monthly_data, + }) + + # --- Loans --- + loan_filter = request.GET.get('loan_status', 'active') + if loan_filter == 'history': + loans = Loan.objects.filter(active=False).select_related('worker').order_by('-date') + else: + loans = Loan.objects.filter(active=True).select_related('worker').order_by('-date') + + # Total active loan balance (always shown in analytics card, regardless of tab) + active_loans = Loan.objects.filter(active=True) + active_loans_count = active_loans.count() + active_loans_balance = active_loans.aggregate( + total=Sum('remaining_balance') + )['total'] or Decimal('0.00') + + # --- Active projects and workers for modal dropdowns --- + active_projects = Project.objects.filter(active=True).order_by('name') + all_workers = Worker.objects.filter(active=True).order_by('name') + all_teams = Team.objects.filter(active=True).prefetch_related('workers').order_by('name') + + # Team-workers map for auto-selecting workers when a team is picked + team_workers_map = {} + for team in all_teams: + team_workers_map[str(team.id)] = list( + team.workers.filter(active=True).values_list('id', flat=True) + ) + + context = { + 'workers_data': workers_data, + 'paid_records': paid_records, + 'outstanding_total': outstanding_total, + 'recent_payments_total': recent_payments_total, + 'outstanding_project_costs': outstanding_project_costs, + 'active_tab': status_filter, + 'all_workers': all_workers, + 'all_teams': all_teams, + 'team_workers_map_json': json.dumps(team_workers_map), + 'adjustment_types': PayrollAdjustment.TYPE_CHOICES, + 'active_projects': active_projects, + 'loans': loans, + 'loan_filter': loan_filter, + 'chart_labels_json': json.dumps(chart_labels), + 'chart_totals_json': json.dumps(chart_totals), + 'project_chart_json': json.dumps(project_chart_data), + 'overtime_data_json': json.dumps(all_ot_data), + 'today': today, # For pre-filling date fields in modals + 'active_loans_count': active_loans_count, + 'active_loans_balance': active_loans_balance, + } + return render(request, 'core/payroll_dashboard.html', context) + + +# ============================================================================= +# === PROCESS PAYMENT === +# Creates a PayrollRecord for a worker, linking all their unpaid work logs +# and applying any pending adjustments. Handles loan repayment deductions. +# ============================================================================= + +@login_required +def process_payment(request, worker_id): + if request.method != 'POST': + return redirect('payroll_dashboard') + if not is_admin(request.user): + return HttpResponseForbidden("Not authorized.") + + worker = get_object_or_404(Worker, id=worker_id) + + # Find unpaid logs for this worker + unpaid_logs = worker.work_logs.exclude( + payroll_records__worker=worker + ) + log_count = unpaid_logs.count() + logs_amount = log_count * worker.daily_rate + + # Find pending adjustments + pending_adjs = worker.adjustments.filter(payroll_record__isnull=True) + + if log_count == 0 and not pending_adjs.exists(): + messages.warning(request, f'No pending payments for {worker.name}.') + return redirect('payroll_dashboard') + + # Calculate net adjustment + adj_amount = Decimal('0.00') + for adj in pending_adjs: + if adj.type in ADDITIVE_TYPES: + adj_amount += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + adj_amount -= adj.amount + + total_amount = logs_amount + adj_amount + + with transaction.atomic(): + # Create the PayrollRecord + payroll_record = PayrollRecord.objects.create( + worker=worker, + amount_paid=total_amount, + date=timezone.now().date(), + ) + + # Link all unpaid work logs to this payment + payroll_record.work_logs.set(unpaid_logs) + + # Link all pending adjustments to this payment + for adj in pending_adjs: + adj.payroll_record = payroll_record + adj.save() + + # If this is a loan repayment, deduct from the loan balance + if adj.type == 'Loan Repayment' and adj.loan: + adj.loan.remaining_balance -= adj.amount + if adj.loan.remaining_balance <= 0: + adj.loan.remaining_balance = Decimal('0.00') + adj.loan.active = False + adj.loan.save() + + messages.success( + request, + f'Payment of R {total_amount:,.2f} processed for {worker.name}. ' + f'{log_count} work log(s) marked as paid.' + ) + return redirect('payroll_dashboard') + + +# ============================================================================= +# === PRICE OVERTIME === +# Creates Overtime adjustments for workers who have unpriced overtime on +# their work logs. Called via AJAX from the Price Overtime modal. +# ============================================================================= + +@login_required +def price_overtime(request): + if request.method != 'POST': + return redirect('payroll_dashboard') + if not is_admin(request.user): + return HttpResponseForbidden("Not authorized.") + + log_ids = request.POST.getlist('log_id[]') + worker_ids = request.POST.getlist('worker_id[]') + rate_pcts = request.POST.getlist('rate_pct[]') + + created_count = 0 + for log_id, w_id, pct in zip(log_ids, worker_ids, rate_pcts): + try: + worklog = WorkLog.objects.select_related('project').get(id=int(log_id)) + worker = Worker.objects.get(id=int(w_id)) + rate_pct = Decimal(pct) + + # Calculate: daily_rate × overtime_fraction × (rate_percentage / 100) + amount = worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100')) + + if amount > 0: + PayrollAdjustment.objects.create( + worker=worker, + type='Overtime', + amount=amount, + date=worklog.date, + description=f'Overtime ({worklog.get_overtime_amount_display()}) at {pct}% on {worklog.project.name}', + work_log=worklog, + project=worklog.project, + ) + # Mark this worker as "priced" for this log's overtime + worklog.priced_workers.add(worker) + created_count += 1 + except (WorkLog.DoesNotExist, Worker.DoesNotExist, Exception): + continue + + messages.success(request, f'Priced {created_count} overtime adjustment(s).') + return redirect('payroll_dashboard') + + +# ============================================================================= +# === ADD ADJUSTMENT === +# Creates a new payroll adjustment (bonus, deduction, loan, etc.). +# Called via POST from the Add Adjustment modal. +# ============================================================================= + +@login_required +def add_adjustment(request): + if request.method != 'POST': + return redirect('payroll_dashboard') + if not is_admin(request.user): + return HttpResponseForbidden("Not authorized.") + + worker_ids = request.POST.getlist('workers') + adj_type = request.POST.get('type', '') + amount_str = request.POST.get('amount', '0') + description = request.POST.get('description', '') + date_str = request.POST.get('date', '') + project_id = request.POST.get('project', '') + + # Validate amount + try: + amount = Decimal(amount_str) + if amount <= 0: + raise ValueError + except (ValueError, Exception): + messages.error(request, 'Please enter a valid amount greater than zero.') + return redirect('payroll_dashboard') + + # Validate date + try: + adj_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else timezone.now().date() + except ValueError: + adj_date = timezone.now().date() + + # Validate project for types that require it + project = None + if project_id: + try: + project = Project.objects.get(id=int(project_id)) + except Project.DoesNotExist: + pass + + project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') + if adj_type in project_required_types and not project: + messages.error(request, 'A project must be selected for this adjustment type.') + return redirect('payroll_dashboard') + + created_count = 0 + for w_id in worker_ids: + try: + worker = Worker.objects.get(id=int(w_id)) + except Worker.DoesNotExist: + continue + + loan = None + + if adj_type == 'Loan Repayment': + # Find the worker's active loan + loan = worker.loans.filter(active=True).first() + if not loan: + messages.warning(request, f'{worker.name} has no active loan — skipped.') + continue + + if adj_type == 'New Loan': + # Create a new Loan object first + loan = Loan.objects.create( + worker=worker, + principal_amount=amount, + remaining_balance=amount, + date=adj_date, + reason=description, + ) + + PayrollAdjustment.objects.create( + worker=worker, + type=adj_type, + amount=amount, + date=adj_date, + description=description, + project=project, + loan=loan, + ) + created_count += 1 + + messages.success(request, f'Created {created_count} {adj_type} adjustment(s).') + return redirect('payroll_dashboard') + + +# ============================================================================= +# === EDIT ADJUSTMENT === +# Updates an existing unpaid adjustment. Type changes are limited to +# Bonus ↔ Deduction swaps only. +# ============================================================================= + +@login_required +def edit_adjustment(request, adj_id): + if request.method != 'POST': + return redirect('payroll_dashboard') + if not is_admin(request.user): + return HttpResponseForbidden("Not authorized.") + + adj = get_object_or_404(PayrollAdjustment, id=adj_id) + + # Can't edit adjustments that have already been paid + if adj.payroll_record is not None: + messages.error(request, 'Cannot edit a paid adjustment.') + return redirect('payroll_dashboard') + + # Can't edit Advance Payments + if adj.type == 'Advance Payment': + messages.warning(request, 'Advance payments cannot be edited.') + return redirect('payroll_dashboard') + + # Update fields + try: + adj.amount = Decimal(request.POST.get('amount', str(adj.amount))) + except (ValueError, Exception): + pass + + adj.description = request.POST.get('description', adj.description) + + date_str = request.POST.get('date', '') + if date_str: + try: + adj.date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + pass + + # Type change — only allow Bonus ↔ Deduction + new_type = request.POST.get('type', adj.type) + if adj.type in ('Bonus', 'Deduction') and new_type in ('Bonus', 'Deduction'): + adj.type = new_type + + # Project + project_id = request.POST.get('project', '') + if project_id: + try: + adj.project = Project.objects.get(id=int(project_id)) + except Project.DoesNotExist: + pass + else: + adj.project = None + + adj.save() + + # If it's a Loan adjustment, sync the loan details + if adj.type == 'New Loan' and adj.loan: + adj.loan.principal_amount = adj.amount + adj.loan.remaining_balance = adj.amount + adj.loan.reason = adj.description + adj.loan.save() + + messages.success(request, f'{adj.type} adjustment updated.') + return redirect('payroll_dashboard') + + +# ============================================================================= +# === DELETE ADJUSTMENT === +# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime. +# ============================================================================= + +@login_required +def delete_adjustment(request, adj_id): + if request.method != 'POST': + return redirect('payroll_dashboard') + if not is_admin(request.user): + return HttpResponseForbidden("Not authorized.") + + adj = get_object_or_404(PayrollAdjustment, id=adj_id) + + # Can't delete adjustments that have been paid + if adj.payroll_record is not None: + messages.error(request, 'Cannot delete a paid adjustment.') + return redirect('payroll_dashboard') + + adj_type = adj.type + worker_name = adj.worker.name + + if adj_type == 'New Loan' and adj.loan: + # Check if any paid repayments exist for this loan + paid_repayments = PayrollAdjustment.objects.filter( + loan=adj.loan, + type='Loan Repayment', + payroll_record__isnull=False, + ) + if paid_repayments.exists(): + messages.error( + request, + f'Cannot delete loan for {worker_name} — it has paid repayments.' + ) + return redirect('payroll_dashboard') + + # Delete all unpaid repayments for this loan, then the loan itself + PayrollAdjustment.objects.filter( + loan=adj.loan, + type='Loan Repayment', + payroll_record__isnull=True, + ).delete() + adj.loan.delete() + + elif adj_type == 'Overtime' and adj.work_log: + # "Un-price" the overtime — remove worker from priced_workers M2M + adj.work_log.priced_workers.remove(adj.worker) + + adj.delete() + messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.') + return redirect('payroll_dashboard') + + +# ============================================================================= +# === PREVIEW PAYSLIP (AJAX) === +# Returns a JSON preview of what a worker's payslip would look like. +# Called from the Preview Payslip modal without saving anything. +# ============================================================================= + +@login_required +def preview_payslip(request, worker_id): + if not is_admin(request.user): + return JsonResponse({'error': 'Not authorized'}, status=403) + + worker = get_object_or_404(Worker, id=worker_id) + + # Find unpaid logs + unpaid_logs = [] + for log in worker.work_logs.select_related('project').prefetch_related('payroll_records').all(): + paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()} + if worker.id not in paid_worker_ids: + unpaid_logs.append({ + 'date': log.date.strftime('%Y-%m-%d'), + 'project': log.project.name, + }) + + log_count = len(unpaid_logs) + log_amount = float(log_count * worker.daily_rate) + + # Find pending adjustments + pending_adjs = worker.adjustments.filter( + payroll_record__isnull=True + ).select_related('project') + + adjustments_list = [] + adj_total = 0.0 + for adj in pending_adjs: + sign = '+' if adj.type in ADDITIVE_TYPES else '-' + adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount) + adjustments_list.append({ + 'type': adj.type, + 'amount': float(adj.amount), + 'sign': sign, + 'description': adj.description, + 'project': adj.project.name if adj.project else '', + }) + + return JsonResponse({ + 'worker_name': worker.name, + 'worker_id_number': worker.id_number, + 'day_rate': float(worker.daily_rate), + 'days_worked': log_count, + 'log_amount': log_amount, + 'adjustments': adjustments_list, + 'adj_total': adj_total, + 'net_pay': log_amount + adj_total, + 'logs': unpaid_logs, + })