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 django import forms
|
||||||
from .models import WorkLog, Project, Team, Worker
|
from .models import WorkLog, Project, Team, Worker
|
||||||
|
|
||||||
|
|
||||||
class AttendanceLogForm(forms.ModelForm):
|
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:
|
class Meta:
|
||||||
model = WorkLog
|
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 = {
|
widgets = {
|
||||||
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||||
'project': forms.Select(attrs={'class': 'form-select'}),
|
'project': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'team': forms.Select(attrs={'class': 'form-select'}),
|
'team': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
|
'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
|
||||||
'supervisor': forms.Select(attrs={'class': 'form-select'}),
|
|
||||||
'overtime_amount': 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):
|
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)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['workers'].queryset = Worker.objects.filter(active=True)
|
|
||||||
self.fields['project'].queryset = Project.objects.filter(active=True)
|
# --- Supervisor filtering ---
|
||||||
self.fields['team'].queryset = Team.objects.filter(active=True)
|
# 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 -->
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ request.timestamp|default:'1.0' }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ request.timestamp|default:'1.0' }}">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; display: flex; flex-direction: column; min-height: 100vh; }
|
/* Layout helpers — keep body full-height so footer sticks to bottom */
|
||||||
h1, h2, h3, h4, h5, h6, .navbar-brand { font-family: 'Poppins', sans-serif; }
|
body { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
.navbar { background-color: #0f172a !important; }
|
main { flex-grow: 1; }
|
||||||
|
/* Branding — Fox in green, Fitt in white */
|
||||||
.navbar-brand-fox { color: #10b981; font-weight: 700; }
|
.navbar-brand-fox { color: #10b981; font-weight: 700; }
|
||||||
.navbar-brand-fitt { color: #ffffff; font-weight: 700; }
|
.navbar-brand-fitt { color: #ffffff; font-weight: 700; }
|
||||||
.nav-link { font-weight: 500; }
|
.nav-link { font-weight: 500; }
|
||||||
.dropdown-menu { border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -52,7 +51,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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
|
<i class="fas fa-clock me-1"></i> History
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,46 +1,144 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Log Work | FoxFitt{% endblock %}
|
{% block title %}Log Work | Fox Fitt{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-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">
|
<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
|
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<!-- 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 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">
|
<form method="POST" id="attendanceForm">
|
||||||
{% csrf_token %}
|
{% 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="row g-3 mb-4">
|
||||||
<div class="col-md-6">
|
<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 }}
|
{{ form.project }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" style="font-weight: 600;">{{ form.supervisor.label }}</label>
|
<label class="form-label fw-semibold">
|
||||||
{{ form.supervisor }}
|
Team <span class="text-muted fw-normal">(optional — selects all team workers)</span>
|
||||||
|
</label>
|
||||||
|
{{ form.team }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Worker Checkboxes --- #}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label" style="font-weight: 600;">{{ form.team.label }} <span class="text-muted fw-normal">(Optional)</span></label>
|
<label class="form-label d-block mb-3 fw-semibold">Workers Present</label>
|
||||||
{{ form.team }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label d-block mb-3" style="font-weight: 600;">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="worker-selection border rounded p-3" style="max-height: 300px; overflow-y: auto; background-color: #f8fafc; border-color: #e2e8f0 !important;">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for worker in form.workers %}
|
{% for worker in form.workers %}
|
||||||
@ -57,25 +155,154 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Overtime --- #}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-md-6">
|
<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 }}
|
{{ form.overtime_amount }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Notes --- #}
|
||||||
<div class="mb-4">
|
<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 }}
|
{{ form.notes }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Submit Button --- #}
|
||||||
<div class="d-grid mt-5">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- 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 %}
|
{% endblock %}
|
||||||
@ -238,22 +238,75 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Supervisor View -->
|
<!-- Supervisor View -->
|
||||||
<div class="row mb-4 position-relative">
|
<!-- Stat Cards — how many projects, teams, and workers this supervisor manages -->
|
||||||
<div class="col-md-6 mb-4">
|
<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 shadow-sm border-0 h-100">
|
||||||
<div class="card-header py-3 bg-white">
|
<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>
|
||||||
<div class="card-body text-center d-flex flex-column justify-content-center">
|
<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 class="text-muted">Work Logs Created This Week</div>
|
||||||
</div>
|
</div>
|
||||||
</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 shadow-sm border-0 h-100">
|
||||||
<div class="card-header py-3 bg-white">
|
<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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
@ -268,6 +321,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="p-4 text-center text-muted">
|
<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.
|
No recent activity.
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -1,17 +1,78 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Work History | FoxFitt{% endblock %}
|
{% block title %}Work History | Fox Fitt{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-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>
|
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Work History</h1>
|
||||||
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
|
<div class="d-flex gap-2">
|
||||||
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
|
{# CSV Export button — keeps the current filters in the export URL #}
|
||||||
</a>
|
<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>
|
</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 shadow-sm border-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -20,11 +81,10 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="ps-4">Date</th>
|
<th scope="col" class="ps-4">Date</th>
|
||||||
<th scope="col">Project</th>
|
<th scope="col">Project</th>
|
||||||
<th scope="col">Team</th>
|
|
||||||
<th scope="col">Workers</th>
|
<th scope="col">Workers</th>
|
||||||
<th scope="col">Supervisor</th>
|
|
||||||
<th scope="col">Overtime</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -32,11 +92,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4 align-middle">{{ log.date }}</td>
|
<td class="ps-4 align-middle">{{ log.date }}</td>
|
||||||
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
|
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
|
||||||
<td class="align-middle">{{ log.team.name|default:"-" }}</td>
|
|
||||||
<td class="align-middle">
|
<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>
|
||||||
<td class="align-middle">{{ log.supervisor.username|default:"-" }}</td>
|
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if log.overtime_amount > 0 %}
|
{% if log.overtime_amount > 0 %}
|
||||||
<span class="badge bg-warning text-dark">{{ log.get_overtime_amount_display }}</span>
|
<span class="badge bg-warning text-dark">{{ log.get_overtime_amount_display }}</span>
|
||||||
@ -44,13 +106,31 @@
|
|||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="pe-4 align-middle text-muted small">
|
<td class="align-middle">
|
||||||
{{ log.notes|truncatechars:30 }}
|
{# 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</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 django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Dashboard — the home page after login
|
||||||
path('', views.index, name='home'),
|
path('', views.index, name='home'),
|
||||||
|
|
||||||
|
# Attendance logging — where supervisors log daily work
|
||||||
path('attendance/log/', views.attendance_log, name='attendance_log'),
|
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'),
|
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'),
|
path('toggle/<str:model_name>/<int:item_id>/', views.toggle_active, name='toggle_active'),
|
||||||
]
|
]
|
||||||
|
|||||||
402
core/views.py
402
core/views.py
@ -1,30 +1,63 @@
|
|||||||
|
# === 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.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum, Count, Q, Prefetch
|
||||||
from decimal import Decimal
|
|
||||||
from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment
|
|
||||||
from .forms import AttendanceLogForm
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
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):
|
def is_admin(user):
|
||||||
|
"""Returns True if the user is staff or superuser (the boss)."""
|
||||||
return user.is_staff or user.is_superuser
|
return user.is_staff or user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
def is_supervisor(user):
|
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):
|
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)
|
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
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
if is_admin(user):
|
if is_admin(user):
|
||||||
# Calculate total value of unpaid work and break it down by project
|
# --- ADMIN DASHBOARD ---
|
||||||
unpaid_worklogs = WorkLog.objects.filter(payroll_records__isnull=True).prefetch_related('workers', 'project')
|
|
||||||
|
# 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_payments = Decimal('0.00')
|
||||||
outstanding_by_project = {}
|
outstanding_by_project = {}
|
||||||
|
|
||||||
@ -37,8 +70,11 @@ def index(request):
|
|||||||
outstanding_payments += cost
|
outstanding_payments += cost
|
||||||
outstanding_by_project[project_name] += cost
|
outstanding_by_project[project_name] += cost
|
||||||
|
|
||||||
# Include unpaid payroll adjustments in the outstanding calculations
|
# Also include unpaid payroll adjustments (bonuses, deductions, etc.)
|
||||||
unpaid_adjustments = PayrollAdjustment.objects.filter(payroll_record__isnull=True)
|
unpaid_adjustments = PayrollAdjustment.objects.filter(
|
||||||
|
payroll_record__isnull=True
|
||||||
|
).select_related('project')
|
||||||
|
|
||||||
for adj in unpaid_adjustments:
|
for adj in unpaid_adjustments:
|
||||||
outstanding_payments += adj.amount
|
outstanding_payments += adj.amount
|
||||||
project_name = adj.project.name if adj.project else 'General'
|
project_name = adj.project.name if adj.project else 'General'
|
||||||
@ -46,21 +82,31 @@ def index(request):
|
|||||||
outstanding_by_project[project_name] = Decimal('0.00')
|
outstanding_by_project[project_name] = Decimal('0.00')
|
||||||
outstanding_by_project[project_name] += adj.amount
|
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)
|
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_qs = Loan.objects.filter(active=True)
|
||||||
active_loans_count = active_loans_qs.count()
|
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()
|
this_week_logs = WorkLog.objects.filter(date__gte=start_of_week).count()
|
||||||
|
|
||||||
recent_activity = WorkLog.objects.all().order_by('-date', '-id')[:5]
|
# Recent activity — last 5 work logs
|
||||||
|
recent_activity = WorkLog.objects.select_related(
|
||||||
|
'project', 'supervisor'
|
||||||
|
).prefetch_related('workers').order_by('-date', '-id')[:5]
|
||||||
|
|
||||||
# Get all workers, projects, and teams for the Manage Resources tab
|
# All workers, projects, and teams for the Manage Resources tab
|
||||||
workers = Worker.objects.all().order_by('name')
|
workers = Worker.objects.all().order_by('name')
|
||||||
projects = Project.objects.all().order_by('name')
|
projects = Project.objects.all().order_by('name')
|
||||||
teams = Team.objects.all().order_by('name')
|
teams = Team.objects.all().order_by('name')
|
||||||
@ -79,42 +125,323 @@ def index(request):
|
|||||||
'teams': teams,
|
'teams': teams,
|
||||||
}
|
}
|
||||||
return render(request, 'core/index.html', context)
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
start_of_week = timezone.now().date() - timezone.timedelta(days=timezone.now().date().weekday())
|
# --- SUPERVISOR DASHBOARD ---
|
||||||
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]
|
# 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 = {
|
context = {
|
||||||
'is_admin': False,
|
'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,
|
'this_week_logs': this_week_logs,
|
||||||
'recent_activity': recent_activity,
|
'recent_activity': recent_activity,
|
||||||
}
|
}
|
||||||
return render(request, 'core/index.html', context)
|
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
|
@login_required
|
||||||
def attendance_log(request):
|
def attendance_log(request):
|
||||||
|
user = request.user
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = AttendanceLogForm(request.POST)
|
form = AttendanceLogForm(request.POST, user=user)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
start_date = form.cleaned_data['date']
|
||||||
messages.success(request, 'Attendance logged successfully!')
|
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')
|
return redirect('home')
|
||||||
else:
|
else:
|
||||||
form = AttendanceLogForm(initial={'date': timezone.now().date(), 'supervisor': request.user})
|
form = AttendanceLogForm(
|
||||||
|
user=user,
|
||||||
|
initial={'date': timezone.now().date()}
|
||||||
|
)
|
||||||
|
|
||||||
return render(request, 'core/attendance_log.html', {'form': form})
|
# 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
|
@login_required
|
||||||
def work_history(request):
|
def work_history(request):
|
||||||
if is_admin(request.user):
|
user = request.user
|
||||||
logs = WorkLog.objects.all().order_by('-date', '-id')
|
|
||||||
else:
|
# Start with base queryset
|
||||||
logs = WorkLog.objects.filter(supervisor=request.user).order_by('-date', '-id')
|
if is_admin(user):
|
||||||
return render(request, 'core/work_history.html', {'logs': logs})
|
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
|
@login_required
|
||||||
def toggle_active(request, model_name, item_id):
|
def toggle_active(request, model_name, item_id):
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
@ -123,6 +450,7 @@ def toggle_active(request, model_name, item_id):
|
|||||||
if not is_admin(request.user):
|
if not is_admin(request.user):
|
||||||
return HttpResponseForbidden("Not authorized.")
|
return HttpResponseForbidden("Not authorized.")
|
||||||
|
|
||||||
|
# Map URL parameter to the correct model class
|
||||||
model_map = {
|
model_map = {
|
||||||
'worker': Worker,
|
'worker': Worker,
|
||||||
'project': Project,
|
'project': Project,
|
||||||
@ -137,6 +465,10 @@ def toggle_active(request, model_name, item_id):
|
|||||||
item = model.objects.get(id=item_id)
|
item = model.objects.get(id=item_id)
|
||||||
item.active = not item.active
|
item.active = not item.active
|
||||||
item.save()
|
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:
|
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