diff --git a/core/forms.py b/core/forms.py index 8e31894..6fb8e06 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,22 +1,102 @@ +# === 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. + from django import forms from .models import WorkLog, Project, Team, Worker + class AttendanceLogForm(forms.ModelForm): + """ + Form for logging daily worker attendance. + + Extra fields (not on the WorkLog model): + - end_date: optional end date for logging multiple days at once + - include_saturday: whether to include Saturdays in a date range + - include_sunday: whether to include Sundays in a date range + + The supervisor field is NOT shown on the form — it gets set automatically + in the view to whoever is logged in. + """ + + # --- Extra fields for date range logging --- + # These aren't on the WorkLog model, they're only used by the form + end_date = forms.DateField( + required=False, + widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + label='End Date', + help_text='Leave blank to log a single day' + ) + include_saturday = forms.BooleanField( + required=False, + initial=False, + label='Include Saturdays', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + include_sunday = forms.BooleanField( + required=False, + initial=False, + label='Include Sundays', + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + ) + class Meta: model = WorkLog - fields = ['date', 'project', 'team', 'workers', 'supervisor', 'overtime_amount', 'notes'] + # Supervisor is NOT included — it gets set in the view automatically + fields = ['date', 'project', 'team', 'workers', 'overtime_amount', 'notes'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'project': forms.Select(attrs={'class': 'form-select'}), 'team': forms.Select(attrs={'class': 'form-select'}), 'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}), - 'supervisor': forms.Select(attrs={'class': 'form-select'}), 'overtime_amount': forms.Select(attrs={'class': 'form-select'}), - 'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Any notes about the day...' + }), } def __init__(self, *args, **kwargs): + # Pop 'user' from kwargs so we can filter based on who's logged in + self.user = kwargs.pop('user', None) super().__init__(*args, **kwargs) - self.fields['workers'].queryset = Worker.objects.filter(active=True) - self.fields['project'].queryset = Project.objects.filter(active=True) - self.fields['team'].queryset = Team.objects.filter(active=True) + + # --- Supervisor filtering --- + # If the user is NOT an admin, they can only see: + # - Projects they're assigned to (via project.supervisors M2M) + # - Workers in teams they supervise + if self.user and not (self.user.is_staff or self.user.is_superuser): + # Only show projects this supervisor is assigned to + self.fields['project'].queryset = Project.objects.filter( + active=True, + supervisors=self.user + ) + # Only show workers from teams this supervisor manages + supervised_teams = Team.objects.filter(supervisor=self.user, active=True) + self.fields['workers'].queryset = Worker.objects.filter( + active=True, + teams__in=supervised_teams + ).distinct() + # Only show teams this supervisor manages + self.fields['team'].queryset = supervised_teams + else: + # Admins see everything + self.fields['workers'].queryset = Worker.objects.filter(active=True) + self.fields['project'].queryset = Project.objects.filter(active=True) + self.fields['team'].queryset = Team.objects.filter(active=True) + + # Make team optional (it already is on the model, but make the form match) + self.fields['team'].required = False + + def clean(self): + """Validate the date range makes sense.""" + cleaned_data = super().clean() + start_date = cleaned_data.get('date') + end_date = cleaned_data.get('end_date') + + if start_date and end_date and end_date < start_date: + raise forms.ValidationError('End date cannot be before start date.') + + return cleaned_data diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/setup_groups.py b/core/management/commands/setup_groups.py new file mode 100644 index 0000000..22c5e2c --- /dev/null +++ b/core/management/commands/setup_groups.py @@ -0,0 +1,74 @@ +# === SETUP GROUPS MANAGEMENT COMMAND === +# Creates two permission groups: "Admin" and "Work Logger". +# Run this once after deploying: python manage.py setup_groups +# +# "Admin" group gets full access to all core models. +# "Work Logger" group can add/change/view WorkLogs, and view-only +# access to Projects, Workers, and Teams. + +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from core.models import ( + Project, Worker, Team, WorkLog, PayrollRecord, + Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem +) + + +class Command(BaseCommand): + help = 'Creates the Admin and Work Logger permission groups' + + def handle(self, *args, **options): + # --- Create the "Admin" group --- + # Admins get every permission on every core model + admin_group, created = Group.objects.get_or_create(name='Admin') + if created: + self.stdout.write(self.style.SUCCESS('Created "Admin" group')) + else: + self.stdout.write('Admin group already exists — updating permissions') + + # Get all permissions for our core models + core_models = [ + Project, Worker, Team, WorkLog, PayrollRecord, + Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem + ] + all_permissions = Permission.objects.filter( + content_type__in=[ + ContentType.objects.get_for_model(model) + for model in core_models + ] + ) + admin_group.permissions.set(all_permissions) + self.stdout.write(f' Assigned {all_permissions.count()} permissions to Admin group') + + # --- Create the "Work Logger" group --- + # Work Loggers can add/change/view WorkLogs, and view-only for + # Projects, Workers, and Teams + logger_group, created = Group.objects.get_or_create(name='Work Logger') + if created: + self.stdout.write(self.style.SUCCESS('Created "Work Logger" group')) + else: + self.stdout.write('Work Logger group already exists — updating permissions') + + logger_permissions = Permission.objects.filter( + # WorkLog: add, change, view (but not delete) + content_type=ContentType.objects.get_for_model(WorkLog), + codename__in=['add_worklog', 'change_worklog', 'view_worklog'] + ) | Permission.objects.filter( + # Projects: view only + content_type=ContentType.objects.get_for_model(Project), + codename='view_project' + ) | Permission.objects.filter( + # Workers: view only + content_type=ContentType.objects.get_for_model(Worker), + codename='view_worker' + ) | Permission.objects.filter( + # Teams: view only + content_type=ContentType.objects.get_for_model(Team), + codename='view_team' + ) + + logger_group.permissions.set(logger_permissions) + self.stdout.write(f' Assigned {logger_permissions.count()} permissions to Work Logger group') + + self.stdout.write(self.style.SUCCESS('Done! Permission groups are ready.')) diff --git a/core/templates/base.html b/core/templates/base.html index 8e885dd..071c02a 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -16,15 +16,14 @@ @@ -52,7 +51,7 @@ diff --git a/core/templates/core/attendance_log.html b/core/templates/core/attendance_log.html index feaeab7..e8a18a1 100644 --- a/core/templates/core/attendance_log.html +++ b/core/templates/core/attendance_log.html @@ -1,46 +1,144 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Log Work | FoxFitt{% endblock %} +{% block title %}Log Work | Fox Fitt{% endblock %} {% block content %}
-

Log Daily Attendance

+

Log Daily Attendance

Back
-
-
+
+ +
-
+
+ + {# --- Conflict Warning --- #} + {# If we found workers already logged on selected dates, show this warning #} + {% if conflicts %} + + {% endif %} + + {# --- Form Errors --- #} + {% if form.errors %} +
+ Please fix the following: +
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} +
{% csrf_token %} -
- - {{ form.date }} -
+ {# --- Date Range Section --- #}
- + + {{ form.date }} +
+
+ + {{ form.end_date }} + Leave blank to log a single day +
+
+ + {# --- Weekend Checkboxes --- #} +
+
+ {{ form.include_saturday }} + +
+
+ {{ form.include_sunday }} + +
+
+ + {# --- Project and Team --- #} +
+
+ {{ form.project }}
- - {{ form.supervisor }} + + {{ form.team }}
+ {# --- Worker Checkboxes --- #}
- - {{ form.team }} -
- -
- +
{% for worker in form.workers %} @@ -57,25 +155,154 @@
+ {# --- Overtime --- #}
- + {{ form.overtime_amount }}
+ {# --- Notes --- #}
- + {{ form.notes }}
+ {# --- Submit Button --- #}
- +
+ + {# --- Estimated Cost Card (Admin Only) --- #} + {% if is_admin %} +
+
+
+
+ Estimated Cost +
+
+
+ R 0.00 +
+ + 0 worker(s) × + 1 day(s) + +
+
+ + This estimate is based on each worker's daily rate multiplied by the + number of working days selected. Overtime is not included. + +
+
+
+ {% endif %}
-{% endblock %} \ No newline at end of file + +{# --- JavaScript for dynamic features --- #} + +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 42de78f..2eb29c6 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -238,22 +238,75 @@
{% else %} -
-
+ +
+
+
+
+
+
+
+ My Projects
+
{{ my_projects_count }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ My Teams
+
{{ my_teams_count }}
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ My Workers
+
{{ my_workers_count }}
+
+
+ +
+
+
+
+
+
+ + +
+
-
This Week Summary
+
This Week Summary
-
{{ this_week_logs }}
+
{{ this_week_logs }}
Work Logs Created This Week
-
+
-
Recent Activity
+
Recent Activity
@@ -268,6 +321,7 @@
{% empty %}
+ No recent activity.
{% endfor %} diff --git a/core/templates/core/work_history.html b/core/templates/core/work_history.html index 81a5c6f..4388ffb 100644 --- a/core/templates/core/work_history.html +++ b/core/templates/core/work_history.html @@ -1,17 +1,78 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Work History | FoxFitt{% endblock %} +{% block title %}Work History | Fox Fitt{% endblock %} {% block content %}
-

Work History

- - Back - +

Work History

+
+ {# CSV Export button — keeps the current filters in the export URL #} + + Export CSV + + + Back + +
+ {# --- Filter Bar --- #} +
+
+
+ {# Filter by Worker #} +
+ + +
+ + {# Filter by Project #} +
+ + +
+ + {# Filter by Payment Status #} +
+ + +
+ + {# Filter Button #} +
+ + + Clear + +
+
+
+
+ + {# --- Work Log Table --- #}
@@ -20,11 +81,10 @@ Date Project - Team Workers - Supervisor Overtime - Notes + Status + Supervisor @@ -32,11 +92,13 @@ {{ log.date }} {{ log.project.name }} - {{ log.team.name|default:"-" }} - {{ log.workers.count }} + {# Show worker names as comma-separated list #} + {% for w in log.workers.all %} + {{ w.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {{ log.workers.count }} - {{ log.supervisor.username|default:"-" }} {% if log.overtime_amount > 0 %} {{ log.get_overtime_amount_display }} @@ -44,13 +106,31 @@ - {% endif %} - - {{ log.notes|truncatechars:30 }} + + {# Payment status — a WorkLog is "paid" if it has at least one PayrollRecord #} + {% if log.payroll_records.exists %} + Paid + {% else %} + Unpaid + {% endif %} + + + {% if log.supervisor %} + {{ log.supervisor.get_full_name|default:log.supervisor.username }} + {% else %} + - + {% endif %} {% empty %} - No work history found. + + + No work history found. + {% if selected_worker or selected_project or selected_status %} +
Try adjusting your filters. + {% endif %} + {% endfor %} diff --git a/core/urls.py b/core/urls.py index 911b624..a5e2f48 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,9 +1,23 @@ +# === URL ROUTING === +# Maps URLs to view functions. Each path() connects a web address to +# the Python function that handles it. + from django.urls import path from . import views urlpatterns = [ + # Dashboard — the home page after login path('', views.index, name='home'), + + # Attendance logging — where supervisors log daily work path('attendance/log/', views.attendance_log, name='attendance_log'), + + # Work history — table of all work logs with filters path('history/', views.work_history, name='work_history'), + + # CSV export — downloads filtered work logs as a spreadsheet + path('history/export/', views.export_work_log_csv, name='export_work_log_csv'), + + # AJAX toggle — activates/deactivates workers, projects, teams from dashboard path('toggle///', views.toggle_active, name='toggle_active'), ] diff --git a/core/views.py b/core/views.py index 9469e11..9ef58f4 100644 --- a/core/views.py +++ b/core/views.py @@ -1,33 +1,66 @@ +# === 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 -from decimal import Decimal -from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment -from .forms import AttendanceLogForm +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 +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): - return user.supervised_teams.exists() or user.assigned_projects.exists() or user.groups.filter(name='Work Logger').exists() + """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 view for the dashboard + +# === 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): - # Calculate total value of unpaid work and break it down by project - unpaid_worklogs = WorkLog.objects.filter(payroll_records__isnull=True).prefetch_related('workers', 'project') + # --- 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: @@ -36,9 +69,12 @@ def index(request): cost = worker.daily_rate outstanding_payments += cost outstanding_by_project[project_name] += cost - - # Include unpaid payroll adjustments in the outstanding calculations - unpaid_adjustments = PayrollAdjustment.objects.filter(payroll_record__isnull=True) + + # 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' @@ -46,25 +82,35 @@ def index(request): outstanding_by_project[project_name] = Decimal('0.00') outstanding_by_project[project_name] += adj.amount - # Sum the total amount paid out over the last 60 days + # 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') + paid_this_month = PayrollRecord.objects.filter( + date__gte=sixty_days_ago + ).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00') - # Tally the count and total balance of active loans + # 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') + active_loans_balance = active_loans_qs.aggregate( + total=Sum('remaining_balance') + )['total'] or Decimal('0.00') - start_of_week = timezone.now().date() - timezone.timedelta(days=timezone.now().date().weekday()) + # 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 = WorkLog.objects.all().order_by('-date', '-id')[:5] - # Get all workers, projects, and teams for the Manage Resources tab + # 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, @@ -79,64 +125,350 @@ def index(request): 'teams': teams, } return render(request, 'core/index.html', context) + else: - 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() - recent_activity = WorkLog.objects.filter(supervisor=user).order_by('-date', '-id')[:5] - + # --- 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) -# View for logging attendance + +# === 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) + form = AttendanceLogForm(request.POST, user=user) + if form.is_valid(): - form.save() - messages.success(request, 'Attendance logged successfully!') + 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(initial={'date': timezone.now().date(), 'supervisor': request.user}) - - return render(request, 'core/attendance_log.html', {'form': form}) + 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. -# Work history view @login_required def work_history(request): - if is_admin(request.user): - logs = WorkLog.objects.all().order_by('-date', '-id') - else: - logs = WorkLog.objects.filter(supervisor=request.user).order_by('-date', '-id') - return render(request, 'core/work_history.html', {'logs': logs}) + 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. -# API view to toggle resource active status @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}) + 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) \ No newline at end of file + return JsonResponse({'error': 'Item not found'}, status=404)