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:
parent
0ace7c6786
commit
5c8508171a
35
CLAUDE.md
35
CLAUDE.md
@ -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)`:
|
||||
|
||||
139
core/forms.py
139
core/forms.py
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
105
core/templates/core/projects/batch_report.html
Normal file
105
core/templates/core/projects/batch_report.html
Normal 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 %}
|
||||
→
|
||||
{% 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 %}
|
||||
319
core/templates/core/projects/detail.html
Normal file
319
core/templates/core/projects/detail.html
Normal 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 %}
|
||||
→
|
||||
{% 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 %}
|
||||
150
core/templates/core/projects/edit.html
Normal file
150
core/templates/core/projects/edit.html
Normal 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 %}
|
||||
141
core/templates/core/projects/list.html
Normal file
141
core/templates/core/projects/list.html
Normal 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 %}
|
||||
→
|
||||
{% 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 %}
|
||||
115
core/templates/core/teams/batch_report.html
Normal file
115
core/templates/core/teams/batch_report.html
Normal 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 %}
|
||||
296
core/templates/core/teams/detail.html
Normal file
296
core/templates/core/teams/detail.html
Normal 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" }} — {{ 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" }} — {{ 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 %}
|
||||
152
core/templates/core/teams/edit.html
Normal file
152
core/templates/core/teams/edit.html
Normal 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 %}
|
||||
131
core/templates/core/teams/list.html
Normal file
131
core/templates/core/teams/list.html
Normal 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 %}
|
||||
19
core/urls.py
19
core/urls.py
@ -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'),
|
||||
|
||||
433
core/views.py
433
core/views.py
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user