- Attendance form: date range (start+end), Sat/Sun checkboxes, conflict detection with Skip/Overwrite, supervisor auto-set, estimated cost card - Work history: filter by worker/project/payment status, CSV export, payment status badges (Paid/Unpaid) - Supervisor dashboard: stat cards for projects, teams, workers count - Forms: supervisor filtering (non-admins only see their projects/workers) - Navbar: History link now works, cleaned up inline styles in base.html - Management command: setup_groups creates Admin + Work Logger groups - No model/migration changes — database is untouched Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
475 lines
18 KiB
Python
475 lines
18 KiB
Python
# === 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)
|