import os import platform import json import csv import calendar import datetime from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone from django.contrib.auth.decorators import login_required, user_passes_test from django.db.models import Sum, Q, Prefetch from django.core.mail import send_mail, EmailMultiAlternatives from django.conf import settings from django.contrib import messages from django.http import JsonResponse, HttpResponse from django.template.loader import render_to_string from django.utils.html import strip_tags from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet from datetime import timedelta from decimal import Decimal from core.utils import render_to_pdf # Adjustment types that ADD to pay ADDITIVE_TYPES = ['BONUS', 'OVERTIME', 'LOAN'] # Adjustment types that SUBTRACT from pay DEDUCTIVE_TYPES = ['DEDUCTION', 'LOAN_REPAYMENT', 'ADVANCE'] def is_admin(user): """Check if user has admin-level access (staff, superuser, or in Admin group).""" return user.is_staff or user.is_superuser def is_supervisor(user): """Check if user is a work logger (assigned to teams or projects, or in Work Logger group).""" if user.groups.filter(name='Work Logger').exists(): return True return user.managed_teams.exists() or user.assigned_projects.exists() def is_staff_or_supervisor(user): """Check if user has at least supervisor-level access.""" return is_admin(user) or is_supervisor(user) @login_required def home(request): """Render the landing screen with dashboard stats.""" # If not staff or supervisor, redirect to log attendance if not is_staff_or_supervisor(request.user): return redirect('log_attendance') user_is_admin = is_admin(request.user) now = timezone.now() today = now.date() # Counts (used by both admin and non-admin) workers_count = Worker.objects.filter(is_active=True).count() projects_count = Project.objects.filter(is_active=True).count() teams_count = Team.objects.filter(is_active=True).count() # Recent logs with team info recent_logs = WorkLog.objects.select_related('team', 'project').prefetch_related('workers').order_by('-date', '-id')[:5] # --- Admin-only analytics --- outstanding_total = 0 recent_payments_total = 0 active_loans_count = 0 active_loans_total = 0 week_worker_days = 0 week_projects = 0 if user_is_admin: # 1. Outstanding Payments active_workers = Worker.objects.filter(is_active=True).prefetch_related('work_logs', 'adjustments') for worker in active_workers: # Unpaid logs unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count() log_amount = unpaid_logs_count * worker.day_rate # Pending Adjustments pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_total = Decimal('0.00') for adj in pending_adjustments: 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 outstanding_total += max(total_payable, Decimal('0.00')) # 2. Paid This Month recent_payments_total = PayrollRecord.objects.filter( date__year=today.year, date__month=today.month ).aggregate(total=Sum('amount'))['total'] or 0 # 3. Active Loans active_loans = Loan.objects.filter(is_active=True) active_loans_count = active_loans.count() active_loans_total = active_loans.aggregate(total=Sum('balance'))['total'] or 0 # 4. This Week stats (Mon-Sun) - visible to all users week_start = today - timedelta(days=today.weekday()) # Monday week_end = week_start + timedelta(days=6) # Sunday week_logs = WorkLog.objects.filter(date__range=(week_start, week_end)).prefetch_related('workers') week_projects = week_logs.values('project').distinct().count() for log in week_logs: week_worker_days += log.workers.count() # Manage Resources data (admin only) all_workers = Worker.objects.all().prefetch_related('teams').order_by('name') if user_is_admin else [] all_projects = Project.objects.all().order_by('name') if user_is_admin else [] all_teams = Team.objects.all().prefetch_related('workers').order_by('name') if user_is_admin else [] # Outstanding Project Costs (Admin only) - Added for Dashboard visibility outstanding_project_costs = [] if user_is_admin: for project in all_projects: outstanding_cost = 0 # Unpaid WorkLogs unpaid_logs = project.logs.filter(paid_in__isnull=True).prefetch_related('workers') for log in unpaid_logs: for worker in log.workers.all(): outstanding_cost += worker.day_rate # Unpaid Adjustments linked to this project project_adjustments = PayrollAdjustment.objects.filter( work_log__project=project, payroll_record__isnull=True ) for adj in project_adjustments: if adj.type in ADDITIVE_TYPES: outstanding_cost += adj.amount elif adj.type in DEDUCTIVE_TYPES: outstanding_cost -= adj.amount if outstanding_cost > 0: outstanding_project_costs.append({'name': project.name, 'cost': outstanding_cost}) context = { "is_admin_user": user_is_admin, "workers_count": workers_count, "projects_count": projects_count, "teams_count": teams_count, "recent_logs": recent_logs, "current_time": now, # Admin financials "outstanding_total": outstanding_total, "recent_payments_total": recent_payments_total, "active_loans_count": active_loans_count, "active_loans_total": active_loans_total, "outstanding_project_costs": outstanding_project_costs, # This week "week_worker_days": week_worker_days, "week_projects": week_projects, # Manage resources "all_workers": all_workers, "all_projects": all_projects, "all_teams": all_teams, } return render(request, "core/index.html", context) @login_required def log_attendance(request): # Build team workers map for frontend JS (needed for both GET and POST if re-rendering) teams_qs = Team.objects.filter(is_active=True) if request.user.is_authenticated and not request.user.is_superuser: teams_qs = teams_qs.filter(supervisor=request.user) team_workers_map = {} for team in teams_qs: # Get active workers for the team active_workers = team.workers.filter(is_active=True).values_list('id', flat=True) team_workers_map[team.id] = list(active_workers) if request.method == 'POST': form = WorkLogForm(request.POST, user=request.user) if form.is_valid(): start_date = form.cleaned_data['date'] end_date = form.cleaned_data.get('end_date') include_sat = form.cleaned_data.get('include_saturday') include_sun = form.cleaned_data.get('include_sunday') selected_workers = form.cleaned_data['workers'] project = form.cleaned_data['project'] team = form.cleaned_data.get('team') notes = form.cleaned_data['notes'] overtime = form.cleaned_data.get('overtime', 0) # Read overtime conflict_action = request.POST.get('conflict_action') # Generate Target Dates target_dates = [] if end_date and end_date >= start_date: curr = start_date while curr <= end_date: # 5 = Saturday, 6 = Sunday if (curr.weekday() == 5 and not include_sat) or (curr.weekday() == 6 and not include_sun): curr += timedelta(days=1) continue target_dates.append(curr) curr += timedelta(days=1) else: target_dates = [start_date] if not target_dates: messages.warning(request, "No valid dates selected (check weekends).") return render(request, 'core/log_attendance.html', { 'form': form, 'team_workers_json': json.dumps(team_workers_map) }) # Check Conflicts - Scan all target dates if not conflict_action: conflicts = [] for d in target_dates: # Find workers who already have a log on this date existing_logs = WorkLog.objects.filter(date=d, workers__in=selected_workers).distinct() for log in existing_logs: # Which of the selected workers are in this log? for w in log.workers.all(): if w in selected_workers: # Avoid adding duplicates if multiple logs exist for same worker/day (rare but possible) conflict_entry = {'name': f"{w.name} ({d.strftime('%Y-%m-%d')})"} if conflict_entry not in conflicts: conflicts.append(conflict_entry) if conflicts: # Prepare worker rates for JS calculation worker_qs = form.fields['workers'].queryset worker_rates = {w.id: float(w.day_rate) for w in worker_qs} context = { 'form': form, 'team_workers_json': json.dumps(team_workers_map), 'conflicting_workers': conflicts, 'is_conflict': True, 'worker_rates_json': json.dumps(worker_rates), } return render(request, 'core/log_attendance.html', context) # Execution Phase created_count = 0 skipped_count = 0 overwritten_count = 0 for d in target_dates: # Find conflicts for this specific day day_conflicts = Worker.objects.filter( work_logs__date=d, id__in=selected_workers.values_list('id', flat=True) ).distinct() workers_to_save = list(selected_workers) if day_conflicts.exists(): if conflict_action == 'skip': conflicting_ids = day_conflicts.values_list('id', flat=True) workers_to_save = [w for w in selected_workers if w.id not in conflicting_ids] skipped_count += day_conflicts.count() elif conflict_action == 'overwrite': # Remove conflicting workers from their OLD logs for worker in day_conflicts: old_logs = WorkLog.objects.filter(date=d, workers=worker) for log in old_logs: log.workers.remove(worker) if log.workers.count() == 0: log.delete() overwritten_count += day_conflicts.count() # workers_to_save remains full list if workers_to_save: # Create Log log = WorkLog.objects.create( date=d, project=project, team=team, notes=notes, supervisor=request.user if request.user.is_authenticated else None, overtime=overtime # Save overtime ) log.workers.set(workers_to_save) created_count += len(workers_to_save) msg = f"Logged {created_count} entries." if skipped_count: msg += f" Skipped {skipped_count} duplicates." if overwritten_count: msg += f" Overwrote {overwritten_count} previous entries." messages.success(request, msg) # Redirect to home, which will then redirect back to log_attendance if restricted return redirect('home') else: form = WorkLogForm(user=request.user if request.user.is_authenticated else None) # Pass worker rates for frontend total calculation (admin only) user_is_admin = is_admin(request.user) if user_is_admin: worker_qs = form.fields['workers'].queryset worker_rates = {w.id: float(w.day_rate) for w in worker_qs} else: worker_rates = {} context = { 'form': form, 'is_admin_user': user_is_admin, 'team_workers_json': json.dumps(team_workers_map), 'worker_rates_json': json.dumps(worker_rates) } return render(request, 'core/log_attendance.html', context) @login_required def work_log_list(request): """View work log history and payroll adjustments with advanced filtering.""" if not is_staff_or_supervisor(request.user): return redirect('log_attendance') worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all' view_mode = request.GET.get('view', 'list') # --- 1. Fetch WorkLogs --- logs = WorkLog.objects.all().select_related('team', 'project', 'supervisor').prefetch_related('workers', 'paid_in').order_by('-date', '-id') target_worker = None if worker_id: logs = logs.filter(workers__id=worker_id) target_worker = Worker.objects.filter(id=worker_id).first() if team_id: logs = logs.filter(team_id=team_id) if project_id: logs = logs.filter(project_id=project_id) if payment_status == 'paid': logs = logs.filter(paid_in__isnull=False).distinct() elif payment_status == 'unpaid': if worker_id: worker = get_object_or_404(Worker, pk=worker_id) logs = logs.exclude(paid_in__worker=worker) else: logs = logs.filter(paid_in__isnull=True) # --- 2. Fetch Adjustments --- adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log') if worker_id: adjustments = adjustments.filter(worker_id=worker_id) if project_id: # Include only adjustments linked to this project (via work_log) adjustments = adjustments.filter(work_log__project_id=project_id) if team_id: adjustments = adjustments.filter(work_log__team_id=team_id) if payment_status == 'paid': adjustments = adjustments.filter(payroll_record__isnull=False) elif payment_status == 'unpaid': adjustments = adjustments.filter(payroll_record__isnull=True) # --- 3. Date Filtering for Calendar View (Applied to both) --- start_date = None end_date = None curr_year = timezone.now().year curr_month = timezone.now().month if view_mode == 'calendar': today = timezone.now().date() try: curr_year = int(request.GET.get('year', today.year)) curr_month = int(request.GET.get('month', today.month)) except ValueError: curr_year = today.year curr_month = today.month if curr_month < 1: curr_month = 1; if curr_month > 12: curr_month = 12; _, num_days = calendar.monthrange(curr_year, curr_month) start_date = datetime.date(curr_year, curr_month, 1) end_date = datetime.date(curr_year, curr_month, num_days) logs = logs.filter(date__range=(start_date, end_date)) # No 'show_adjustments' check needed as query is already filtered adjustments = adjustments.filter(date__range=(start_date, end_date)) # --- 4. Combine and Sort --- user_is_admin = is_admin(request.user) total_amount = 0 combined_records = [] # Prepare Chart Data (Overtime) - Admin only ot_chart_labels = [] ot_chart_data = [] if user_is_admin: from django.db.models.functions import TruncMonth ot_stats = adjustments.filter(type='OVERTIME') \ .annotate(month=TruncMonth('date')) \ .values('month') \ .annotate(total=Sum('amount')) \ .order_by('month') ot_chart_labels = [s['month'].strftime('%b %Y') for s in ot_stats] ot_chart_data = [float(s['total']) for s in ot_stats] # Process Logs for log in logs: record = { 'type': 'WORK', 'date': log.date, 'obj': log, 'project_name': log.project.name, 'team_name': log.team.name if log.team else None, 'workers': list(log.workers.all()), 'supervisor': log.supervisor.username if log.supervisor else "System", 'is_paid': log.paid_in.exists() if not worker_id else log.paid_in.filter(worker_id=worker_id).exists(), 'paid_record': log.paid_in.first() if not worker_id else log.paid_in.filter(worker_id=worker_id).first(), 'notes': log.notes, 'sort_id': log.id } # Calculate amount if user_is_admin: if target_worker: amt = target_worker.day_rate else: amt = sum(w.day_rate for w in log.workers.all()) record['amount'] = amt total_amount += amt else: record['amount'] = None combined_records.append(record) # Process Adjustments for adj in adjustments: # Determine signed amount for display/total amt = adj.amount if adj.type in DEDUCTIVE_TYPES: amt = -amt record = { 'type': 'ADJ', 'date': adj.date, 'obj': adj, 'project_name': f"{adj.get_type_display()}", # Use project column for Type 'team_name': None, 'workers': [adj.worker], 'supervisor': "System", 'is_paid': adj.payroll_record is not None, 'paid_record': adj.payroll_record, 'notes': adj.description, 'amount': amt if user_is_admin else None, 'sort_id': adj.id } if user_is_admin: total_amount += amt combined_records.append(record) # Sort combined list by Date Descending, then ID Descending combined_records.sort(key=lambda x: (x['date'], x['sort_id']), reverse=True) # Context for filters context = { 'is_admin_user': user_is_admin, 'total_amount': total_amount if user_is_admin else None, 'workers': Worker.objects.filter(is_active=True).order_by('name'), 'teams': Team.objects.filter(is_active=True).order_by('name'), 'projects': Project.objects.filter(is_active=True).order_by('name'), 'selected_worker': int(worker_id) if worker_id else None, 'selected_team': int(team_id) if team_id else None, 'selected_project': int(project_id) if project_id else None, 'selected_payment_status': payment_status, 'target_worker': target_worker, 'view_mode': view_mode, 'ot_chart_labels': json.dumps(ot_chart_labels), 'ot_chart_data': json.dumps(ot_chart_data), } if view_mode == 'calendar': # Group by date for easy lookup in template records_map = {} for rec in combined_records: d = rec['date'] if d not in records_map: records_map[d] = [] records_map[d].append(rec) cal = calendar.Calendar(firstweekday=0) # Monday is 0 month_dates = cal.monthdatescalendar(curr_year, curr_month) # Prepare structured data for template calendar_weeks = [] for week in month_dates: week_data = [] for d in week: week_data.append({ 'date': d, 'day': d.day, 'is_current_month': d.month == curr_month, 'records': records_map.get(d, []) }) calendar_weeks.append(week_data) # Build JSON lookup for day detail panel calendar_detail_data = {} for date_key, day_records in records_map.items(): date_str = date_key.strftime('%Y-%m-%d') calendar_detail_data[date_str] = [] for rec in day_records: workers_list = [w.name for w in rec['workers']] team_name = rec['team_name'] if rec['team_name'] else '' # Format for JS calendar_detail_data[date_str].append({ 'project': rec['project_name'], # This holds Type for ADJ 'teams': [team_name] if team_name else [], 'workers': workers_list, 'supervisor': rec['supervisor'], 'notes': rec['notes'] or '', 'type': rec['type'], 'amount': float(rec['amount']) if rec['amount'] is not None else 0 }) # Nav Links prev_month_date = start_date - datetime.timedelta(days=1) next_month_date = end_date + datetime.timedelta(days=1) context.update({ 'calendar_detail_json': json.dumps(calendar_detail_data), 'calendar_weeks': calendar_weeks, 'curr_month': curr_month, 'curr_year': curr_year, 'month_name': calendar.month_name[curr_month], 'prev_month': prev_month_date.month, 'prev_year': prev_month_date.year, 'next_month': next_month_date.month, 'next_year': next_month_date.year, }) else: context['records'] = combined_records return render(request, 'core/work_log_list.html', context) @login_required def export_work_log_csv(request): """Export filtered work logs and adjustments to CSV.""" if not is_staff_or_supervisor(request.user): return HttpResponse("Unauthorized", status=401) worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') payment_status = request.GET.get('payment_status') # --- 1. Fetch WorkLogs --- logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id') target_worker = None if worker_id: logs = logs.filter(workers__id=worker_id) target_worker = Worker.objects.filter(id=worker_id).first() if team_id: team_workers = Worker.objects.filter(teams__id=team_id) logs = logs.filter(workers__in=team_workers).distinct() if project_id: logs = logs.filter(project_id=project_id) if payment_status == 'paid': logs = logs.filter(paid_in__isnull=False).distinct() elif payment_status == 'unpaid': if worker_id: worker = get_object_or_404(Worker, pk=worker_id) logs = logs.exclude(paid_in__worker=worker) else: logs = logs.filter(paid_in__isnull=True) # --- 2. Fetch Adjustments --- adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log') if worker_id: adjustments = adjustments.filter(worker_id=worker_id) if project_id: adjustments = adjustments.filter(work_log__project_id=project_id) if team_id: adjustments = adjustments.filter(work_log__team_id=team_id) if payment_status == 'paid': adjustments = adjustments.filter(payroll_record__isnull=False) elif payment_status == 'unpaid': adjustments = adjustments.filter(payroll_record__isnull=True) adjustments = list(adjustments) user_is_admin = is_admin(request.user) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="work_logs_and_adjustments.csv"' writer = csv.writer(response) if user_is_admin: writer.writerow(['Date', 'Description', 'Workers', 'Amount', 'Payment Status', 'Supervisor']) else: writer.writerow(['Date', 'Description', 'Workers', 'Supervisor']) # Combine and Sort combined = [] for log in logs: if target_worker: workers_str = target_worker.name else: workers_str = ", ".join([w.name for w in log.workers.all()]) amt = 0 is_paid = False if user_is_admin: if target_worker: amt = target_worker.day_rate is_paid = log.paid_in.filter(worker=target_worker).exists() else: amt = sum(w.day_rate for w in log.workers.all()) is_paid = log.paid_in.exists() combined.append({ 'date': log.date, 'desc': log.project.name, 'workers': workers_str, 'amount': amt, 'status': "Paid" if is_paid else "Pending", 'supervisor': log.supervisor.username if log.supervisor else "System" }) for adj in adjustments: amt = adj.amount if adj.type in DEDUCTIVE_TYPES: amt = -amt is_paid = adj.payroll_record is not None combined.append({ 'date': adj.date, 'desc': f"{adj.get_type_display()} - {adj.description}", 'workers': adj.worker.name, 'amount': amt, 'status': "Paid" if is_paid else "Pending", 'supervisor': "System" }) # Sort combined.sort(key=lambda x: x['date'], reverse=True) for row in combined: if user_is_admin: writer.writerow([ row['date'], row['desc'], row['workers'], f"{row['amount']:.2f}", row['status'], row['supervisor'] ]) else: writer.writerow([ row['date'], row['desc'], row['workers'], row['supervisor'] ]) return response @login_required def manage_resources(request): """Redirect to dashboard which now includes manage resources.""" return redirect('home') @login_required def toggle_resource_status(request, model_type, pk): """Toggle the is_active status of a resource.""" if not is_admin(request.user): return redirect('log_attendance') if request.method == 'POST': model_map = { 'worker': Worker, 'project': Project, 'team': Team, } model_class = model_map.get(model_type) if model_class: obj = get_object_or_404(model_class, pk=pk) obj.is_active = not obj.is_active obj.save() if request.headers.get('x-requested-with') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'is_active': obj.is_active, 'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}." }) return redirect('home') @login_required def payroll_dashboard(request): """Dashboard for payroll management with filtering.""" if not is_admin(request.user): return redirect('log_attendance') status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans # Common Analytics outstanding_total = 0 active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments') workers_data = [] # For pending payments all_ot_data = [] # For JSON context for worker in active_workers: # Unpaid Work Logs unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() log_amount = log_count * worker.day_rate # Overtime Logic (Updated: Check M2M field) ot_logs = worker.work_logs.filter(overtime__gt=0).exclude(overtime_paid_to=worker).exclude(paid_in__worker=worker).select_related('project') ot_data_worker = [] ot_hours_unpriced = Decimal('0.0') for log in ot_logs: 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), 'ot_label': log.get_overtime_display(), } ot_data_worker.append(entry) all_ot_data.append(entry) ot_hours_unpriced += log.overtime # Pending Adjustments (unlinked to any payroll record) pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_total = Decimal('0.00') for adj in pending_adjustments: if adj.type in ADDITIVE_TYPES: adj_total += adj.amount elif adj.type in DEDUCTIVE_TYPES: adj_total -= adj.amount total_payable = log_amount + adj_total # Only show if there is something to pay or negative (e.g. loan repayment greater than work) # Note: If total_payable is negative, it implies they owe money. if log_count > 0 or pending_adjustments.exists(): outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total if status_filter in ['pending', 'all']: workers_data.append({ 'worker': worker, 'unpaid_count': log_count, 'unpaid_amount': log_amount, 'adj_amount': adj_total, 'total_payable': total_payable, 'adjustments': pending_adjustments, 'logs': unpaid_logs, 'ot_data': ot_data_worker, 'ot_hours_unpriced': float(ot_hours_unpriced), 'day_rate': float(worker.day_rate), 'has_pending_advances': pending_adjustments.filter(type='ADVANCE').exists(), }) # Paid History paid_records = [] if status_filter in ['paid', 'all']: paid_records = PayrollRecord.objects.select_related('worker').order_by('-date', '-id') # Analytics: Project Costs (Active Projects) project_costs = [] outstanding_project_costs = [] active_projects = Project.objects.filter(is_active=True) for project in active_projects: # 1. Total Historical Cost cost = 0 logs = project.logs.all() for log in logs: for worker in log.workers.all(): cost += worker.day_rate if cost > 0: project_costs.append({'name': project.name, 'cost': cost}) # 2. Outstanding Cost (Unpaid) outstanding_cost = 0 # Unpaid WorkLogs unpaid_logs = project.logs.filter(paid_in__isnull=True).prefetch_related('workers') for log in unpaid_logs: for worker in log.workers.all(): outstanding_cost += worker.day_rate # Unpaid Adjustments linked to this project project_adjustments = PayrollAdjustment.objects.filter( work_log__project=project, payroll_record__isnull=True ) for adj in project_adjustments: if adj.type in ADDITIVE_TYPES: outstanding_cost += adj.amount elif adj.type in DEDUCTIVE_TYPES: outstanding_cost -= adj.amount if outstanding_cost > 0: outstanding_project_costs.append({'name': project.name, 'cost': outstanding_cost}) # Analytics: Previous 2 months payments two_months_ago = timezone.now().date() - timedelta(days=60) recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0 # Active Loans for dropdowns/modals all_workers = Worker.objects.filter(is_active=True).order_by('name') # Loans data (for loans tab) loan_filter = request.GET.get('loan_status', 'active') if loan_filter == 'history': loans = Loan.objects.filter(is_active=False).order_by('-date') else: loans = Loan.objects.filter(is_active=True).order_by('-date') # --- Chart Data: Monthly payroll totals & per-project costs (last 6 months) --- today = timezone.now().date() chart_months = [] # list of (year, month) tuples, oldest first for i in range(5, -1, -1): # Go back i months from current month m = today.month - i y = today.year while m <= 0: m += 12 y -= 1 chart_months.append((y, m)) chart_labels = [] # e.g. ["Sep 2025", "Oct 2025", ...] chart_totals = [] # total payroll paid per month # For per-project chart: {project_name: [month0_cost, month1_cost, ...]}} all_project_names = list(Project.objects.values_list('name', flat=True).order_by('name')) project_monthly = {name: [] for name in all_project_names} ot_history_totals = [] # Overtime history for year, month in chart_months: chart_labels.append(f"{calendar.month_abbr[month]} {year}") # Total paid out this month _, last_day = calendar.monthrange(year, month) month_start = datetime.date(year, month, 1) month_end = datetime.date(year, month, last_day) month_paid = PayrollRecord.objects.filter( date__gte=month_start, date__lte=month_end ).aggregate(total=Sum('amount'))['total'] or 0 chart_totals.append(float(month_paid)) # Overtime paid this month ot_month_total = PayrollAdjustment.objects.filter( type='OVERTIME', date__gte=month_start, date__lte=month_end ).aggregate(total=Sum('amount'))['total'] or 0 ot_history_totals.append(float(ot_month_total)) # Per-project labour cost this month (from work logs × day rates) month_logs = WorkLog.objects.filter( date__gte=month_start, date__lte=month_end ).prefetch_related('workers', 'project') project_cost_month = {name: 0 for name in all_project_names} for log in month_logs: pname = log.project.name for worker in log.workers.all(): project_cost_month[pname] = project_cost_month.get(pname, 0) + float(worker.day_rate) for name in all_project_names: project_monthly[name].append(project_cost_month.get(name, 0)) # Filter out projects with zero cost across all months project_chart_data = [ {'name': name, 'data': costs} for name, costs in project_monthly.items() if any(c > 0 for c in costs) ] context = { 'workers_data': workers_data, 'paid_records': paid_records, 'outstanding_total': outstanding_total, 'project_costs': project_costs, 'outstanding_project_costs': outstanding_project_costs, 'recent_payments_total': recent_payments_total, 'active_tab': status_filter, 'all_workers': all_workers, 'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES, '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), 'ot_history_json': json.dumps(ot_history_totals), } return render(request, 'core/payroll_dashboard.html', context) @login_required def process_payment(request, worker_id): """Process payment for a worker, mark logs as paid, link adjustments, and email receipt.""" if not is_admin(request.user): return redirect('log_attendance') worker = get_object_or_404(Worker, pk=worker_id) if request.method == 'POST': # Find unpaid logs unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() logs_amount = log_count * worker.day_rate # Find pending adjustments pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_amount = Decimal('0.00') for adj in pending_adjustments: if adj.type in ADDITIVE_TYPES: adj_amount += adj.amount elif adj.type in DEDUCTIVE_TYPES: adj_amount -= adj.amount total_amount = logs_amount + adj_amount if log_count > 0 or pending_adjustments.exists(): # Create Payroll Record payroll_record = PayrollRecord.objects.create( worker=worker, amount=total_amount, date=timezone.now().date() ) # Link logs payroll_record.work_logs.set(unpaid_logs) # Link Adjustments and Handle Loans for adj in pending_adjustments: adj.payroll_record = payroll_record adj.save() # Update Loan Balance if it's a repayment if adj.type == 'LOAN_REPAYMENT' and adj.loan: adj.loan.balance -= adj.amount if adj.loan.balance <= 0: adj.loan.balance = 0 adj.loan.is_active = False adj.loan.save() payroll_record.save() # Email Notification subject = f"Payslip for {worker.name} - {payroll_record.date}" # Prepare Context context = { 'record': payroll_record, 'logs_count': log_count, 'logs_amount': logs_amount, 'adjustments': payroll_record.adjustments.all(), } # 1. Render HTML Body html_message = render_to_string('core/email/payslip_email.html', context) plain_message = strip_tags(html_message) # 2. Render PDF Attachment pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', context) recipient_list = [settings.SPARK_RECEIPT_EMAIL] try: # Construct Email with Attachment email = EmailMultiAlternatives( subject, plain_message, settings.DEFAULT_FROM_EMAIL, recipient_list, ) email.attach_alternative(html_message, "text/html") if pdf_content: email.attach(f"Payslip_{worker.id}_{payroll_record.date}.pdf", pdf_content, 'application/pdf') email.send() messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}") except Exception as e: messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}") return redirect('payroll_dashboard') return redirect('payroll_dashboard') @login_required def preview_payslip(request, worker_id): """Return payslip preview data as JSON (no DB changes, no email).""" if not is_admin(request.user): return JsonResponse({'error': 'Unauthorized'}, status=403) worker = get_object_or_404(Worker, pk=worker_id) # Calculate the same data as process_payment, but read-only unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() logs_amount = float(log_count * worker.day_rate) pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_list = [] for adj in pending_adjustments: adj_list.append({ 'type': adj.get_type_display(), 'description': adj.description or '', 'amount': float(adj.amount), 'is_deduction': adj.type in DEDUCTIVE_TYPES, }) adj_amount = Decimal('0.00') for adj in pending_adjustments: if adj.type in ADDITIVE_TYPES: adj_amount += adj.amount elif adj.type in DEDUCTIVE_TYPES: adj_amount -= adj.amount total_amount = float(logs_amount + float(adj_amount)) return JsonResponse({ 'worker_name': worker.name, 'worker_id_no': worker.id_no or '', 'date': timezone.now().date().strftime('%Y-%m-%d'), 'day_rate': float(worker.day_rate), 'days_worked': log_count, 'base_pay': logs_amount, 'adjustments': adj_list, 'net_pay': total_amount, }) @login_required def price_overtime(request): """Create OVERTIME PayrollAdjustments from unpriced overtime logs.""" if not is_admin(request.user): return redirect('payroll_dashboard') if request.method != 'POST': return redirect('payroll_dashboard') log_ids = request.POST.getlist('log_id') worker_ids = request.POST.getlist('worker_id') rate_pcts = request.POST.getlist('rate_pct') created = 0 for log_id, worker_id, rate_pct in zip(log_ids, worker_ids, rate_pcts): try: worklog = WorkLog.objects.get(pk=log_id) worker = Worker.objects.get(pk=worker_id) rate = Decimal(rate_pct) # Formula: Day Rate * Overtime Fraction * (Rate % / 100) amount = worker.day_rate * worklog.overtime * (rate / Decimal('100')) if amount > 0: PayrollAdjustment.objects.create( worker=worker, type='OVERTIME', amount=amount, date=worklog.date, description=f"Overtime: {worklog.get_overtime_display()} @ {rate_pct}% - {worklog.date.strftime('%d %b %Y')}", work_log=worklog ) created += 1 # Updated: Use M2M field worklog.overtime_paid_to.add(worker) except (WorkLog.DoesNotExist, Worker.DoesNotExist, Exception): continue messages.success(request, f"Created {created} overtime adjustment(s).") return redirect('payroll_dashboard') @login_required def payslip_detail(request, pk): """Show details of a payslip (Payment Record).""" if not is_admin(request.user): return redirect('log_attendance') record = get_object_or_404(PayrollRecord, pk=pk) # Get the logs included in this payment logs = record.work_logs.all().order_by('date') adjustments = record.adjustments.all().order_by('type') # Calculate base pay from logs (re-verify logic) # The record.amount is the final NET. # We can reconstruct the display. base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker) adjustments_net = record.amount - base_pay context = { 'record': record, 'logs': logs, 'adjustments': adjustments, 'base_pay': base_pay, 'adjustments_net': adjustments_net, } return render(request, 'core/payslip.html', context) @login_required def loan_list(request): """Redirect to payroll dashboard loans tab.""" return redirect('/payroll/?status=loans') @login_required def add_loan(request): """Create a new loan.""" if not is_admin(request.user): return redirect('log_attendance') if request.method == 'POST': worker_id = request.POST.get('worker') amount = request.POST.get('amount') reason = request.POST.get('reason') date = request.POST.get('date') or timezone.now().date() if worker_id and amount: worker = get_object_or_404(Worker, pk=worker_id) Loan.objects.create( worker=worker, amount=amount, date=date, reason=reason ) messages.success(request, f"Loan of R{amount} recorded for {worker.name}.") return redirect('/payroll/?status=loans') @login_required def add_adjustment(request): """Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers.""" if not is_admin(request.user): return redirect('log_attendance') if request.method == 'POST': worker_ids = request.POST.getlist('workers') adj_type = request.POST.get('type') amount = request.POST.get('amount') description = request.POST.get('description') date = request.POST.get('date') or timezone.now().date() loan_id = request.POST.get('loan_id') # Optional, for repayments if worker_ids and amount and adj_type: success_names = [] skip_names = [] for worker_id in worker_ids: worker = get_object_or_404(Worker, pk=worker_id) # --- ADVANCE: validate, create adjustment + standalone PayrollRecord, send payslip --- if adj_type == 'ADVANCE': advance_amount = Decimal(amount) # Must have unpaid work logs unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) log_count = unpaid_logs.count() if log_count == 0: skip_names.append(f"{worker.name} (no unpaid work)") continue # Calculate max available (earned + existing adjustments) logs_amount = log_count * worker.day_rate existing_pending = worker.adjustments.filter(payroll_record__isnull=True) existing_adj_total = Decimal('0.00') for existing_adj in existing_pending: if existing_adj.type in ADDITIVE_TYPES: existing_adj_total += existing_adj.amount elif existing_adj.type in DEDUCTIVE_TYPES: existing_adj_total -= existing_adj.amount max_available = logs_amount + existing_adj_total if advance_amount > max_available: skip_names.append(f"{worker.name} (R{advance_amount} exceeds available R{max_available:.2f})") continue # Create ADVANCE adjustment (stays pending — payroll_record=NULL) PayrollAdjustment.objects.create( worker=worker, type='ADVANCE', amount=advance_amount, description=description or 'Advance payment', date=date, ) # Create standalone PayrollRecord (NO work_logs linked) advance_date = date if isinstance(date, datetime.date) else timezone.now().date() advance_record = PayrollRecord.objects.create( worker=worker, amount=advance_amount, date=advance_date, ) # Send advance payslip to Spark subject = f"Advance Payment for {worker.name} - {advance_record.date}" email_context = { 'record': advance_record, 'logs_count': 0, 'logs_amount': Decimal('0.00'), 'adjustments': [], 'is_advance': True, 'advance_amount': advance_amount, 'advance_description': description or 'Advance payment', } html_message = render_to_string('core/email/payslip_email.html', email_context) plain_message = strip_tags(html_message) pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context) try: email_obj = EmailMultiAlternatives( subject, plain_message, settings.DEFAULT_FROM_EMAIL, [settings.SPARK_RECEIPT_EMAIL], ) email_obj.attach_alternative(html_message, "text/html") if pdf_content: email_obj.attach( f"Advance_{worker.id}_{advance_record.date}.pdf", pdf_content, 'application/pdf' ) email_obj.send() except Exception as e: messages.warning(request, f"Advance recorded for {worker.name}, but email failed: {e}") success_names.append(worker.name) continue # Validation for repayment OR Creation for New Loan loan = None if adj_type == 'LOAN_REPAYMENT': if loan_id: loan = get_object_or_404(Loan, pk=loan_id) else: loan = worker.loans.filter(is_active=True).first() if not loan: skip_names.append(worker.name) continue elif adj_type == 'LOAN': loan = Loan.objects.create( worker=worker, amount=amount, date=date, reason=description ) PayrollAdjustment.objects.create( worker=worker, type=adj_type, amount=amount, description=description, date=date, loan=loan ) success_names.append(worker.name) if success_names: names = ', '.join(success_names) if adj_type == 'ADVANCE': messages.success(request, f"Advance of R{amount} processed and payslip sent for {names}.") else: messages.success(request, f"{adj_type} of R{amount} added for {names}.") if skip_names: names = ', '.join(skip_names) messages.warning(request, f"Skipped: {names}.") return redirect('payroll_dashboard') @login_required def edit_adjustment(request, pk): """Edit an unpaid payroll adjustment. Admin only, POST only.""" if not is_admin(request.user): return redirect('log_attendance') if request.method != 'POST': return redirect('payroll_dashboard') adj = get_object_or_404(PayrollAdjustment, pk=pk) # Only allow editing unpaid adjustments if adj.payroll_record is not None: messages.error(request, "Cannot edit a paid adjustment.") return redirect('payroll_dashboard') # Advance amounts cannot be edited — payslip was already sent if adj.type == 'ADVANCE': messages.warning(request, "Advance amounts cannot be edited after creation. Delete and re-create instead.") return redirect('payroll_dashboard') amount = request.POST.get('amount') description = request.POST.get('description') date = request.POST.get('date') new_type = request.POST.get('type') if amount: adj.amount = Decimal(amount) if description: adj.description = description if date: adj.date = date # Only allow type change for BONUS/DEDUCTION (others have linked objects) if new_type and adj.type in ('BONUS', 'DEDUCTION') and new_type in ('BONUS', 'DEDUCTION'): adj.type = new_type adj.save() # If LOAN type, sync the linked Loan object if adj.type == 'LOAN' and adj.loan: adj.loan.amount = adj.amount adj.loan.balance = adj.amount adj.loan.reason = adj.description adj.loan.save() messages.success(request, f"Updated {adj.get_type_display()} for {adj.worker.name}.") return redirect('payroll_dashboard') @login_required def delete_adjustment(request, pk): """Delete an unpaid payroll adjustment. Admin only, POST only.""" if not is_admin(request.user): return redirect('log_attendance') if request.method != 'POST': return redirect('payroll_dashboard') adj = get_object_or_404(PayrollAdjustment, pk=pk) # Only allow deleting unpaid adjustments if adj.payroll_record is not None: messages.error(request, "Cannot delete a paid adjustment.") return redirect('payroll_dashboard') worker_name = adj.worker.name type_display = adj.get_type_display() if adj.type == 'LOAN' and adj.loan: loan = adj.loan # Check if any repayments for this loan have been paid paid_repayments = PayrollAdjustment.objects.filter( loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=False ).exists() if paid_repayments: messages.warning(request, f"Cannot delete loan for {worker_name} — it has paid repayments. Delete unpaid repayments manually first.") return redirect('payroll_dashboard') # Delete any unpaid repayment adjustments linked to this loan PayrollAdjustment.objects.filter( loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=True ).delete() # Delete the loan itself loan.delete() elif adj.type == 'OVERTIME' and adj.work_log: # Remove worker from overtime_paid_to so OT can be re-priced adj.work_log.overtime_paid_to.remove(adj.worker) adj.delete() messages.success(request, f"Deleted {type_display} for {worker_name}.") return redirect('payroll_dashboard') @login_required def create_receipt(request): """Create a new expense receipt and email it.""" if not is_staff_or_supervisor(request.user): return redirect('log_attendance') if request.method == 'POST': form = ExpenseReceiptForm(request.POST) items = ExpenseLineItemFormSet(request.POST) if form.is_valid() and items.is_valid(): receipt = form.save(commit=False) receipt.user = request.user receipt.save() items.instance = receipt line_items = items.save() # Backend Calculation for Consistency sum_amount = sum(item.amount for item in line_items) vat_type = receipt.vat_type if vat_type == 'INCLUDED': receipt.total_amount = sum_amount receipt.subtotal = sum_amount / Decimal('1.15') receipt.vat_amount = receipt.total_amount - receipt.subtotal elif vat_type == 'EXCLUDED': receipt.subtotal = sum_amount receipt.vat_amount = sum_amount * Decimal('0.15') receipt.total_amount = receipt.subtotal + receipt.vat_amount else: # NONE receipt.subtotal = sum_amount receipt.vat_amount = Decimal('0.00') receipt.total_amount = sum_amount receipt.save() # Email Generation subject = f"Receipt from {receipt.vendor} - {receipt.date}" recipient_list = [settings.SPARK_RECEIPT_EMAIL] # Prepare Context context = { 'receipt': receipt, 'items': line_items, } # 1. Render HTML Body html_message = render_to_string('core/email/receipt_email.html', context) plain_message = strip_tags(html_message) # 2. Render PDF Attachment pdf_content = render_to_pdf('core/pdf/receipt_pdf.html', context) try: # Construct Email with Attachment email = EmailMultiAlternatives( subject, plain_message, settings.DEFAULT_FROM_EMAIL, recipient_list, ) email.attach_alternative(html_message, "text/html") if pdf_content: email.attach(f"Receipt_{receipt.id}.pdf", pdf_content, 'application/pdf') email.send() messages.success(request, "Receipt created and sent to SparkReceipt.") return redirect('create_receipt') except Exception as e: messages.warning(request, f"Receipt saved, but email failed: {e}") else: form = ExpenseReceiptForm(initial={'date': timezone.now().date()}) items = ExpenseLineItemFormSet() return render(request, 'core/create_receipt.html', { 'form': form, 'items': items })