38686-vm/core/views.py
Konrad du Plessis efe5f08682 Add Phase 3: Payroll Dashboard with full payment processing
- PayrollAdjustmentForm with project validation for types that require it
- 7 payroll views: dashboard, process_payment, price_overtime, add/edit/delete
  adjustment, preview_payslip (all admin-only)
- Payroll dashboard template with analytics cards, Chart.js charts (monthly
  totals + per-project costs), 3 tabs (Pending/Paid/Loans), 5 modals
- XSS-safe JavaScript using createElement+textContent (zero innerHTML)
- Fix: outstanding-by-project now handles partially-paid WorkLogs per-worker
- Fix: active loan count and balance computed via aggregate in view
- Payroll navbar link wired up, 7 URL patterns added
- Zero model/migration changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:47:12 +02:00

1102 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# === VIEWS ===
# All the page logic for the LabourPay app.
# 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, 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 ===
# These small functions check what kind of user is logged in.
# "Admin" = the boss (is_staff or is_superuser in Django).
# "Supervisor" = someone who manages teams or projects, or is in the Work Logger group.
def is_admin(user):
"""Returns True if the user is staff or superuser (the boss)."""
return user.is_staff or user.is_superuser
def is_supervisor(user):
"""Returns True if the user manages teams, has assigned projects, or is a Work Logger."""
return (
user.supervised_teams.exists()
or user.assigned_projects.exists()
or user.groups.filter(name='Work Logger').exists()
)
def is_staff_or_supervisor(user):
"""Returns True if the user is either an admin or a supervisor."""
return is_admin(user) or is_supervisor(user)
# === HOME DASHBOARD ===
# The main page users see after logging in. Shows different content
# depending on whether the user is an admin or supervisor.
@login_required
def index(request):
user = request.user
if is_admin(user):
# --- ADMIN DASHBOARD ---
# Calculate total value of unpaid work and break it down by project.
# A WorkLog is "unpaid" if it has no linked PayrollRecord entries.
unpaid_worklogs = WorkLog.objects.filter(
payroll_records__isnull=True
).select_related('project').prefetch_related('workers')
outstanding_payments = Decimal('0.00')
outstanding_by_project = {}
for wl in unpaid_worklogs:
project_name = wl.project.name
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
for worker in wl.workers.all():
cost = worker.daily_rate
outstanding_payments += cost
outstanding_by_project[project_name] += cost
# Also include unpaid payroll adjustments (bonuses, deductions, etc.)
unpaid_adjustments = PayrollAdjustment.objects.filter(
payroll_record__isnull=True
).select_related('project')
for adj in unpaid_adjustments:
outstanding_payments += adj.amount
project_name = adj.project.name if adj.project else 'General'
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
outstanding_by_project[project_name] += adj.amount
# Sum total paid out in the last 60 days
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
paid_this_month = PayrollRecord.objects.filter(
date__gte=sixty_days_ago
).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00')
# Count and total balance of active loans
active_loans_qs = Loan.objects.filter(active=True)
active_loans_count = active_loans_qs.count()
active_loans_balance = active_loans_qs.aggregate(
total=Sum('remaining_balance')
)['total'] or Decimal('0.00')
# This week summary
start_of_week = timezone.now().date() - timezone.timedelta(
days=timezone.now().date().weekday()
)
this_week_logs = WorkLog.objects.filter(date__gte=start_of_week).count()
# Recent activity — last 5 work logs
recent_activity = WorkLog.objects.select_related(
'project', 'supervisor'
).prefetch_related('workers').order_by('-date', '-id')[:5]
# All workers, projects, and teams for the Manage Resources tab
workers = Worker.objects.all().order_by('name')
projects = Project.objects.all().order_by('name')
teams = Team.objects.all().order_by('name')
context = {
'is_admin': True,
'outstanding_payments': outstanding_payments,
'paid_this_month': paid_this_month,
'active_loans_count': active_loans_count,
'active_loans_balance': active_loans_balance,
'outstanding_by_project': outstanding_by_project,
'this_week_logs': this_week_logs,
'recent_activity': recent_activity,
'workers': workers,
'projects': projects,
'teams': teams,
}
return render(request, 'core/index.html', context)
else:
# --- SUPERVISOR DASHBOARD ---
# Count projects this supervisor is assigned to
my_projects_count = user.assigned_projects.filter(active=True).count()
# Count teams this supervisor manages
my_teams_count = user.supervised_teams.filter(active=True).count()
# Count unique workers across all their teams
my_workers_count = Worker.objects.filter(
active=True,
teams__supervisor=user,
teams__active=True
).distinct().count()
# This week summary — only their own logs
start_of_week = timezone.now().date() - timezone.timedelta(
days=timezone.now().date().weekday()
)
this_week_logs = WorkLog.objects.filter(
date__gte=start_of_week, supervisor=user
).count()
# Their last 5 work logs
recent_activity = WorkLog.objects.filter(
supervisor=user
).select_related('project').prefetch_related('workers').order_by('-date', '-id')[:5]
context = {
'is_admin': False,
'my_projects_count': my_projects_count,
'my_teams_count': my_teams_count,
'my_workers_count': my_workers_count,
'this_week_logs': this_week_logs,
'recent_activity': recent_activity,
}
return render(request, 'core/index.html', context)
# === ATTENDANCE LOGGING ===
# This is where supervisors log which workers showed up to work each day.
# Supports logging a single day or a date range (e.g. a whole week).
# Includes conflict detection to prevent double-logging workers.
@login_required
def attendance_log(request):
user = request.user
if request.method == 'POST':
form = AttendanceLogForm(request.POST, user=user)
if form.is_valid():
start_date = form.cleaned_data['date']
end_date = form.cleaned_data.get('end_date') or start_date
include_saturday = form.cleaned_data.get('include_saturday', False)
include_sunday = form.cleaned_data.get('include_sunday', False)
project = form.cleaned_data['project']
team = form.cleaned_data.get('team')
workers = form.cleaned_data['workers']
overtime_amount = form.cleaned_data['overtime_amount']
notes = form.cleaned_data.get('notes', '')
# --- Build list of dates to log ---
# Go through each day from start to end, skipping weekends
# unless the user checked the "Include Saturday/Sunday" boxes
dates_to_log = []
current_date = start_date
while current_date <= end_date:
day_of_week = current_date.weekday() # 0=Mon, 5=Sat, 6=Sun
if day_of_week == 5 and not include_saturday:
current_date += datetime.timedelta(days=1)
continue
if day_of_week == 6 and not include_sunday:
current_date += datetime.timedelta(days=1)
continue
dates_to_log.append(current_date)
current_date += datetime.timedelta(days=1)
if not dates_to_log:
messages.warning(request, 'No valid dates in the selected range.')
return render(request, 'core/attendance_log.html', {
'form': form,
'is_admin': is_admin(user),
})
# --- Conflict detection ---
# Check if any selected workers already have a WorkLog on any of these dates
worker_ids = list(workers.values_list('id', flat=True))
existing_logs = WorkLog.objects.filter(
date__in=dates_to_log,
workers__id__in=worker_ids
).prefetch_related('workers').select_related('project')
conflicts = []
for log in existing_logs:
for w in log.workers.all():
if w.id in worker_ids:
conflicts.append({
'worker_name': w.name,
'date': log.date,
'project_name': log.project.name,
})
# If there are conflicts and the user hasn't chosen what to do yet
conflict_action = request.POST.get('conflict_action', '')
if conflicts and not conflict_action:
# Show the conflict warning — let user choose Skip or Overwrite
return render(request, 'core/attendance_log.html', {
'form': form,
'conflicts': conflicts,
'is_admin': is_admin(user),
})
# --- Create work logs ---
created_count = 0
skipped_count = 0
for log_date in dates_to_log:
# Check which workers already have a log on this date
workers_with_existing = set(
WorkLog.objects.filter(
date=log_date,
workers__id__in=worker_ids
).values_list('workers__id', flat=True)
)
if conflict_action == 'overwrite':
# Remove conflicting workers from their existing logs
conflicting_logs = WorkLog.objects.filter(
date=log_date,
workers__id__in=worker_ids
)
for existing_log in conflicting_logs:
for w_id in worker_ids:
existing_log.workers.remove(w_id)
workers_to_add = workers
elif conflict_action == 'skip':
# Skip workers who already have logs on this date
workers_to_add = workers.exclude(id__in=workers_with_existing)
skipped_count += len(workers_with_existing & set(worker_ids))
else:
# No conflicts, or first submission — add all workers
workers_to_add = workers
if workers_to_add.exists():
# Create the WorkLog record
work_log = WorkLog.objects.create(
date=log_date,
project=project,
team=team,
supervisor=user, # Auto-set to logged-in user
overtime_amount=overtime_amount,
notes=notes,
)
work_log.workers.set(workers_to_add)
created_count += 1
# Show success message
if created_count > 0:
msg = f'Successfully created {created_count} work log(s).'
if skipped_count > 0:
msg += f' Skipped {skipped_count} conflicts.'
messages.success(request, msg)
else:
messages.warning(request, 'No work logs created — all entries were conflicts.')
return redirect('home')
else:
form = AttendanceLogForm(
user=user,
initial={'date': timezone.now().date()}
)
# Build a list of worker data for the estimated cost JavaScript
# (admins only — supervisors don't see the cost card)
worker_rates = {}
if is_admin(user):
for w in Worker.objects.filter(active=True):
worker_rates[str(w.id)] = str(w.daily_rate)
return render(request, 'core/attendance_log.html', {
'form': form,
'is_admin': is_admin(user),
'worker_rates_json': worker_rates,
})
# === WORK LOG HISTORY ===
# Shows a table of all work logs with filters.
# Supervisors only see their own projects. Admins see everything.
@login_required
def work_history(request):
user = request.user
# Start with base queryset
if is_admin(user):
logs = WorkLog.objects.all()
else:
# Supervisors only see logs for their projects
logs = WorkLog.objects.filter(
Q(supervisor=user) | Q(project__supervisors=user)
).distinct()
# --- Filters ---
# Read filter values from the URL query string
worker_filter = request.GET.get('worker', '')
project_filter = request.GET.get('project', '')
status_filter = request.GET.get('status', '')
if worker_filter:
logs = logs.filter(workers__id=worker_filter).distinct()
if project_filter:
logs = logs.filter(project__id=project_filter)
if status_filter == 'paid':
# "Paid" = has at least one PayrollRecord linked
logs = logs.filter(payroll_records__isnull=False).distinct()
elif status_filter == 'unpaid':
# "Unpaid" = has no PayrollRecord linked
logs = logs.filter(payroll_records__isnull=True)
# Add related data and order by date (newest first)
logs = logs.select_related(
'project', 'supervisor'
).prefetch_related('workers', 'payroll_records').order_by('-date', '-id')
# Get filter options for the dropdowns
if is_admin(user):
filter_workers = Worker.objects.filter(active=True).order_by('name')
filter_projects = Project.objects.filter(active=True).order_by('name')
else:
supervised_teams = Team.objects.filter(supervisor=user, active=True)
filter_workers = Worker.objects.filter(
active=True, teams__in=supervised_teams
).distinct().order_by('name')
filter_projects = Project.objects.filter(
active=True, supervisors=user
).order_by('name')
context = {
'logs': logs,
'filter_workers': filter_workers,
'filter_projects': filter_projects,
'selected_worker': worker_filter,
'selected_project': project_filter,
'selected_status': status_filter,
'is_admin': is_admin(user),
}
return render(request, 'core/work_history.html', context)
# === CSV EXPORT ===
# Downloads the filtered work log history as a CSV file.
# Uses the same filters as the work_history page.
@login_required
def export_work_log_csv(request):
user = request.user
# Build the same queryset as work_history, using the same filters
if is_admin(user):
logs = WorkLog.objects.all()
else:
logs = WorkLog.objects.filter(
Q(supervisor=user) | Q(project__supervisors=user)
).distinct()
worker_filter = request.GET.get('worker', '')
project_filter = request.GET.get('project', '')
status_filter = request.GET.get('status', '')
if worker_filter:
logs = logs.filter(workers__id=worker_filter).distinct()
if project_filter:
logs = logs.filter(project__id=project_filter)
if status_filter == 'paid':
logs = logs.filter(payroll_records__isnull=False).distinct()
elif status_filter == 'unpaid':
logs = logs.filter(payroll_records__isnull=True)
logs = logs.select_related(
'project', 'supervisor'
).prefetch_related('workers', 'payroll_records').order_by('-date', '-id')
# Create the CSV response
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_log_history.csv"'
writer = csv.writer(response)
writer.writerow(['Date', 'Project', 'Workers', 'Overtime', 'Payment Status', 'Supervisor'])
for log in logs:
worker_names = ', '.join(w.name for w in log.workers.all())
payment_status = 'Paid' if log.payroll_records.exists() else 'Unpaid'
overtime_display = log.get_overtime_amount_display() if log.overtime_amount > 0 else 'None'
supervisor_name = log.supervisor.get_full_name() or log.supervisor.username if log.supervisor else '-'
writer.writerow([
log.date.strftime('%Y-%m-%d'),
log.project.name,
worker_names,
overtime_display,
payment_status,
supervisor_name,
])
return response
# === TOGGLE RESOURCE STATUS (AJAX) ===
# Called by the toggle switches on the dashboard to activate/deactivate
# workers, projects, or teams without reloading the page.
@login_required
def toggle_active(request, model_name, item_id):
if request.method != 'POST':
return HttpResponseForbidden("Only POST requests are allowed.")
if not is_admin(request.user):
return HttpResponseForbidden("Not authorized.")
# Map URL parameter to the correct model class
model_map = {
'worker': Worker,
'project': Project,
'team': Team
}
if model_name not in model_map:
return JsonResponse({'error': 'Invalid model'}, status=400)
model = model_map[model_name]
try:
item = model.objects.get(id=item_id)
item.active = not item.active
item.save()
return JsonResponse({
'status': 'success',
'active': item.active,
'message': f'{item.name} is now {"active" if item.active else "inactive"}.'
})
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,
})