Add Teams & Projects management pages

Extends the app with friendly form-based management for Teams and Projects —
an alternative to using Django admin for routine maintenance.

New URLs (admin-only, all return 403 for non-admins):
- /teams/ · /teams/new/ · /teams/<id>/ · /teams/<id>/edit/
- /teams/report/ · /teams/report/csv/
- /projects/ + same 5 variants

Forms (core/forms.py):
- TeamForm — ModelForm with pay-schedule validation (both or neither field)
- ProjectForm — ModelForm with end_date >= start_date validation
- _supervisor_user_queryset() — admins + Work Logger group members

Views (core/views.py):
- 10 new views (5 per model: list, detail, edit, batch_report, batch_csv)
- _build_team_report_context() / _build_project_report_context() shared helpers
- All views gate on is_admin(user)
- Reuses existing get_pay_period() for Team detail Pay Schedule tab

Templates (core/templates/core/teams/ and projects/):
- list.html — filterable table with search
- detail.html — tabbed profile / workers / history / schedule
- edit.html — serves both /new/ and /edit/
- batch_report.html — lifetime aggregates per row, CSV download

UI integration:
- Resources dropdown added to top nav (admin-only, Teams + Projects)
- Manage All buttons added to Dashboard Manage Resources tabs (Teams, Projects)

No model changes, no migrations — purely additive.
CLAUDE.md updated with new routes and section describing the pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 02:30:25 +02:00
parent 0ace7c6786
commit 5c8508171a
14 changed files with 2068 additions and 1 deletions

View File

@ -134,6 +134,41 @@ python manage.py check # System check
| `/payroll/batch-pay/preview/` | `batch_pay_preview` | Admin: AJAX JSON batch pay preview (`?mode=schedule\|all`) |
| `/payroll/batch-pay/` | `batch_pay` | Admin: POST process batch payments for multiple workers |
| `/run-migrate/` | `run_migrate` | Setup: run pending DB migrations from browser |
| `/teams/` | `team_list` | Admin: list teams with filters & search |
| `/teams/new/` | `team_edit` | Admin: create a new team |
| `/teams/<id>/` | `team_detail` | Admin: team detail (Profile · Pay Schedule · Workers · History) |
| `/teams/<id>/edit/` | `team_edit` | Admin: edit team (shared view with `team_new`) |
| `/teams/report/` | `team_batch_report` | Admin: batch report across all teams (HTML) |
| `/teams/report/csv/` | `team_batch_report_csv` | Admin: batch report CSV download |
| `/projects/` | `project_list` | Admin: list projects with filters & search |
| `/projects/new/` | `project_edit` | Admin: create a new project |
| `/projects/<id>/` | `project_detail` | Admin: project detail (Profile · Supervisors · Teams · Workers · History) |
| `/projects/<id>/edit/` | `project_edit` | Admin: edit project (shared view with `project_new`) |
| `/projects/report/` | `project_batch_report` | Admin: batch report across all projects (HTML) |
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: batch report CSV download |
## Team & Project Management Pages
Added as a friendly alternative to Django admin for managing Teams and Projects
outside of `/admin/`. Pattern mirrors (or anticipates) the Workers management UI.
- **Access**: Admins only — every view checks `is_admin(user)` and returns 403 for others
- **Entry points**:
- `Resources` dropdown in the top nav (admin-only) — links to Teams and Projects
- `Manage All Teams` / `Manage All Projects` buttons on the Dashboard's Manage Resources card tabs
- **Forms**: `TeamForm` and `ProjectForm` in `core/forms.py` — plain `ModelForm` classes,
no inline formsets. `_supervisor_user_queryset()` returns admins + Work Logger group members
so both forms pick supervisors from the same pool
- **Helpers in views.py**:
- `_build_team_report_context(request)` — shared between HTML and CSV batch-report views
- `_build_project_report_context(request)` — same pattern for projects
- Reuses existing `get_pay_period(team)` for the Team detail "Pay Schedule" tab
- **Templates**: `core/templates/core/teams/{list,detail,edit,batch_report}.html`
and `core/templates/core/projects/{list,detail,edit,batch_report}.html`
- **No model changes / no migrations** — these pages are purely additive
- **PDF export deferred** — HTML + CSV only for now; PDF can be added with a new
view + template per model if needed later
- **Django admin still works** for all of these models — the new pages are an
alternative UI, not a replacement
## Frontend Design Conventions
- **CSS variables** in `static/css/custom.css` `:root` — always use `var(--name)`:

View File

@ -6,9 +6,21 @@
from django import forms
from django.forms import inlineformset_factory
from django.contrib.auth.models import User
from django.db.models import Q
from .models import WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
# === HELPER: who can be a supervisor? ===
# The app's business rule: a "supervisor" is either an admin (is_staff)
# or a member of the "Work Logger" group. We reuse this queryset in both
# TeamForm (single supervisor) and ProjectForm (multiple supervisors).
def _supervisor_user_queryset():
return User.objects.filter(
Q(is_staff=True) | Q(groups__name='Work Logger')
).distinct().order_by('username')
class AttendanceLogForm(forms.ModelForm):
"""
Form for logging daily worker attendance.
@ -213,3 +225,130 @@ ExpenseLineItemFormSet = inlineformset_factory(
}),
}
)
# =============================================================================
# === TEAM FORM ===
# Used on /teams/new/ and /teams/<id>/edit/ to create or edit a Team.
# Mirrors the Django admin experience outside of /admin/ so the owner
# doesn't need to go into Django admin for routine team maintenance.
# =============================================================================
class TeamForm(forms.ModelForm):
"""
Form for creating/editing a Team.
Fields:
- name: team name
- supervisor: a single User (filtered to admins + Work Logger group)
- active: whether the team is currently in use
- pay_frequency: optional weekly / fortnightly / monthly
- pay_start_date: anchor date for the first pay period
- workers: checkbox list of ALL workers (active + inactive)
inactive ones are flagged with a badge in the template
"""
class Meta:
model = Team
fields = ['name', 'supervisor', 'active', 'pay_frequency',
'pay_start_date', 'workers']
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Team name (e.g. "Footings Crew")'
}),
'supervisor': forms.Select(attrs={'class': 'form-select'}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'pay_frequency': forms.Select(attrs={'class': 'form-select'}),
'pay_start_date': forms.DateInput(attrs={
'type': 'date', 'class': 'form-control'
}),
# Multi-select for workers — rendered as a checkbox grid in the template
'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Supervisor dropdown = admins + Work Logger group, alphabetical
self.fields['supervisor'].queryset = _supervisor_user_queryset()
self.fields['supervisor'].required = False
# Workers picker: ALL workers — inactive marked in template
self.fields['workers'].queryset = Worker.objects.all().order_by('name')
self.fields['workers'].required = False
# Schedule fields are optional
self.fields['pay_frequency'].required = False
self.fields['pay_start_date'].required = False
def clean(self):
"""If pay_frequency is set, pay_start_date must also be set (and vice versa)."""
cleaned = super().clean()
freq = cleaned.get('pay_frequency')
start = cleaned.get('pay_start_date')
if freq and not start:
self.add_error('pay_start_date',
'A start date is required when pay frequency is set.')
if start and not freq:
self.add_error('pay_frequency',
'Choose a pay frequency when setting a start date.')
return cleaned
# =============================================================================
# === PROJECT FORM ===
# Used on /projects/new/ and /projects/<id>/edit/ to create or edit a Project.
# =============================================================================
class ProjectForm(forms.ModelForm):
"""
Form for creating/editing a Project.
Fields:
- name: project name (e.g. "Solar Farm — Phase 2")
- description: free-text notes
- active: whether the project is currently running
- start_date / end_date: optional timeline
- supervisors: M2M to User any number of supervisors may be assigned
"""
class Meta:
model = Project
fields = ['name', 'description', 'active', 'start_date', 'end_date',
'supervisors']
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Project name'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'What this project covers...'
}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'start_date': forms.DateInput(attrs={
'type': 'date', 'class': 'form-control'
}),
'end_date': forms.DateInput(attrs={
'type': 'date', 'class': 'form-control'
}),
# Multi-select checkboxes for supervisors
'supervisors': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Supervisor dropdown = admins + Work Logger group members
self.fields['supervisors'].queryset = _supervisor_user_queryset()
self.fields['supervisors'].required = False
self.fields['start_date'].required = False
self.fields['end_date'].required = False
self.fields['description'].required = False
def clean(self):
"""If both dates are set, end_date must not be before start_date."""
cleaned = super().clean()
start = cleaned.get('start_date')
end = cleaned.get('end_date')
if start and end and end < start:
self.add_error('end_date', 'End date cannot be before start date.')
return cleaned

View File

@ -67,7 +67,31 @@
<i class="fas fa-receipt me-1"></i> Receipts
</a>
</li>
{# === RESOURCES DROPDOWN (admin only) ===
Friendly management pages for Teams and Projects — an
alternative to Django admin. Workers will be added here
when the Workers management UI ships in a later release. #}
{% if user.is_staff %}
<li class="nav-item dropdown">
{% with url_name=request.resolver_match.url_name %}
<a class="nav-link dropdown-toggle {% if url_name == 'team_list' or url_name == 'team_detail' or url_name == 'team_edit' or url_name == 'team_new' or url_name == 'team_batch_report' or url_name == 'project_list' or url_name == 'project_detail' or url_name == 'project_edit' or url_name == 'project_new' or url_name == 'project_batch_report' %}active{% endif %}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-folder-tree me-1"></i> Resources
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if url_name == 'team_list' or url_name == 'team_detail' or url_name == 'team_edit' or url_name == 'team_new' or url_name == 'team_batch_report' %}active{% endif %}" href="{% url 'team_list' %}">
<i class="fas fa-users me-1"></i> Teams
</a>
</li>
<li>
<a class="dropdown-item {% if url_name == 'project_list' or url_name == 'project_detail' or url_name == 'project_edit' or url_name == 'project_new' or url_name == 'project_batch_report' %}active{% endif %}" href="{% url 'project_list' %}">
<i class="fas fa-folder-open me-1"></i> Projects
</a>
</li>
</ul>
{% endwith %}
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">
<i class="fas fa-cog me-1"></i> Admin

View File

@ -246,6 +246,11 @@
{# === PROJECTS TAB === #}
<div class="tab-pane fade" id="projects" role="tabpanel">
<div class="d-flex justify-content-end px-3 pt-2 pb-1">
<a href="{% url 'project_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-1"></i> Manage All Projects
</a>
</div>
{% for item in projects %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
@ -261,6 +266,11 @@
{# === TEAMS TAB === #}
<div class="tab-pane fade" id="teams" role="tabpanel">
<div class="d-flex justify-content-end px-3 pt-2 pb-1">
<a href="{% url 'team_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-users me-1"></i> Manage All Teams
</a>
</div>
{% for item in teams %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>

View File

@ -0,0 +1,105 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Projects Report | Fox Fitt{% endblock %}
{% block content %}
{# === PROJECT BATCH REPORT ===
Admin-only. Per-project lifetime aggregates. Filter by active/inactive/all.
CSV download preserves the same filter. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-file-alt me-2"></i>Projects Batch Report
</h1>
<div class="d-flex gap-2">
<a href="{% url 'project_batch_report_csv' %}?active={{ active_filter }}"
class="btn btn-outline-success btn-sm shadow-sm">
<i class="fas fa-file-csv me-1"></i> Download CSV
</a>
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left me-1"></i> Back to Projects
</a>
</div>
</div>
{# === FILTER BAR === #}
<div class="card shadow-sm border-0 mb-4">
<div class="card-body py-3">
<form method="GET" action="{% url 'project_batch_report' %}" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Include</label>
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="active" {% if active_filter == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if active_filter == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>All projects</option>
</select>
</div>
</form>
</div>
</div>
{# === REPORT TABLE === #}
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Timeline</th>
<th class="text-center">Supervisors</th>
<th>Teams Involved</th>
<th class="text-center">Workers</th>
<th class="text-center">Worker-Days</th>
<th class="text-end">Labour Cost</th>
</tr>
</thead>
<tbody>
{% for row in project_rows %}
<tr>
<td>
<a href="{% url 'project_detail' row.project.id %}" class="text-decoration-none fw-semibold">
{{ row.project.name }}
</a>
{% if not row.project.active %}
<span class="badge bg-secondary ms-1">Inactive</span>
{% endif %}
</td>
<td class="small">
{% if row.project.start_date %}{{ row.project.start_date|date:"d M Y" }}{% else %}<span class="text-muted"></span>{% endif %}
&rarr;
{% if row.project.end_date %}{{ row.project.end_date|date:"d M Y" }}{% else %}<span class="text-muted">ongoing</span>{% endif %}
</td>
<td class="text-center">{{ row.supervisor_count }}</td>
<td class="small">
{% for team_name in row.teams_involved %}
<span class="badge bg-light text-dark border me-1">{{ team_name }}</span>
{% empty %}
<span class="text-muted"></span>
{% endfor %}
</td>
<td class="text-center">{{ row.worker_count }}</td>
<td class="text-center">{{ row.total_worker_days }}</td>
<td class="text-end fw-semibold">R {{ row.labour_cost|floatformat:2 }}</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">No projects match this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted small mt-3">
<i class="fas fa-info-circle me-1"></i>
Worker-Days = sum of workers across every day of work.
Labour Cost = sum of daily rates for every worker on every day.
</p>
</div>
{% endblock %}

View File

@ -0,0 +1,319 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ project.name }} | Projects | Fox Fitt{% endblock %}
{% block content %}
{# === PROJECT DETAIL PAGE ===
Admin-only, read-only view with 5 tabs:
Profile · Supervisors · Teams · Workers · History. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-folder-open me-2"></i>{{ project.name }}
</h1>
{% if project.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
{% if project.start_date or project.end_date %}
<span class="text-muted small ms-2">
<i class="fas fa-calendar me-1"></i>
{% if project.start_date %}{{ project.start_date|date:"d M Y" }}{% else %}—{% endif %}
&rarr;
{% if project.end_date %}{{ project.end_date|date:"d M Y" }}{% else %}ongoing{% endif %}
</span>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{% url 'project_edit' project.id %}" class="btn btn-accent btn-sm shadow-sm">
<i class="fas fa-edit me-1"></i> Edit Project
</a>
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left me-1"></i> Back to Projects
</a>
</div>
</div>
{# === TABS === #}
<ul class="nav nav-tabs mb-3" id="projectTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile-tab" type="button">
<i class="fas fa-id-card me-1"></i>Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#supervisors-tab" type="button">
<i class="fas fa-user-tie me-1"></i>Supervisors
<span class="badge bg-secondary ms-1">{{ supervisors.count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#teams-tab" type="button">
<i class="fas fa-users me-1"></i>Teams
<span class="badge bg-secondary ms-1">{{ teams_involved|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers-tab" type="button">
<i class="fas fa-hard-hat me-1"></i>Workers
<span class="badge bg-secondary ms-1">{{ workers_involved|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#history-tab" type="button">
<i class="fas fa-history me-1"></i>History
</button>
</li>
</ul>
<div class="tab-content">
{# === PROFILE TAB === #}
<div class="tab-pane fade show active" id="profile-tab">
<div class="card shadow-sm border-0">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3 text-muted">Name</dt>
<dd class="col-sm-9">{{ project.name }}</dd>
<dt class="col-sm-3 text-muted">Description</dt>
<dd class="col-sm-9">
{% if project.description %}
{{ project.description|linebreaks }}
{% else %}
<span class="text-muted">No description.</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Status</dt>
<dd class="col-sm-9">
{% if project.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Start Date</dt>
<dd class="col-sm-9">
{% if project.start_date %}{{ project.start_date|date:"d M Y" }}{% else %}<span class="text-muted"></span>{% endif %}
</dd>
<dt class="col-sm-3 text-muted">End Date</dt>
<dd class="col-sm-9">
{% if project.end_date %}{{ project.end_date|date:"d M Y" }}{% else %}<span class="text-muted">Ongoing</span>{% endif %}
</dd>
</dl>
</div>
</div>
</div>
{# === SUPERVISORS TAB === #}
<div class="tab-pane fade" id="supervisors-tab">
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th class="text-center">Staff</th>
</tr>
</thead>
<tbody>
{% for s in supervisors %}
<tr>
<td class="fw-semibold">{{ s.username }}</td>
<td>
{% if s.first_name or s.last_name %}
{{ s.first_name }} {{ s.last_name }}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td class="small">
{% if s.email %}{{ s.email }}{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td class="text-center">
{% if s.is_staff %}
<span class="badge bg-primary" title="Staff — full admin access">
<i class="fas fa-star"></i>
</span>
{% else %}
<span class="badge bg-light text-dark" title="Supervisor only"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No supervisors assigned.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# === TEAMS TAB === #}
<div class="tab-pane fade" id="teams-tab">
<div class="card shadow-sm border-0">
<div class="card-body">
{% if teams_involved %}
<p class="small text-muted mb-3">Teams that have worked on this project at some point.</p>
<div class="row g-2">
{% for team in teams_involved %}
<div class="col-md-6 col-lg-4">
<div class="p-3 border rounded d-flex justify-content-between align-items-center">
<div>
<a href="{% url 'team_detail' team.id %}" class="fw-semibold text-decoration-none">
{{ team.name }}
</a>
{% if team.supervisor %}
<div class="small text-muted">
<i class="fas fa-user-tie me-1"></i>{{ team.supervisor.username }}
</div>
{% endif %}
</div>
{% if team.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">No teams have been logged on this project yet.</p>
{% endif %}
</div>
</div>
</div>
{# === WORKERS TAB === #}
<div class="tab-pane fade" id="workers-tab">
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID Number</th>
<th class="text-end">Daily Rate</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
{% for w in workers_involved %}
<tr>
<td class="fw-semibold">{{ w.name }}</td>
<td class="text-muted small">{{ w.id_number }}</td>
<td class="text-end">R {{ w.daily_rate|floatformat:2 }}</td>
<td class="text-center">
{% if w.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No workers have logged attendance on this project.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# === HISTORY TAB === #}
<div class="tab-pane fade" id="history-tab">
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Total Worker-Days</div>
<div class="h3 mb-0">{{ total_worker_days }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Lifetime Labour Cost</div>
<div class="h3 mb-0">R {{ labour_cost|floatformat:2 }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Active Date Range</div>
<div class="h6 mb-0">
{% if date_range.0 %}
{{ date_range.0|date:"d M Y" }}<br>
<span class="small text-muted">to {{ date_range.1|date:"d M Y" }}</span>
{% else %}
<span class="text-muted">No logs yet</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0">Recent Work Logs</h6>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Team</th>
<th class="text-center">Workers</th>
<th>Supervisor</th>
</tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>{{ log.date|date:"d M Y" }}</td>
<td>
{% if log.team %}
{{ log.team.name }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">{{ log.workers.count }}</td>
<td>
{% if log.supervisor %}{{ log.supervisor.username }}{% else %}<span class="text-muted"></span>{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No work logs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,150 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{% if is_new %}New Project{% else %}Edit {{ project.name }}{% endif %} | Fox Fitt{% endblock %}
{% block content %}
{# === PROJECT EDIT/CREATE PAGE ===
Serves both /projects/new/ and /projects/<id>/edit/.
Form sections: Project Basics · Timeline · Supervisors. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-{% if is_new %}plus{% else %}edit{% endif %} me-2"></i>
{% if is_new %}New Project{% else %}Edit: {{ project.name }}{% endif %}
</h1>
<div class="d-flex gap-2">
{% if not is_new %}
<a href="{% url 'project_detail' project.id %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-times me-1"></i> Cancel
</a>
{% else %}
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-times me-1"></i> Cancel
</a>
{% endif %}
</div>
</div>
<form method="POST" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger shadow-sm">
{{ form.non_field_errors }}
</div>
{% endif %}
<div class="row g-3">
{# === PROJECT BASICS === #}
<div class="col-lg-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-id-card me-2 text-muted"></i>Project Basics</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="{{ form.name.id_for_label }}">Name *</label>
{{ form.name }}
{% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors.0 }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label" for="{{ form.description.id_for_label }}">Description</label>
{{ form.description }}
{% if form.description.errors %}<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>{% endif %}
</div>
<div class="form-check form-switch">
{{ form.active }}
<label class="form-check-label" for="{{ form.active.id_for_label }}">
Active
</label>
<div class="form-text small text-muted">
Inactive projects are hidden from attendance logging forms.
</div>
</div>
</div>
</div>
</div>
{# === TIMELINE === #}
<div class="col-lg-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-calendar-alt me-2 text-muted"></i>Timeline</h6>
</div>
<div class="card-body">
<p class="small text-muted">
Optional. Used for reporting and filtering.
</p>
<div class="mb-3">
<label class="form-label" for="{{ form.start_date.id_for_label }}">Start Date</label>
{{ form.start_date }}
{% if form.start_date.errors %}<div class="text-danger small mt-1">{{ form.start_date.errors.0 }}</div>{% endif %}
</div>
<div class="mb-0">
<label class="form-label" for="{{ form.end_date.id_for_label }}">End Date</label>
{{ form.end_date }}
{% if form.end_date.errors %}<div class="text-danger small mt-1">{{ form.end_date.errors.0 }}</div>{% endif %}
<div class="form-text small text-muted">
Leave blank for ongoing projects.
</div>
</div>
</div>
</div>
</div>
{# === SUPERVISORS PICKER === #}
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-user-tie me-2 text-muted"></i>Supervisors</h6>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
Any number of supervisors may be assigned.
Only admins and members of the "Work Logger" group are listed.
</p>
{% if form.supervisors.errors %}
<div class="text-danger small mb-2">{{ form.supervisors.errors.0 }}</div>
{% endif %}
<div class="row g-2">
{% for choice in form.supervisors %}
<div class="col-md-4 col-lg-3">
<div class="form-check">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
</label>
</div>
</div>
{% empty %}
<div class="col-12 text-muted small">
No eligible supervisors. Add a user to the "Work Logger" group or grant them staff access first.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{# === SUBMIT ROW === #}
<div class="d-flex justify-content-end gap-2 mt-4">
{% if not is_new %}
<a href="{% url 'project_detail' project.id %}" class="btn btn-outline-secondary">Cancel</a>
{% else %}
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary">Cancel</a>
{% endif %}
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
{% if is_new %}Create Project{% else %}Save Changes{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,141 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Projects | Fox Fitt{% endblock %}
{% block content %}
{# === PROJECT LIST PAGE ===
Admin-only. Shows every project with supervisor summary, worker count,
active status, and date range. Filter by active/inactive/all; search by name. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-folder-open me-2"></i>Projects
</h1>
<div class="d-flex gap-2">
<a href="{% url 'project_batch_report' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-file-alt me-1"></i> Batch Report
</a>
<a href="{% url 'project_new' %}" class="btn btn-accent btn-sm shadow-sm">
<i class="fas fa-plus me-1"></i> New Project
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left 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" action="{% url 'project_list' %}" class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-muted mb-1">Search</label>
<input type="text" name="q" value="{{ search }}" class="form-control form-control-sm"
placeholder="Project name…">
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Status</label>
<select name="active" class="form-select form-select-sm">
<option value="active" {% if active_filter == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if active_filter == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>All</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="fas fa-filter me-1"></i> Apply
</button>
</div>
</form>
</div>
</div>
{# === PROJECT TABLE === #}
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Supervisors</th>
<th class="text-center">Workers</th>
<th>Timeline</th>
<th class="text-center">Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for row in project_data %}
{% with p=row.project %}
<tr>
<td>
<a href="{% url 'project_detail' p.id %}" class="text-decoration-none fw-semibold">
{{ p.name }}
</a>
</td>
<td class="small">
{% with first=p.supervisors.first total=p.supervisors.count %}
{% if first %}
<i class="fas fa-user-tie me-1 text-muted"></i>{{ first.username }}
{% if total > 1 %}
<span class="text-muted">and {{ total|add:"-1" }} more</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
{% endwith %}
</td>
<td class="text-center">
<span class="badge bg-secondary">{{ row.worker_count }}</span>
</td>
<td class="small">
{% if p.start_date %}
{{ p.start_date|date:"d M Y" }}
{% else %}
<span class="text-muted"></span>
{% endif %}
&rarr;
{% if p.end_date %}
{{ p.end_date|date:"d M Y" }}
{% else %}
<span class="text-muted">ongoing</span>
{% endif %}
</td>
<td class="text-center">
{% if p.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<a href="{% url 'project_detail' p.id %}" class="btn btn-sm btn-outline-secondary" title="View">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'project_edit' p.id %}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="fas fa-edit"></i>
</a>
</td>
</tr>
{% endwith %}
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
No projects match the current filter.
{% if search or active_filter != 'all' %}
<a href="{% url 'project_list' %}?active=all">Clear filters</a>.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,115 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Teams Report | Fox Fitt{% endblock %}
{% block content %}
{# === TEAM BATCH REPORT ===
Admin-only. Per-team lifetime aggregates. Filter by active/inactive/all.
CSV download preserves the same filter. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-file-alt me-2"></i>Teams Batch Report
</h1>
<div class="d-flex gap-2">
<a href="{% url 'team_batch_report_csv' %}?active={{ active_filter }}"
class="btn btn-outline-success btn-sm shadow-sm">
<i class="fas fa-file-csv me-1"></i> Download CSV
</a>
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left me-1"></i> Back to Teams
</a>
</div>
</div>
{# === FILTER BAR === #}
<div class="card shadow-sm border-0 mb-4">
<div class="card-body py-3">
<form method="GET" action="{% url 'team_batch_report' %}" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Include</label>
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="active" {% if active_filter == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if active_filter == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>All teams</option>
</select>
</div>
</form>
</div>
</div>
{# === REPORT TABLE === #}
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Team</th>
<th>Supervisor</th>
<th class="text-center">Workers</th>
<th>Pay Schedule</th>
<th class="text-center">Work Days</th>
<th class="text-center">Worker-Days</th>
<th class="text-end">Labour Cost</th>
<th>Projects</th>
</tr>
</thead>
<tbody>
{% for row in team_rows %}
<tr>
<td>
<a href="{% url 'team_detail' row.team.id %}" class="text-decoration-none fw-semibold">
{{ row.team.name }}
</a>
{% if not row.team.active %}
<span class="badge bg-secondary ms-1">Inactive</span>
{% endif %}
</td>
<td>
{% if row.team.supervisor %}
{{ row.team.supervisor.username }}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td class="text-center">{{ row.worker_count }}</td>
<td>
{% if row.team.pay_frequency %}
<span class="small">{{ row.team.get_pay_frequency_display }}</span>
{% else %}
<span class="text-muted small">Not set</span>
{% endif %}
</td>
<td class="text-center">{{ row.total_work_days }}</td>
<td class="text-center">{{ row.total_worker_days }}</td>
<td class="text-end fw-semibold">R {{ row.labour_cost|floatformat:2 }}</td>
<td class="small">
{% for project_name in row.projects_touched %}
<span class="badge bg-light text-dark border me-1">{{ project_name }}</span>
{% empty %}
<span class="text-muted"></span>
{% endfor %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center text-muted py-4">No teams match this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted small mt-3">
<i class="fas fa-info-circle me-1"></i>
Work Days = total attendance records. Worker-Days = sum of workers across all records.
Labour Cost = sum of daily rates for every worker on every day.
</p>
</div>
{% endblock %}

View File

@ -0,0 +1,296 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ team.name }} | Teams | Fox Fitt{% endblock %}
{% block content %}
{# === TEAM DETAIL PAGE ===
Admin-only, read-only view with 4 tabs:
Profile · Pay Schedule · Workers · History. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-users me-2"></i>{{ team.name }}
</h1>
{% if team.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
{% if team.supervisor %}
<span class="text-muted small ms-2">
<i class="fas fa-user-tie me-1"></i>Supervisor: <strong>{{ team.supervisor.username }}</strong>
</span>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{% url 'team_edit' team.id %}" class="btn btn-accent btn-sm shadow-sm">
<i class="fas fa-edit me-1"></i> Edit Team
</a>
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left me-1"></i> Back to Teams
</a>
</div>
</div>
{# === TABS === #}
<ul class="nav nav-tabs mb-3" id="teamTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile-tab" type="button">
<i class="fas fa-id-card me-1"></i>Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#schedule-tab" type="button">
<i class="fas fa-calendar-alt me-1"></i>Pay Schedule
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers-tab" type="button">
<i class="fas fa-hard-hat me-1"></i>Workers
<span class="badge bg-secondary ms-1">{{ workers.count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#history-tab" type="button">
<i class="fas fa-history me-1"></i>History
</button>
</li>
</ul>
<div class="tab-content">
{# === PROFILE TAB === #}
<div class="tab-pane fade show active" id="profile-tab">
<div class="card shadow-sm border-0">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3 text-muted">Name</dt>
<dd class="col-sm-9">{{ team.name }}</dd>
<dt class="col-sm-3 text-muted">Supervisor</dt>
<dd class="col-sm-9">
{% if team.supervisor %}
{{ team.supervisor.username }}
{% if team.supervisor.email %}
<span class="text-muted small ms-2">({{ team.supervisor.email }})</span>
{% endif %}
{% else %}
<span class="text-muted">Not assigned</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Status</dt>
<dd class="col-sm-9">
{% if team.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Pay Frequency</dt>
<dd class="col-sm-9">
{% if team.pay_frequency %}
{{ team.get_pay_frequency_display }}
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Pay Start Date</dt>
<dd class="col-sm-9">
{% if team.pay_start_date %}
{{ team.pay_start_date|date:"d M Y" }}
<span class="text-muted small ms-2">(anchor for pay period calculation)</span>
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
</dd>
<dt class="col-sm-3 text-muted">Worker Count</dt>
<dd class="col-sm-9">{{ workers.count }}</dd>
</dl>
</div>
</div>
</div>
{# === PAY SCHEDULE TAB === #}
<div class="tab-pane fade" id="schedule-tab">
<div class="card shadow-sm border-0">
<div class="card-body">
{% if current_period %}
<h5 class="mb-3"><i class="fas fa-calendar-check me-2 text-success"></i>Current Pay Period</h5>
<p class="lead">
{{ current_period.0|date:"d M Y" }} &mdash; {{ current_period.1|date:"d M Y" }}
</p>
{% if upcoming_periods %}
<hr>
<h6 class="text-muted mb-3">Upcoming Pay Periods</h6>
<ul class="list-unstyled">
{% for start, end in upcoming_periods %}
<li class="mb-2">
<i class="fas fa-chevron-right me-2 text-muted"></i>
{{ start|date:"d M Y" }} &mdash; {{ end|date:"d M Y" }}
</li>
{% endfor %}
</ul>
{% endif %}
<hr>
<p class="small text-muted mb-0">
<i class="fas fa-info-circle me-1"></i>
Pay periods are calculated from the anchor date
({{ team.pay_start_date|date:"d M Y" }}) stepping forward by
{{ team.get_pay_frequency_display|lower }} intervals.
</p>
{% else %}
<p class="text-muted mb-3">
<i class="fas fa-info-circle me-1"></i>
This team has no pay schedule configured.
</p>
<p class="small text-muted mb-0">
To enable pay period calculations, edit the team and set both
<strong>Pay Frequency</strong> (weekly/fortnightly/monthly) and
<strong>Pay Start Date</strong>.
</p>
<a href="{% url 'team_edit' team.id %}" class="btn btn-outline-primary btn-sm mt-3">
<i class="fas fa-edit me-1"></i>Set up pay schedule
</a>
{% endif %}
</div>
</div>
</div>
{# === WORKERS TAB === #}
<div class="tab-pane fade" id="workers-tab">
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID Number</th>
<th class="text-end">Monthly Salary</th>
<th class="text-end">Daily Rate</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
{% for w in workers %}
<tr>
<td class="fw-semibold">{{ w.name }}</td>
<td class="text-muted small">{{ w.id_number }}</td>
<td class="text-end">R {{ w.monthly_salary|floatformat:2 }}</td>
<td class="text-end">R {{ w.daily_rate|floatformat:2 }}</td>
<td class="text-center">
{% if w.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted py-4">No workers assigned to this team yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# === HISTORY TAB === #}
<div class="tab-pane fade" id="history-tab">
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Total Work Days</div>
<div class="h3 mb-0">{{ total_days }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Projects Worked</div>
<div class="h3 mb-0">{{ projects_touched|length }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-center">
<div class="text-muted small text-uppercase mb-1">Lifetime Labour Cost</div>
<div class="h3 mb-0">R {{ labour_cost|floatformat:2 }}</div>
</div>
</div>
</div>
</div>
{% if projects_touched %}
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-white">
<h6 class="mb-0">Projects this team has worked on</h6>
</div>
<div class="card-body">
{% for name in projects_touched %}
<span class="badge bg-light text-dark border me-1 mb-1">
<i class="fas fa-folder-open me-1 text-muted"></i>{{ name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0">Recent Work Logs</h6>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th class="text-center">Workers</th>
<th>Supervisor</th>
</tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.project.name }}</td>
<td class="text-center">{{ log.workers.count }}</td>
<td>
{% if log.supervisor %}
{{ log.supervisor.username }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No work logs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,152 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{% if is_new %}New Team{% else %}Edit {{ team.name }}{% endif %} | Fox Fitt{% endblock %}
{% block content %}
{# === TEAM EDIT/CREATE PAGE ===
Serves both /teams/new/ and /teams/<id>/edit/.
Form sections: Team Basics · Pay Schedule · Workers. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-{% if is_new %}plus{% else %}edit{% endif %} me-2"></i>
{% if is_new %}New Team{% else %}Edit: {{ team.name }}{% endif %}
</h1>
<div class="d-flex gap-2">
{% if not is_new %}
<a href="{% url 'team_detail' team.id %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-times me-1"></i> Cancel
</a>
{% else %}
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-times me-1"></i> Cancel
</a>
{% endif %}
</div>
</div>
<form method="POST" novalidate>
{% csrf_token %}
{# === FORM-LEVEL ERRORS === #}
{% if form.non_field_errors %}
<div class="alert alert-danger shadow-sm">
{{ form.non_field_errors }}
</div>
{% endif %}
<div class="row g-3">
{# === TEAM BASICS === #}
<div class="col-lg-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-id-card me-2 text-muted"></i>Team Basics</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="{{ form.name.id_for_label }}">Name *</label>
{{ form.name }}
{% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors.0 }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label" for="{{ form.supervisor.id_for_label }}">Supervisor</label>
{{ form.supervisor }}
{% if form.supervisor.errors %}<div class="text-danger small mt-1">{{ form.supervisor.errors.0 }}</div>{% endif %}
<div class="form-text small text-muted">
The user who manages this team — admin or member of the Work Logger group.
</div>
</div>
<div class="form-check form-switch">
{{ form.active }}
<label class="form-check-label" for="{{ form.active.id_for_label }}">
Active
</label>
<div class="form-text small text-muted">
Inactive teams are hidden from attendance logging forms.
</div>
</div>
</div>
</div>
</div>
{# === PAY SCHEDULE === #}
<div class="col-lg-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-calendar-alt me-2 text-muted"></i>Pay Schedule</h6>
</div>
<div class="card-body">
<p class="small text-muted">
Optional. If set, the app can calculate pay periods automatically.
Both fields must be filled together (or both left blank).
</p>
<div class="mb-3">
<label class="form-label" for="{{ form.pay_frequency.id_for_label }}">Pay Frequency</label>
{{ form.pay_frequency }}
{% if form.pay_frequency.errors %}<div class="text-danger small mt-1">{{ form.pay_frequency.errors.0 }}</div>{% endif %}
</div>
<div class="mb-0">
<label class="form-label" for="{{ form.pay_start_date.id_for_label }}">Pay Start Date</label>
{{ form.pay_start_date }}
{% if form.pay_start_date.errors %}<div class="text-danger small mt-1">{{ form.pay_start_date.errors.0 }}</div>{% endif %}
<div class="form-text small text-muted">
Anchor date — the first day of the very first pay period. Never needs updating.
</div>
</div>
</div>
</div>
</div>
{# === WORKERS PICKER === #}
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="fas fa-hard-hat me-2 text-muted"></i>Workers</h6>
<span class="small text-muted">
Inactive workers appear with a grey badge and are still selectable.
</span>
</div>
<div class="card-body">
{% if form.workers.errors %}
<div class="text-danger small mb-2">{{ form.workers.errors.0 }}</div>
{% endif %}
<div class="row g-2">
{% for choice in form.workers %}
<div class="col-md-4 col-lg-3">
<div class="form-check">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
</label>
</div>
</div>
{% empty %}
<div class="col-12 text-muted small">No workers exist yet.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{# === SUBMIT ROW === #}
<div class="d-flex justify-content-end gap-2 mt-4">
{% if not is_new %}
<a href="{% url 'team_detail' team.id %}" class="btn btn-outline-secondary">Cancel</a>
{% else %}
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary">Cancel</a>
{% endif %}
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
{% if is_new %}Create Team{% else %}Save Changes{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,131 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Teams | Fox Fitt{% endblock %}
{% block content %}
{# === TEAM LIST PAGE ===
Admin-only. Shows every team with supervisor, worker count, pay schedule,
and active status. Filter by active/inactive/all; search by name. #}
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">
<i class="fas fa-users me-2"></i>Teams
</h1>
<div class="d-flex gap-2">
<a href="{% url 'team_batch_report' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-file-alt me-1"></i> Batch Report
</a>
<a href="{% url 'team_new' %}" class="btn btn-accent btn-sm shadow-sm">
<i class="fas fa-plus me-1"></i> New Team
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left 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" action="{% url 'team_list' %}" class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-muted mb-1">Search</label>
<input type="text" name="q" value="{{ search }}" class="form-control form-control-sm"
placeholder="Team name…">
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Status</label>
<select name="active" class="form-select form-select-sm">
<option value="active" {% if active_filter == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if active_filter == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>All</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="fas fa-filter me-1"></i> Apply
</button>
</div>
</form>
</div>
</div>
{# === TEAM TABLE === #}
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Supervisor</th>
<th class="text-center">Workers</th>
<th>Pay Schedule</th>
<th class="text-center">Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td>
<a href="{% url 'team_detail' team.id %}" class="text-decoration-none fw-semibold">
{{ team.name }}
</a>
</td>
<td>
{% if team.supervisor %}
<i class="fas fa-user-tie me-1 text-muted"></i>{{ team.supervisor.username }}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td class="text-center">
<span class="badge bg-secondary">{{ team.workers.count }}</span>
</td>
<td>
{% if team.pay_frequency %}
<span class="small">{{ team.get_pay_frequency_display }}</span>
{% if team.pay_start_date %}
<br><span class="small text-muted">from {{ team.pay_start_date|date:"d M Y" }}</span>
{% endif %}
{% else %}
<span class="text-muted small">Not set</span>
{% endif %}
</td>
<td class="text-center">
{% if team.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<a href="{% url 'team_detail' team.id %}" class="btn btn-sm btn-outline-secondary" title="View">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'team_edit' team.id %}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="fas fa-edit"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
No teams match the current filter.
{% if search or active_filter != 'all' %}
<a href="{% url 'team_list' %}?active=all">Clear filters</a>.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -24,6 +24,25 @@ urlpatterns = [
# AJAX toggle — activates/deactivates workers, projects, teams from dashboard
path('toggle/<str:model_name>/<int:item_id>/', views.toggle_active, name='toggle_active'),
# === TEAMS MANAGEMENT ===
# Friendly form-based management pages (alternative to Django admin).
# Admins can view, create, edit, and report on teams without leaving the app.
path('teams/', views.team_list, name='team_list'),
path('teams/new/', views.team_edit, name='team_new'),
path('teams/<int:team_id>/', views.team_detail, name='team_detail'),
path('teams/<int:team_id>/edit/', views.team_edit, name='team_edit'),
path('teams/report/', views.team_batch_report, name='team_batch_report'),
path('teams/report/csv/', views.team_batch_report_csv, name='team_batch_report_csv'),
# === PROJECTS MANAGEMENT ===
# Same pattern as Teams — friendly management pages outside Django admin.
path('projects/', views.project_list, name='project_list'),
path('projects/new/', views.project_edit, name='project_new'),
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
path('projects/<int:project_id>/edit/', views.project_edit, name='project_edit'),
path('projects/report/', views.project_batch_report, name='project_batch_report'),
path('projects/report/csv/', views.project_batch_report_csv, name='project_batch_report_csv'),
# === PAYROLL ===
# Main payroll dashboard — shows pending payments, history, loans, and charts
path('payroll/', views.payroll_dashboard, name='payroll_dashboard'),

View File

@ -23,7 +23,8 @@ from django.utils.html import strip_tags
from django.conf import settings
from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment
from .forms import AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet
from .forms import (AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm,
ExpenseLineItemFormSet, TeamForm, ProjectForm)
# NOTE: render_to_pdf is NOT imported here at the top level.
# It's imported lazily inside process_payment() and create_receipt()
# to avoid crashing the entire app if xhtml2pdf is not installed on the server.
@ -2636,6 +2637,436 @@ def run_migrate(request):
)
# =============================================================================
# === TEAM MANAGEMENT VIEWS ===
# Friendly form-based pages for Teams. Mirrors the Django admin Team screens
# but inside the app UI so the owner doesn't need to use /admin/ for routine
# maintenance.
# =============================================================================
def _build_team_report_context(request):
"""Shared helper for the team batch report (HTML + CSV).
Builds a list of dicts one per team with aggregate stats used
across the HTML table, the printable batch report, and the CSV export.
Separated so all three views pull identical numbers.
"""
# Optional filter: ?active=active|inactive|all (default: active)
active_filter = request.GET.get('active', 'active')
teams_qs = Team.objects.all().order_by('name')
if active_filter == 'active':
teams_qs = teams_qs.filter(active=True)
elif active_filter == 'inactive':
teams_qs = teams_qs.filter(active=False)
rows = []
for team in teams_qs.prefetch_related('workers', 'work_logs__project',
'work_logs__workers'):
# Collect distinct projects + total worker-days lifetime
projects_touched = set()
total_worker_days = 0
for log in team.work_logs.all():
projects_touched.add(log.project.name)
total_worker_days += log.workers.count()
# Total lifetime labour cost for this team
labour_cost = Decimal('0.00')
for log in team.work_logs.all():
for w in log.workers.all():
labour_cost += w.daily_rate
# Current pay period (if configured)
period_start, period_end = get_pay_period(team)
rows.append({
'team': team,
'worker_count': team.workers.count(),
'total_work_days': team.work_logs.count(),
'total_worker_days': total_worker_days,
'labour_cost': labour_cost.quantize(Decimal('0.01')),
'projects_touched': sorted(projects_touched),
'period_start': period_start,
'period_end': period_end,
})
return {'team_rows': rows, 'active_filter': active_filter}
@login_required
def team_list(request):
"""Team list page at /teams/ — admin-only.
Shows name · supervisor · worker count · pay schedule · active badge.
Filter bar: active / inactive / all; search by team name.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
active_filter = request.GET.get('active', 'active')
search = request.GET.get('q', '').strip()
teams = Team.objects.all().order_by('name')
if active_filter == 'active':
teams = teams.filter(active=True)
elif active_filter == 'inactive':
teams = teams.filter(active=False)
if search:
teams = teams.filter(name__icontains=search)
# Annotate worker count per team in a single query (instead of per-row lookups)
teams = teams.prefetch_related('workers', 'supervisor')
return render(request, 'core/teams/list.html', {
'teams': teams,
'active_filter': active_filter,
'search': search,
})
@login_required
def team_detail(request, team_id):
"""Team detail page — admin-only. Read-only with 4 tabs.
Tabs: Profile · Pay Schedule · Workers · History.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
team = get_object_or_404(Team, pk=team_id)
# === PAY SCHEDULE TAB: current + next 2 upcoming periods ===
# Step forward from the current period end to compute the next 2 periods.
upcoming_periods = []
period_start, period_end = get_pay_period(team)
if period_start and period_end:
# Iterate forward 2 more times to get the next 2 upcoming periods
cursor = period_end + datetime.timedelta(days=1)
for _ in range(2):
nxt_start, nxt_end = get_pay_period(team, reference_date=cursor)
if nxt_start and nxt_end:
upcoming_periods.append((nxt_start, nxt_end))
cursor = nxt_end + datetime.timedelta(days=1)
else:
break
# === WORKERS TAB ===
# All workers in this team, with active status for the template badge
workers = team.workers.all().order_by('name')
# === HISTORY TAB ===
# Work logs for this team — total days, projects touched, labour cost,
# and the 10 most recent logs.
work_logs = team.work_logs.select_related('project').prefetch_related('workers')
total_days = work_logs.count()
projects_touched = sorted({log.project.name for log in work_logs})
labour_cost = Decimal('0.00')
for log in work_logs:
for w in log.workers.all():
labour_cost += w.daily_rate
labour_cost = labour_cost.quantize(Decimal('0.01'))
recent_logs = work_logs.order_by('-date')[:10]
return render(request, 'core/teams/detail.html', {
'team': team,
'current_period': (period_start, period_end) if period_start else None,
'upcoming_periods': upcoming_periods,
'workers': workers,
'total_days': total_days,
'projects_touched': projects_touched,
'labour_cost': labour_cost,
'recent_logs': recent_logs,
})
@login_required
def team_edit(request, team_id=None):
"""Create a new team (when team_id is None) or edit an existing one.
Both /teams/new/ and /teams/<id>/edit/ route here Django passes
team_id=None for the 'new' URL.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
team = get_object_or_404(Team, pk=team_id) if team_id else None
if request.method == 'POST':
form = TeamForm(request.POST, instance=team)
if form.is_valid():
saved_team = form.save()
action = 'updated' if team_id else 'created'
messages.success(request, f'Team "{saved_team.name}" {action} successfully.')
return redirect('team_detail', team_id=saved_team.pk)
else:
form = TeamForm(instance=team)
return render(request, 'core/teams/edit.html', {
'form': form,
'team': team, # None when creating — template uses it to pick heading
'is_new': team_id is None,
})
@login_required
def team_batch_report(request):
"""Admin-only. Batch report across all teams — HTML page.
Shows per-team aggregates: supervisor, worker count, pay schedule,
total work days, labour cost, projects worked on.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
context = _build_team_report_context(request)
return render(request, 'core/teams/batch_report.html', context)
@login_required
def team_batch_report_csv(request):
"""Admin-only. Same data as the batch report, downloaded as CSV."""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
context = _build_team_report_context(request)
response = HttpResponse(content_type='text/csv')
ts = timezone.now().strftime('%Y%m%d_%H%M%S')
response['Content-Disposition'] = f'attachment; filename="teams_report_{ts}.csv"'
writer = csv.writer(response)
writer.writerow([
'Team Name', 'Supervisor', 'Active', 'Pay Frequency', 'Pay Start Date',
'Worker Count', 'Total Work Days', 'Total Worker-Days',
'Lifetime Labour Cost', 'Projects Worked On',
])
for row in context['team_rows']:
t = row['team']
writer.writerow([
t.name,
t.supervisor.username if t.supervisor else '',
'Yes' if t.active else 'No',
t.pay_frequency or '',
t.pay_start_date.isoformat() if t.pay_start_date else '',
row['worker_count'],
row['total_work_days'],
row['total_worker_days'],
f"{row['labour_cost']:.2f}",
'; '.join(row['projects_touched']),
])
return response
# =============================================================================
# === PROJECT MANAGEMENT VIEWS ===
# Same pattern as Team management — friendly form-based pages outside admin.
# =============================================================================
def _build_project_report_context(request):
"""Shared helper for the project batch report (HTML + CSV).
Builds a row-per-project list with the aggregates the report displays:
supervisor count, teams involved, distinct workers, total worker-days,
and lifetime labour cost.
"""
active_filter = request.GET.get('active', 'active')
projects_qs = Project.objects.all().order_by('name')
if active_filter == 'active':
projects_qs = projects_qs.filter(active=True)
elif active_filter == 'inactive':
projects_qs = projects_qs.filter(active=False)
rows = []
for project in projects_qs.prefetch_related(
'supervisors', 'work_logs__team', 'work_logs__workers'):
# Count distinct teams and workers that have worked on this project
teams_involved = set()
workers_involved = set()
total_worker_days = 0
labour_cost = Decimal('0.00')
log_dates = []
for log in project.work_logs.all():
if log.team:
teams_involved.add(log.team.name)
log_dates.append(log.date)
for w in log.workers.all():
workers_involved.add(w.id)
total_worker_days += 1
labour_cost += w.daily_rate
# Date range of all activity (earliest to latest log)
date_range = (min(log_dates), max(log_dates)) if log_dates else (None, None)
rows.append({
'project': project,
'supervisor_count': project.supervisors.count(),
'teams_involved': sorted(teams_involved),
'worker_count': len(workers_involved),
'total_worker_days': total_worker_days,
'labour_cost': labour_cost.quantize(Decimal('0.01')),
'date_range': date_range,
})
return {'project_rows': rows, 'active_filter': active_filter}
@login_required
def project_list(request):
"""Project list page at /projects/ — admin-only.
Columns: name, supervisors (first + count), worker count, active,
start/end dates, actions.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
active_filter = request.GET.get('active', 'active')
search = request.GET.get('q', '').strip()
projects = Project.objects.all().order_by('name')
if active_filter == 'active':
projects = projects.filter(active=True)
elif active_filter == 'inactive':
projects = projects.filter(active=False)
if search:
projects = projects.filter(name__icontains=search)
projects = projects.prefetch_related('supervisors', 'work_logs__workers')
# Annotate worker count (distinct workers across all work logs for this project)
project_data = []
for p in projects:
worker_ids = set()
for log in p.work_logs.all():
for w in log.workers.all():
worker_ids.add(w.id)
project_data.append({
'project': p,
'worker_count': len(worker_ids),
})
return render(request, 'core/projects/list.html', {
'project_data': project_data,
'active_filter': active_filter,
'search': search,
})
@login_required
def project_detail(request, project_id):
"""Project detail page — admin-only. 5 tabs: Profile · Supervisors ·
Teams · Workers · History.
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
project = get_object_or_404(Project, pk=project_id)
# === SUPERVISORS TAB ===
supervisors = project.supervisors.all().order_by('username')
# === TEAMS + WORKERS TABS ===
# Gather which teams + distinct workers have worked on this project
work_logs = project.work_logs.select_related('team').prefetch_related('workers')
teams_involved = {}
workers_involved = {}
total_worker_days = 0
labour_cost = Decimal('0.00')
log_dates = []
for log in work_logs:
if log.team:
teams_involved[log.team.id] = log.team
log_dates.append(log.date)
for w in log.workers.all():
workers_involved[w.id] = w
total_worker_days += 1
labour_cost += w.daily_rate
labour_cost = labour_cost.quantize(Decimal('0.01'))
date_range = (min(log_dates), max(log_dates)) if log_dates else (None, None)
# === HISTORY TAB ===
recent_logs = work_logs.order_by('-date')[:10]
return render(request, 'core/projects/detail.html', {
'project': project,
'supervisors': supervisors,
'teams_involved': list(teams_involved.values()),
'workers_involved': sorted(workers_involved.values(), key=lambda w: w.name),
'total_worker_days': total_worker_days,
'labour_cost': labour_cost,
'date_range': date_range,
'recent_logs': recent_logs,
})
@login_required
def project_edit(request, project_id=None):
"""Create a new project (project_id=None) or edit an existing one."""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
project = get_object_or_404(Project, pk=project_id) if project_id else None
if request.method == 'POST':
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
saved_project = form.save()
action = 'updated' if project_id else 'created'
messages.success(request, f'Project "{saved_project.name}" {action} successfully.')
return redirect('project_detail', project_id=saved_project.pk)
else:
form = ProjectForm(instance=project)
return render(request, 'core/projects/edit.html', {
'form': form,
'project': project,
'is_new': project_id is None,
})
@login_required
def project_batch_report(request):
"""Admin-only batch report across all projects — HTML."""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
context = _build_project_report_context(request)
return render(request, 'core/projects/batch_report.html', context)
@login_required
def project_batch_report_csv(request):
"""Admin-only. Same data as batch report, downloaded as CSV."""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
context = _build_project_report_context(request)
response = HttpResponse(content_type='text/csv')
ts = timezone.now().strftime('%Y%m%d_%H%M%S')
response['Content-Disposition'] = f'attachment; filename="projects_report_{ts}.csv"'
writer = csv.writer(response)
writer.writerow([
'Project Name', 'Active', 'Start Date', 'End Date', 'Supervisor Count',
'Supervisors', 'Teams Involved', 'Worker Count', 'Total Worker-Days',
'Lifetime Labour Cost',
])
for row in context['project_rows']:
p = row['project']
writer.writerow([
p.name,
'Yes' if p.active else 'No',
p.start_date.isoformat() if p.start_date else '',
p.end_date.isoformat() if p.end_date else '',
row['supervisor_count'],
'; '.join(s.username for s in p.supervisors.all()),
'; '.join(row['teams_involved']),
row['worker_count'],
row['total_worker_days'],
f"{row['labour_cost']:.2f}",
])
return response
# === BACKUP / RESTORE (browser-accessible, admin-only) ===
# Flatlogic has no shell/SSH — admins need to backup and restore via browser.
# These views wrap the `backup_data` and `restore_data` management commands