Compare commits

...

2 Commits

Author SHA1 Message Date
Konrad du Plessis
5c8508171a Add Teams & Projects management pages
Extends the app with friendly form-based management for Teams and Projects —
an alternative to using Django admin for routine maintenance.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:30:25 +02:00
Konrad du Plessis
0ace7c6786 Phase 1: security fixes + backup/restore tooling + vat_type migrations
Minimal infrastructure push before the bigger feature release (worker/team/
project management UIs, WeasyPrint migration, new models). Deploying this
first gives us a browser-accessible `/backup-data/` endpoint so we can
snapshot production before the bigger change lands.

SECURITY
  - Remove hardcoded Gmail App Password from settings.py (was leaking via
    git history; new password now lives in Flatlogic's `../.env` file)
  - Remove hardcoded SECRET_KEY default; raise ImproperlyConfigured in
    prod if env var missing; dev fallback only when USE_SQLITE is set
  - Flip DEBUG default from 'true' to 'false' so missing env var doesn't
    silently expose tracebacks
  - Remove hardcoded EMAIL_HOST_USER / DEFAULT_FROM_EMAIL defaults
  - Add startup warning when email vars missing in production
  - Fix CSRF_TRUSTED_ORIGINS double-scheme bug (would break with
    pre-prefixed HOST_FQDN env var)

BACKUP / RESTORE
  - New `backup_data` management command — serialises every core + auth
    row to a timestamped JSON file. Gracefully handles models missing at
    older schema versions (WorkerCertificate/Warning imported optionally).
  - New `restore_data` management command — loads JSON back into the DB
    with a populated-DB safety guard and transactional all-or-nothing
    semantics.
  - New `/backup-data/` admin-only URL — downloads the JSON to browser.
  - New `/restore-data/` admin-only URL — upload form with CSRF and
    explicit confirm checkbox before any data is loaded.

MIGRATIONS
  - Add 0007_vat_type_default + 0008_vat_type_default_none (change
    ExpenseReceipt.vat_type default to 'None').
  - Update models.py to match migration 0008's end state.

HOUSEKEEPING
  - Extend .gitignore: .claude/, .vscode/, .idea/, test_*.pdf,
    test_*.json, nul, backups/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:28:21 +02:00
21 changed files with 2603 additions and 17 deletions

17
.gitignore vendored
View File

@ -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/

View File

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

View File

@ -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

View File

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

View File

@ -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}")

View 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}")

View 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),
),
]

View 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),
),
]

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>'
)