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:
parent
b1f415b72b
commit
77236dd78f
@ -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
|
||||
|
||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
74
core/management/commands/setup_groups.py
Normal file
74
core/management/commands/setup_groups.py
Normal 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.'))
|
||||
@ -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>
|
||||
|
||||
@ -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) ×
|
||||
<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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
14
core/urls.py
14
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/<str:model_name>/<int:item_id>/', views.toggle_active, name='toggle_active'),
|
||||
]
|
||||
|
||||
426
core/views.py
426
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)
|
||||
return JsonResponse({'error': 'Item not found'}, status=404)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user