Compare commits
2 Commits
3c28387dd3
...
5c8508171a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8508171a | ||
|
|
0ace7c6786 |
17
.gitignore
vendored
17
.gitignore
vendored
@ -6,8 +6,23 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
.DS_Store
|
||||
media/
|
||||
.venv/
|
||||
.venv/
|
||||
|
||||
# Claude Code / IDE
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Dev artifacts — test PDFs, backup files, accidental shell artifacts
|
||||
test_*.pdf
|
||||
test_*.json
|
||||
nul
|
||||
|
||||
# Local backup downloads — these should never be in git
|
||||
backups/
|
||||
|
||||
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)`:
|
||||
|
||||
@ -12,13 +12,40 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
# === DEBUG ===
|
||||
# DEBUG defaults to FALSE — must be explicitly enabled via env var.
|
||||
# Previously defaulted to "true" which exposed full tracebacks and
|
||||
# settings to anyone who hit a 500 error in production.
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
|
||||
|
||||
# === DEV MODE DETECTION ===
|
||||
# Local dev uses SQLite (see run_dev.bat). When USE_SQLITE is set we're
|
||||
# in dev and can relax a few "must be set in prod" checks.
|
||||
_IS_DEV = os.getenv("USE_SQLITE", "").lower() == "true"
|
||||
|
||||
# === SECRET_KEY ===
|
||||
# Must be provided via DJANGO_SECRET_KEY env var in any non-dev deploy.
|
||||
# In dev mode (USE_SQLITE=true) we fall back to a known-insecure key so
|
||||
# local development works out of the box. In prod the absence of the
|
||||
# env var raises a startup error rather than silently using a weak key.
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
|
||||
if not SECRET_KEY:
|
||||
if _IS_DEV or DEBUG:
|
||||
# Dev-only key — NEVER set this value in a production env var.
|
||||
SECRET_KEY = "dev-only-insecure-key-do-not-use-in-production"
|
||||
else:
|
||||
raise ImproperlyConfigured(
|
||||
"DJANGO_SECRET_KEY environment variable is not set. "
|
||||
"Set it in the deploy platform's environment variables (or .env file). "
|
||||
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
|
||||
"to generate a new one."
|
||||
)
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
@ -27,17 +54,29 @@ ALLOWED_HOSTS = [
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
]
|
||||
|
||||
# === CSRF TRUSTED ORIGINS ===
|
||||
# Build the list, then normalise each entry to have an https:// prefix.
|
||||
# Guard against the double-prefix bug: if the user sets HOST_FQDN to
|
||||
# "https://example.com" (with a scheme), the raw f-string would produce
|
||||
# "https://https://example.com" which Django rejects.
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin for origin in [
|
||||
"foxlog.flatlogic.app",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", ""),
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
|
||||
def _normalize_origin(host):
|
||||
"""Ensure `host` has an http:// or https:// scheme; default to https."""
|
||||
host = host.strip()
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host
|
||||
return f"https://{host}"
|
||||
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [_normalize_origin(h) for h in CSRF_TRUSTED_ORIGINS]
|
||||
|
||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
@ -160,29 +199,52 @@ MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# === EMAIL CONFIGURATION ===
|
||||
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
|
||||
# The App Password is a 16-character code from Google Account settings —
|
||||
# it lets the app send email through Gmail without your actual password.
|
||||
# NO FALLBACKS for credentials — they MUST come from environment variables
|
||||
# (the Flatlogic .env file at `../.env`). Previous versions had the Gmail
|
||||
# App Password committed in source as a fallback default, which is a
|
||||
# critical security leak via git history. In local dev (USE_SQLITE=true)
|
||||
# empty credentials are fine; email sends will just fail with an auth
|
||||
# error — which is what you want locally.
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set via .env
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set via .env
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "")
|
||||
CONTACT_EMAIL_TO = [
|
||||
item.strip()
|
||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting import
|
||||
# Spark Receipt Email — payslip and receipt PDFs routed here for accounting import.
|
||||
# This is a routing address, not a secret, so a default is acceptable.
|
||||
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
|
||||
|
||||
# Fail loudly in production if critical email vars are missing — catches the
|
||||
# "I forgot to set env vars on the new deploy platform" mistake before a user
|
||||
# triggers a payroll payment and the email silently fails.
|
||||
if not DEBUG and not _IS_DEV:
|
||||
_missing_email_vars = [
|
||||
name for name, val in [
|
||||
("EMAIL_HOST_USER", EMAIL_HOST_USER),
|
||||
("EMAIL_HOST_PASSWORD", EMAIL_HOST_PASSWORD),
|
||||
("DEFAULT_FROM_EMAIL", DEFAULT_FROM_EMAIL),
|
||||
] if not val
|
||||
]
|
||||
if _missing_email_vars:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Email configuration incomplete in production. Missing env vars: %s. "
|
||||
"Payslip and receipt emails will fail to send until these are set.",
|
||||
", ".join(_missing_email_vars),
|
||||
)
|
||||
|
||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||
if EMAIL_USE_SSL:
|
||||
EMAIL_USE_TLS = False
|
||||
|
||||
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
|
||||
|
||||
141
core/management/commands/backup_data.py
Normal file
141
core/management/commands/backup_data.py
Normal file
@ -0,0 +1,141 @@
|
||||
# === BACKUP DATA MANAGEMENT COMMAND ===
|
||||
# Exports every row of every core model to a single JSON file that can
|
||||
# be restored later via `python manage.py restore_data <file.json>`.
|
||||
#
|
||||
# WHY THIS EXISTS:
|
||||
# Flatlogic doesn't expose MySQL directly — no mysqldump, no SSH, no
|
||||
# DB console. Django's built-in `dumpdata` / `loaddata` give us a
|
||||
# platform-independent backup format that travels with the code.
|
||||
#
|
||||
# WHY NOT JUST USE `dumpdata`?
|
||||
# This command is a thin wrapper around dumpdata that:
|
||||
# - Pins the exact set of app+model rows we want to back up
|
||||
# - Writes to a timestamped file so you never overwrite a backup
|
||||
# - Includes Users + Groups + auth content types (so permissions
|
||||
# restore correctly too)
|
||||
# - Prints a row-count summary so you can confirm it worked
|
||||
#
|
||||
# USAGE (local):
|
||||
# python manage.py backup_data → backups/foxlog_YYYYMMDD_HHMMSS.json
|
||||
# python manage.py backup_data --output=my.json → my.json
|
||||
#
|
||||
# USAGE (Flatlogic, via browser):
|
||||
# Visit /backup-data/ as admin — downloads the backup file to your browser.
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core import serializers
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from core.models import (
|
||||
UserProfile, Project, Worker, Team, WorkLog,
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem,
|
||||
)
|
||||
|
||||
# WorkerCertificate and WorkerWarning were added in a later migration.
|
||||
# Import them optionally so this backup command works during a multi-phase
|
||||
# deploy where the backup tool ships before those models do.
|
||||
try:
|
||||
from core.models import WorkerCertificate, WorkerWarning
|
||||
_HAS_WORKER_CERTS_WARNINGS = True
|
||||
except ImportError:
|
||||
_HAS_WORKER_CERTS_WARNINGS = False
|
||||
|
||||
|
||||
# === BACKUP SCOPE ===
|
||||
# The exact list of models we back up. Order matters for restore —
|
||||
# we list models in dependency order (no FK should point at something
|
||||
# that comes later in the list). Django's loaddata handles this
|
||||
# correctly regardless, but keeping it sorted helps humans read it.
|
||||
MODELS_TO_BACKUP = [
|
||||
# Auth fundamentals — restore these first so FKs from UserProfile
|
||||
# etc. find their user rows.
|
||||
ContentType,
|
||||
Permission,
|
||||
Group,
|
||||
User,
|
||||
# Core app
|
||||
UserProfile,
|
||||
Project,
|
||||
Worker,
|
||||
Team,
|
||||
WorkLog,
|
||||
PayrollRecord,
|
||||
Loan,
|
||||
PayrollAdjustment,
|
||||
ExpenseReceipt,
|
||||
ExpenseLineItem,
|
||||
]
|
||||
# Append the cert/warning models only if they're available in this deploy
|
||||
if _HAS_WORKER_CERTS_WARNINGS:
|
||||
MODELS_TO_BACKUP.extend([WorkerCertificate, WorkerWarning])
|
||||
|
||||
|
||||
def build_backup_payload():
|
||||
"""Return (json_str, summary_dict) for the current DB state.
|
||||
|
||||
Separated from the Command class so the browser view can reuse it
|
||||
to stream the backup to the user's browser.
|
||||
"""
|
||||
# Pull every row of every model we care about, serialise as JSON.
|
||||
# serializers.serialize("json", queryset) returns a JSON string.
|
||||
# We concatenate by building one big list first, then dumping once.
|
||||
all_rows = []
|
||||
summary = {}
|
||||
for model in MODELS_TO_BACKUP:
|
||||
qs = list(model.objects.all())
|
||||
summary[f"{model._meta.app_label}.{model._meta.model_name}"] = len(qs)
|
||||
# Use the built-in Django serializer for proper natural-key support
|
||||
serialized = serializers.serialize("python", qs)
|
||||
all_rows.extend(serialized)
|
||||
|
||||
payload = {
|
||||
"version": 1,
|
||||
"exported_at": datetime.datetime.now().isoformat(),
|
||||
"row_counts": summary,
|
||||
"data": all_rows,
|
||||
}
|
||||
return json.dumps(payload, indent=2, default=str), summary
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Export every core-app row to a JSON file for backup/restore. "
|
||||
"Writes to backups/foxlog_<timestamp>.json unless --output is given."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output filepath. Default: backups/foxlog_<timestamp>.json",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
json_str, summary = build_backup_payload()
|
||||
|
||||
# Default path: ./backups/foxlog_<timestamp>.json
|
||||
if options["output"]:
|
||||
output_path = Path(options["output"])
|
||||
else:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = Path("backups") / f"foxlog_{ts}.json"
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json_str, encoding="utf-8")
|
||||
|
||||
# Print a summary so you can verify at a glance
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Backup written to: {output_path}"
|
||||
))
|
||||
self.stdout.write(f"File size: {output_path.stat().st_size:,} bytes")
|
||||
self.stdout.write("Row counts by model:")
|
||||
for model_name, count in sorted(summary.items()):
|
||||
self.stdout.write(f" {model_name:<40} {count:>6}")
|
||||
141
core/management/commands/restore_data.py
Normal file
141
core/management/commands/restore_data.py
Normal file
@ -0,0 +1,141 @@
|
||||
# === RESTORE DATA MANAGEMENT COMMAND ===
|
||||
# Restores a backup produced by `backup_data` — takes a JSON file and
|
||||
# loads every row into the database.
|
||||
#
|
||||
# SAFETY:
|
||||
# By default this command REFUSES to run against a non-empty database
|
||||
# (prevents accidentally overwriting live data). Pass --force to
|
||||
# bypass — but only when you know the target is empty or already
|
||||
# matches the backup.
|
||||
#
|
||||
# USAGE (local):
|
||||
# python manage.py restore_data backups/foxlog_20260421_120000.json
|
||||
# python manage.py restore_data backup.json --force (overwrite existing)
|
||||
#
|
||||
# USAGE (Flatlogic, via browser):
|
||||
# Upload a .json backup file via /restore-data/ (admin only).
|
||||
#
|
||||
# BEHAVIOUR:
|
||||
# Uses Django's built-in `loaddata` under the hood, which:
|
||||
# - Updates existing rows if their pk matches (no duplicates)
|
||||
# - Creates new rows for any pk not yet in the DB
|
||||
# - Respects FK/M2M dependencies
|
||||
# - Runs inside a transaction — if any row fails, nothing is saved
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management import call_command
|
||||
from django.db import transaction
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from core.models import Worker, WorkLog, PayrollRecord
|
||||
|
||||
|
||||
def check_database_is_populated():
|
||||
"""Return True if the database already has meaningful data.
|
||||
|
||||
Used as a guardrail: by default we refuse to restore into a DB that
|
||||
already contains workers, work logs, or payroll records, because
|
||||
that could double-insert and corrupt the state.
|
||||
"""
|
||||
has_workers = Worker.objects.exists()
|
||||
has_logs = WorkLog.objects.exists()
|
||||
has_payments = PayrollRecord.objects.exists()
|
||||
return has_workers or has_logs or has_payments
|
||||
|
||||
|
||||
def restore_from_json_string(json_str):
|
||||
"""Load a JSON backup string into the database.
|
||||
|
||||
Returns (success, message_or_summary). Used both by this management
|
||||
command and by the browser-accessible `/restore-data/` view so the
|
||||
same logic runs in both places.
|
||||
|
||||
Raises no exceptions — returns (False, error_message) on failure so
|
||||
the caller (CLI or web view) can format the error appropriately.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"File is not valid JSON: {e}"
|
||||
|
||||
# Backups produced by `backup_data` wrap rows in a top-level dict.
|
||||
# Raw dumpdata output is a bare list — support both for flexibility.
|
||||
if isinstance(payload, dict) and "data" in payload:
|
||||
rows = payload["data"]
|
||||
elif isinstance(payload, list):
|
||||
rows = payload
|
||||
else:
|
||||
return False, "Unexpected JSON structure — expected dict with 'data' key or a list."
|
||||
|
||||
if not rows:
|
||||
return False, "Backup file contains no rows."
|
||||
|
||||
# Write the rows to a tmp file then let Django's loaddata do the work
|
||||
# (it handles FK order, transaction wrapping, and natural keys).
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False,
|
||||
encoding="utf-8") as tmp:
|
||||
# loaddata expects the bare list format
|
||||
json.dump(rows, tmp, default=str)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
call_command("loaddata", tmp_path, verbosity=0)
|
||||
except Exception as e:
|
||||
return False, f"Restore failed: {e}"
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass # cleanup best-effort
|
||||
|
||||
# Build a summary for the caller to display
|
||||
summary = {
|
||||
"users": User.objects.count(),
|
||||
"workers": Worker.objects.count(),
|
||||
"work_logs": WorkLog.objects.count(),
|
||||
"payroll_records": PayrollRecord.objects.count(),
|
||||
"rows_in_backup": len(rows),
|
||||
}
|
||||
return True, summary
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Restore a JSON backup produced by `backup_data`."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("backup_file", type=str, help="Path to a .json backup file")
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Allow restore even if the target database already has data",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_path = Path(options["backup_file"])
|
||||
if not backup_path.exists():
|
||||
raise CommandError(f"Backup file not found: {backup_path}")
|
||||
|
||||
if not options["force"] and check_database_is_populated():
|
||||
raise CommandError(
|
||||
"Database already contains data (workers/logs/payments). "
|
||||
"Restoring now could duplicate or corrupt rows.\n"
|
||||
"If you really want to proceed, run again with --force.\n"
|
||||
"Or flush first: python manage.py flush (irreversible)."
|
||||
)
|
||||
|
||||
json_str = backup_path.read_text(encoding="utf-8")
|
||||
ok, result = restore_from_json_string(json_str)
|
||||
|
||||
if not ok:
|
||||
raise CommandError(result)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Restore complete."))
|
||||
self.stdout.write("Rows in database after restore:")
|
||||
for k, v in result.items():
|
||||
self.stdout.write(f" {k}: {v}")
|
||||
18
core/migrations/0007_vat_type_default.py
Normal file
18
core/migrations/0007_vat_type_default.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-20 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_worker_drivers_license_worker_has_drivers_license_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='expensereceipt',
|
||||
name='vat_type',
|
||||
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='Included', max_length=20),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0008_vat_type_default_none.py
Normal file
18
core/migrations/0008_vat_type_default_none.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-20 19:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_vat_type_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='expensereceipt',
|
||||
name='vat_type',
|
||||
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='None', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -195,7 +195,7 @@ class ExpenseReceipt(models.Model):
|
||||
vendor_name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES)
|
||||
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES)
|
||||
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None')
|
||||
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
|
||||
@ -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 %}
|
||||
26
core/urls.py
26
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'),
|
||||
@ -70,4 +89,11 @@ urlpatterns = [
|
||||
# === TEMPORARY: Run migrations from browser ===
|
||||
# Visit /run-migrate/ to apply pending database migrations on production.
|
||||
path('run-migrate/', views.run_migrate, name='run_migrate'),
|
||||
|
||||
# === BACKUP / RESTORE (admin-only, browser-accessible) ===
|
||||
# Flatlogic has no SSH/shell — admins use these to snapshot and
|
||||
# restore all app data via the browser. See CLAUDE.md "Backup &
|
||||
# Restore" section for the full procedure.
|
||||
path('backup-data/', views.backup_data, name='backup_data'),
|
||||
path('restore-data/', views.restore_data, name='restore_data'),
|
||||
]
|
||||
|
||||
550
core/views.py
550
core/views.py
@ -16,13 +16,15 @@ from django.db.models.functions import TruncMonth
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
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.
|
||||
@ -2633,3 +2635,549 @@ def run_migrate(request):
|
||||
'</body></html>',
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 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
|
||||
# and render a minimal HTML UI. Safe to leave in place in production.
|
||||
|
||||
@login_required
|
||||
def backup_data(request):
|
||||
"""Download the complete app data as a timestamped JSON file.
|
||||
|
||||
Admin-only. Serves the backup as a browser download so it lands
|
||||
safely on the admin's laptop rather than the server filesystem
|
||||
(which is ephemeral on Flatlogic).
|
||||
"""
|
||||
if not is_admin(request.user):
|
||||
return HttpResponseForbidden("Admin access required.")
|
||||
|
||||
from core.management.commands.backup_data import build_backup_payload
|
||||
import datetime as _dt
|
||||
|
||||
json_str, summary = build_backup_payload()
|
||||
filename = f'foxlog_backup_{_dt.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
|
||||
response = HttpResponse(json_str, content_type='application/json')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def restore_data(request):
|
||||
"""Upload a .json backup to restore it into the current database.
|
||||
|
||||
GET → renders a minimal upload form + warning
|
||||
POST → accepts the file, validates, and loads it (inside a transaction)
|
||||
|
||||
Admin-only. Requires explicit `confirm=yes` POST field to proceed,
|
||||
so a stray click can't wipe production.
|
||||
"""
|
||||
if not is_admin(request.user):
|
||||
return HttpResponseForbidden("Admin access required.")
|
||||
|
||||
from core.management.commands.restore_data import (
|
||||
check_database_is_populated, restore_from_json_string,
|
||||
)
|
||||
|
||||
db_has_data = check_database_is_populated()
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('confirm') != 'yes':
|
||||
return HttpResponse(
|
||||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||
'<h2>Restore cancelled</h2>'
|
||||
'<p>You must tick the "Yes, I understand" checkbox to proceed.</p>'
|
||||
'<a href="/restore-data/">Back</a></body></html>',
|
||||
status=400,
|
||||
)
|
||||
|
||||
uploaded = request.FILES.get('backup_file')
|
||||
if not uploaded:
|
||||
return HttpResponse(
|
||||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||
'<h2>No file uploaded</h2>'
|
||||
'<a href="/restore-data/">Back</a></body></html>',
|
||||
status=400,
|
||||
)
|
||||
|
||||
json_str = uploaded.read().decode('utf-8', errors='replace')
|
||||
ok, result = restore_from_json_string(json_str)
|
||||
|
||||
if not ok:
|
||||
return HttpResponse(
|
||||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||
'<h2>Restore failed</h2>'
|
||||
f'<pre>{result}</pre>'
|
||||
'<a href="/restore-data/">Back</a></body></html>',
|
||||
status=500,
|
||||
)
|
||||
|
||||
rows_html = '<br>'.join(f'{k}: {v}' for k, v in result.items())
|
||||
return HttpResponse(
|
||||
'<html><body style="font-family: monospace; padding: 20px;">'
|
||||
'<h2 style="color: #10b981;">Restore complete!</h2>'
|
||||
f'<div>{rows_html}</div><br><br>'
|
||||
'<a href="/">Go to Dashboard</a></body></html>'
|
||||
)
|
||||
|
||||
# GET — render the upload form
|
||||
warning_html = ''
|
||||
if db_has_data:
|
||||
warning_html = (
|
||||
'<p style="color: #e8851a; border-left: 3px solid #e8851a; padding-left: 10px;">'
|
||||
'<strong>Warning:</strong> this database already contains data '
|
||||
'(workers / work logs / payroll records). Restoring will UPDATE existing rows '
|
||||
'by primary key and INSERT missing ones. This will NOT delete data that exists '
|
||||
'in the DB but not in the backup. If you want a clean restore, run '
|
||||
'<code>python manage.py flush</code> first (irreversible).'
|
||||
'</p>'
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
'<html><body style="font-family: monospace; padding: 20px; max-width: 700px;">'
|
||||
'<h2>Restore from backup</h2>'
|
||||
+ warning_html +
|
||||
'<form method="post" enctype="multipart/form-data">'
|
||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||
'<p><label>Backup JSON file:<br>'
|
||||
'<input type="file" name="backup_file" accept="application/json" required></label></p>'
|
||||
'<p><label><input type="checkbox" name="confirm" value="yes" required> '
|
||||
'Yes, I understand this will overwrite matching rows in the database.</label></p>'
|
||||
'<p><button type="submit" style="padding: 10px 20px; background: #e8851a; '
|
||||
'color: white; border: none; border-radius: 4px; cursor: pointer;">'
|
||||
'Restore</button>'
|
||||
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
|
||||
'</form></body></html>'
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user