@@ -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)