# === 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 datetime from decimal import Decimal from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone from django.db.models import Sum, Count, Q, Prefetch 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 # === 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)