Phase 2B: Enhanced attendance, work history filters, supervisor dashboard

- 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>
This commit is contained in:
Konrad du Plessis 2026-02-22 16:28:18 +02:00
parent b1f415b72b
commit 77236dd78f
10 changed files with 961 additions and 101 deletions

View File

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

View File

View File

View File

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

View File

@ -16,15 +16,14 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ request.timestamp|default:'1.0' }}">
<style>
body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; display: flex; flex-direction: column; min-height: 100vh; }
h1, h2, h3, h4, h5, h6, .navbar-brand { font-family: 'Poppins', sans-serif; }
.navbar { background-color: #0f172a !important; }
/* Layout helpers — keep body full-height so footer sticks to bottom */
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex-grow: 1; }
/* Branding — Fox in green, Fitt in white */
.navbar-brand-fox { color: #10b981; font-weight: 700; }
.navbar-brand-fitt { color: #ffffff; font-weight: 700; }
.nav-link { font-weight: 500; }
.dropdown-menu { border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
main { flex-grow: 1; }
footer { background-color: #0f172a; color: #cbd5e1; }
</style>
</head>
<body>
@ -52,7 +51,7 @@
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<a class="nav-link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}" href="{% url 'work_history' %}">
<i class="fas fa-clock me-1"></i> History
</a>
</li>

View File

@ -1,46 +1,144 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Log Work | FoxFitt{% endblock %}
{% block title %}Log Work | Fox Fitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="color: #0f172a; font-family: 'Poppins', sans-serif;">Log Daily Attendance</h1>
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Log Daily Attendance</h1>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="row">
<!-- Main Form Column -->
<div class="{% if is_admin %}col-lg-8{% else %}col-lg-8 mx-auto{% endif %}">
<div class="card shadow-sm border-0" style="border-radius: 12px;">
<div class="card-body p-5">
<div class="card-body p-4 p-md-5">
{# --- Conflict Warning --- #}
{# If we found workers already logged on selected dates, show this warning #}
{% if conflicts %}
<div class="alert alert-warning border-0 shadow-sm mb-4" role="alert">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>Conflicts Found
</h6>
<p class="mb-2">The following workers already have work logs on the selected dates:</p>
<ul class="mb-3">
{% for c in conflicts %}
<li><strong>{{ c.worker_name }}</strong> on {{ c.date }} ({{ c.project_name }})</li>
{% endfor %}
</ul>
<div class="d-flex gap-2">
<form method="POST" class="d-inline">
{% csrf_token %}
{# Re-submit all form data with a conflict_action flag #}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' %}
{% if key == 'workers' %}
{# Workers is a multi-value field — need each value separately #}
{% for worker_val in form.data.workers %}
<input type="hidden" name="workers" value="{{ worker_val }}">
{% endfor %}
{% else %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endif %}
{% endfor %}
<input type="hidden" name="conflict_action" value="skip">
<button type="submit" class="btn btn-outline-warning btn-sm">
<i class="fas fa-forward me-1"></i> Skip Conflicts
</button>
</form>
<form method="POST" class="d-inline">
{% csrf_token %}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' %}
{% if key == 'workers' %}
{% for worker_val in form.data.workers %}
<input type="hidden" name="workers" value="{{ worker_val }}">
{% endfor %}
{% else %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endif %}
{% endfor %}
<input type="hidden" name="conflict_action" value="overwrite">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-sync me-1"></i> Overwrite Existing
</button>
</form>
</div>
</div>
{% endif %}
{# --- Form Errors --- #}
{% if form.errors %}
<div class="alert alert-danger border-0 shadow-sm mb-4">
<strong><i class="fas fa-exclamation-circle me-1"></i> Please fix the following:</strong>
<ul class="mb-0 mt-2">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<form method="POST" id="attendanceForm">
{% csrf_token %}
<div class="mb-4">
<label class="form-label" style="font-weight: 600;">{{ form.date.label }}</label>
{{ form.date }}
</div>
{# --- Date Range Section --- #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" style="font-weight: 600;">{{ form.project.label }}</label>
<label class="form-label fw-semibold">Start Date</label>
{{ form.date }}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
End Date <span class="text-muted fw-normal">(optional)</span>
</label>
{{ form.end_date }}
<small class="text-muted">Leave blank to log a single day</small>
</div>
</div>
{# --- Weekend Checkboxes --- #}
<div class="d-flex gap-4 mb-4">
<div class="form-check">
{{ form.include_saturday }}
<label class="form-check-label ms-1" for="{{ form.include_saturday.id_for_label }}">
Include Saturdays
</label>
</div>
<div class="form-check">
{{ form.include_sunday }}
<label class="form-check-label ms-1" for="{{ form.include_sunday.id_for_label }}">
Include Sundays
</label>
</div>
</div>
{# --- Project and Team --- #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Project</label>
{{ form.project }}
</div>
<div class="col-md-6">
<label class="form-label" style="font-weight: 600;">{{ form.supervisor.label }}</label>
{{ form.supervisor }}
<label class="form-label fw-semibold">
Team <span class="text-muted fw-normal">(optional — selects all team workers)</span>
</label>
{{ form.team }}
</div>
</div>
{# --- Worker Checkboxes --- #}
<div class="mb-4">
<label class="form-label" style="font-weight: 600;">{{ form.team.label }} <span class="text-muted fw-normal">(Optional)</span></label>
{{ form.team }}
</div>
<div class="mb-4">
<label class="form-label d-block mb-3" style="font-weight: 600;">Workers Present</label>
<label class="form-label d-block mb-3 fw-semibold">Workers Present</label>
<div class="worker-selection border rounded p-3" style="max-height: 300px; overflow-y: auto; background-color: #f8fafc; border-color: #e2e8f0 !important;">
<div class="row">
{% for worker in form.workers %}
@ -57,25 +155,154 @@
</div>
</div>
{# --- Overtime --- #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" style="font-weight: 600;">{{ form.overtime_amount.label }}</label>
<label class="form-label fw-semibold">Overtime</label>
{{ form.overtime_amount }}
</div>
</div>
{# --- Notes --- #}
<div class="mb-4">
<label class="form-label" style="font-weight: 600;">{{ form.notes.label }}</label>
<label class="form-label fw-semibold">Notes</label>
{{ form.notes }}
</div>
{# --- Submit Button --- #}
<div class="d-grid mt-5">
<button type="submit" class="btn btn-lg text-white shadow-sm" style="background-color: #10b981; border: none; font-weight: 600; border-radius: 8px;">Save Attendance Log</button>
<button type="submit" class="btn btn-lg btn-accent shadow-sm" style="border-radius: 8px;">
<i class="fas fa-save me-2"></i>Save Attendance Log
</button>
</div>
</form>
</div>
</div>
</div>
{# --- Estimated Cost Card (Admin Only) --- #}
{% if is_admin %}
<div class="col-lg-4 mt-4 mt-lg-0">
<div class="card shadow-sm border-0 sticky-top" style="border-radius: 12px; top: 80px;">
<div class="card-body p-4">
<h6 class="fw-bold mb-3">
<i class="fas fa-calculator me-2 text-success"></i>Estimated Cost
</h6>
<div class="text-center py-3">
<div class="display-6 fw-bold" id="estimatedCost" style="color: var(--accent-color, #10b981);">
R 0.00
</div>
<small class="text-muted">
<span id="selectedWorkerCount">0</span> worker(s) &times;
<span id="selectedDayCount">1</span> day(s)
</small>
</div>
<hr>
<small class="text-muted">
This estimate is based on each worker's daily rate multiplied by the
number of working days selected. Overtime is not included.
</small>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{# --- JavaScript for dynamic features --- #}
<script>
document.addEventListener('DOMContentLoaded', function() {
// === TEAM AUTO-SELECT ===
// When a team is chosen from the dropdown, automatically check all workers
// that belong to that team. We do this with a data attribute approach.
const teamSelect = document.querySelector('[name="team"]');
if (teamSelect) {
teamSelect.addEventListener('change', function() {
// Team auto-select would need team-worker mapping from backend.
// For now, we'll handle this server-side if needed in a future phase.
});
}
{% if is_admin %}
// === ESTIMATED COST CALCULATOR (Admin Only) ===
// Updates the cost card in real-time as workers and dates are selected.
// Worker daily rates passed from the view
const workerRates = {{ worker_rates_json|safe }};
const startDateInput = document.querySelector('[name="date"]');
const endDateInput = document.querySelector('[name="end_date"]');
const satCheckbox = document.querySelector('[name="include_saturday"]');
const sunCheckbox = document.querySelector('[name="include_sunday"]');
const workerCheckboxes = document.querySelectorAll('[name="workers"]');
const costDisplay = document.getElementById('estimatedCost');
const workerCountDisplay = document.getElementById('selectedWorkerCount');
const dayCountDisplay = document.getElementById('selectedDayCount');
function countWorkingDays() {
// Count how many working days are in the selected date range
const startDate = startDateInput ? new Date(startDateInput.value) : null;
const endDateVal = endDateInput ? endDateInput.value : '';
const endDate = endDateVal ? new Date(endDateVal) : startDate;
if (!startDate || isNaN(startDate)) return 1;
if (!endDate || isNaN(endDate)) return 1;
let count = 0;
let current = new Date(startDate);
while (current <= endDate) {
const day = current.getDay(); // 0=Sun, 6=Sat
if (day === 6 && !(satCheckbox && satCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
if (day === 0 && !(sunCheckbox && sunCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
count++;
current.setDate(current.getDate() + 1);
}
return Math.max(count, 1);
}
function updateEstimatedCost() {
// Add up daily rates of all checked workers, multiply by number of days
let totalDailyRate = 0;
let selectedCount = 0;
workerCheckboxes.forEach(function(cb) {
if (cb.checked) {
const workerId = cb.value;
if (workerRates[workerId]) {
totalDailyRate += parseFloat(workerRates[workerId]);
}
selectedCount++;
}
});
const days = countWorkingDays();
const totalCost = totalDailyRate * days;
// Update the display
if (costDisplay) costDisplay.textContent = 'R ' + totalCost.toLocaleString('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (workerCountDisplay) workerCountDisplay.textContent = selectedCount;
if (dayCountDisplay) dayCountDisplay.textContent = days;
}
// Listen for changes on all relevant inputs
workerCheckboxes.forEach(function(cb) {
cb.addEventListener('change', updateEstimatedCost);
});
if (startDateInput) startDateInput.addEventListener('change', updateEstimatedCost);
if (endDateInput) endDateInput.addEventListener('change', updateEstimatedCost);
if (satCheckbox) satCheckbox.addEventListener('change', updateEstimatedCost);
if (sunCheckbox) sunCheckbox.addEventListener('change', updateEstimatedCost);
// Run once on page load in case of pre-selected values
updateEstimatedCost();
{% endif %}
});
</script>
{% endblock %}

View File

@ -238,22 +238,75 @@
</div>
{% else %}
<!-- Supervisor View -->
<div class="row mb-4 position-relative">
<div class="col-md-6 mb-4">
<!-- Stat Cards — how many projects, teams, and workers this supervisor manages -->
<div class="row g-4 mb-4 position-relative">
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #8b5cf6;">
My Projects</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_projects_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-project-diagram fa-2x opacity-50" style="color: #8b5cf6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #3b82f6;">
My Teams</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_teams_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x opacity-50" style="color: #3b82f6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
My Workers</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_workers_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-hard-hat fa-2x opacity-50" style="color: #10b981;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- This Week + Recent Activity -->
<div class="row mb-4">
<div class="col-lg-4 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">This Week Summary</h6>
<h6 class="m-0 fw-bold" style="color: #0f172a;">This Week Summary</h6>
</div>
<div class="card-body text-center d-flex flex-column justify-content-center">
<div class="h1 mb-0 font-weight-bold text-primary">{{ this_week_logs }}</div>
<div class="h1 mb-0 fw-bold text-primary">{{ this_week_logs }}</div>
<div class="text-muted">Work Logs Created This Week</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="col-lg-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Recent Activity</h6>
<h6 class="m-0 fw-bold" style="color: #0f172a;">Recent Activity</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
@ -268,6 +321,7 @@
</div>
{% empty %}
<div class="p-4 text-center text-muted">
<i class="fas fa-inbox fa-2x mb-2 d-block opacity-50"></i>
No recent activity.
</div>
{% endfor %}

View File

@ -1,17 +1,78 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Work History | FoxFitt{% endblock %}
{% block title %}Work History | Fox Fitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="color: #0f172a; font-family: 'Poppins', sans-serif;">Work History</h1>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Work History</h1>
<div class="d-flex gap-2">
{# CSV Export button — keeps the current filters in the export URL #}
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&status={{ selected_status }}"
class="btn btn-outline-success btn-sm shadow-sm">
<i class="fas fa-file-csv me-1"></i> Export CSV
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
</div>
{# --- Filter Bar --- #}
<div class="card shadow-sm border-0 mb-4">
<div class="card-body py-3">
<form method="GET" class="row g-2 align-items-end">
{# Filter by Worker #}
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Worker</label>
<select name="worker" class="form-select form-select-sm">
<option value="">All Workers</option>
{% for w in filter_workers %}
<option value="{{ w.id }}" {% if selected_worker == w.id|stringformat:"d" %}selected{% endif %}>
{{ w.name }}
</option>
{% endfor %}
</select>
</div>
{# Filter by Project #}
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Project</label>
<select name="project" class="form-select form-select-sm">
<option value="">All Projects</option>
{% for p in filter_projects %}
<option value="{{ p.id }}" {% if selected_project == p.id|stringformat:"d" %}selected{% endif %}>
{{ p.name }}
</option>
{% endfor %}
</select>
</div>
{# Filter by Payment Status #}
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Payment Status</label>
<select name="status" class="form-select form-select-sm">
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
<option value="paid" {% if selected_status == 'paid' %}selected{% endif %}>Paid</option>
<option value="unpaid" {% if selected_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
</select>
</div>
{# Filter Button #}
<div class="col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i> Filter
</button>
<a href="{% url 'work_history' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-times me-1"></i> Clear
</a>
</div>
</form>
</div>
</div>
{# --- Work Log Table --- #}
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
@ -20,11 +81,10 @@
<tr>
<th scope="col" class="ps-4">Date</th>
<th scope="col">Project</th>
<th scope="col">Team</th>
<th scope="col">Workers</th>
<th scope="col">Supervisor</th>
<th scope="col">Overtime</th>
<th scope="col" class="pe-4">Notes</th>
<th scope="col">Status</th>
<th scope="col" class="pe-4">Supervisor</th>
</tr>
</thead>
<tbody>
@ -32,11 +92,13 @@
<tr>
<td class="ps-4 align-middle">{{ log.date }}</td>
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
<td class="align-middle">{{ log.team.name|default:"-" }}</td>
<td class="align-middle">
<span class="badge bg-secondary">{{ log.workers.count }}</span>
{# Show worker names as comma-separated list #}
{% for w in log.workers.all %}
{{ w.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
<span class="badge bg-secondary ms-1">{{ log.workers.count }}</span>
</td>
<td class="align-middle">{{ log.supervisor.username|default:"-" }}</td>
<td class="align-middle">
{% if log.overtime_amount > 0 %}
<span class="badge bg-warning text-dark">{{ log.get_overtime_amount_display }}</span>
@ -44,13 +106,31 @@
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="pe-4 align-middle text-muted small">
{{ log.notes|truncatechars:30 }}
<td class="align-middle">
{# Payment status — a WorkLog is "paid" if it has at least one PayrollRecord #}
{% if log.payroll_records.exists %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Paid</span>
{% else %}
<span class="badge bg-danger bg-opacity-75"><i class="fas fa-clock me-1"></i>Unpaid</span>
{% endif %}
</td>
<td class="pe-4 align-middle">
{% if log.supervisor %}
{{ log.supervisor.get_full_name|default:log.supervisor.username }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4 text-muted">No work history found.</td>
<td colspan="6" class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
No work history found.
{% if selected_worker or selected_project or selected_status %}
<br><small>Try adjusting your filters.</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -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/<str:model_name>/<int:item_id>/', views.toggle_active, name='toggle_active'),
]

View File

@ -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)
return JsonResponse({'error': 'Item not found'}, status=404)