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>
This commit is contained in:
parent
77236dd78f
commit
efe5f08682
@ -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
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
</li>
|
||||
{% if user.is_staff %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}" href="{% url 'payroll_dashboard' %}">
|
||||
<i class="fas fa-wallet me-1"></i> Payroll
|
||||
</a>
|
||||
</li>
|
||||
|
||||
1157
core/templates/core/payroll_dashboard.html
Normal file
1157
core/templates/core/payroll_dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
22
core/urls.py
22
core/urls.py
@ -20,4 +20,26 @@ urlpatterns = [
|
||||
|
||||
# AJAX toggle — activates/deactivates workers, projects, teams from dashboard
|
||||
path('toggle/<str:model_name>/<int:item_id>/', 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/<int:worker_id>/', 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/<int:adj_id>/edit/', views.edit_adjustment, name='edit_adjustment'),
|
||||
|
||||
# Delete an unpaid adjustment
|
||||
path('payroll/adjustment/<int:adj_id>/delete/', views.delete_adjustment, name='delete_adjustment'),
|
||||
|
||||
# Preview a worker's payslip (AJAX — returns JSON)
|
||||
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
||||
]
|
||||
|
||||
629
core/views.py
629
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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user