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 %}
-
+
Payroll
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 === #}
+
+
+ {# === TAB NAVIGATION === #}
+
+
+ {# =============================================== #}
+ {# === PENDING PAYMENTS TAB === #}
+ {# =============================================== #}
+ {% if active_tab == 'pending' %}
+
+
+
+
+
+
+ | Worker |
+ Days |
+ Day Rate |
+ Log Amount |
+ Adjustments |
+ Net Adj |
+ Total |
+ Actions |
+
+
+
+ {% for wd in workers_data %}
+
+ |
+ {{ 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 }} |
+
+
+
+
+
+ |
+
+ {% empty %}
+
+ |
+
+ No pending payments. All workers are paid up!
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+ {# =============================================== #}
+ {# === PAYMENT HISTORY TAB === #}
+ {# =============================================== #}
+ {% if active_tab == 'paid' %}
+
+
+
+
+
+
+ | Date |
+ Worker |
+ Amount Paid |
+ Work Logs |
+ Adjustments |
+
+
+
+ {% for record in paid_records %}
+
+ | {{ 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 %}
+ |
+
+ {% empty %}
+
+ |
+
+ No payment history yet.
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+ {# =============================================== #}
+ {# === LOANS TAB === #}
+ {# =============================================== #}
+ {% if active_tab == 'loans' %}
+
+
+
+
+
+
+
+ | Worker |
+ Principal |
+ Balance |
+ Date |
+ Reason |
+ Status |
+
+
+
+ {% for loan in loans %}
+
+ | {{ 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 %}
+ |
+
+ {% empty %}
+
+ |
+
+ {% if loan_filter == 'active' %}No active loans.{% else %}No loan history.{% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+
+
+{# ================================================================== #}
+{# === MODALS === #}
+{# ================================================================== #}
+
+{# --- ADD ADJUSTMENT MODAL --- #}
+
+
+{# --- EDIT ADJUSTMENT MODAL --- #}
+
+
+
+
+
+
+
+
+{# --- DELETE CONFIRMATION MODAL --- #}
+
+
+
+
+
+
+
+
+{# --- PRICE OVERTIME MODAL --- #}
+
+
+
+
+
+
+
+
+{# --- PREVIEW PAYSLIP MODAL --- #}
+
+
+
+
+
+ {# Content loaded via JavaScript #}
+
+
+
+
+
+
+
+{# ================================================================== #}
+{# === 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,
+ })