WIP: 2026-04-22 session checkpoint

Complete working state of the session. Will be split into two deploy
phases (safety scaffolding then feature release) before merging to ai-dev.

Includes:
- Security fixes (email creds / SECRET_KEY / DEBUG / CSRF)
- Backup + restore management commands and browser endpoints
- WeasyPrint migration (replaces xhtml2pdf)
- New Worker fields + WorkerCertificate + WorkerWarning models
- Worker / Team / Project friendly management UIs
- Dashboard cert-expiry card + Manage All buttons
- Bootstrap tooltips (global init + theme-aware CSS)
- Django admin template override (taller M2M pickers)
- Money filter for ZAR currency formatting
- Resources dropdown nav
- Massive CLAUDE.md expansion + deploy plan docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 00:19:15 +02:00
parent deef851e52
commit 3c28387dd3
44 changed files with 8360 additions and 436 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/

375
CLAUDE.md
View File

@ -14,7 +14,7 @@ This is v5 — a fresh export from Flatlogic/AppWizzy, rebuilt from the v2 codeb
## Tech Stack
- Django 5.2.7, Python 3.13, MySQL (production on Flatlogic Cloud Run) / SQLite (local dev)
- Bootstrap 5.3.3 (CDN), Font Awesome 6.5.1 (CDN), Google Fonts (Inter + Poppins)
- xhtml2pdf for PDF generation (payslips, receipts)
- WeasyPrint for PDF generation (payroll report, payslips, receipts) — migrated from xhtml2pdf; browser-grade HTML/CSS rendering with flexbox, grid, @font-face, shadows, and proper CSS cascade
- Gmail SMTP for automated document delivery
- Hosted on Flatlogic/AppWizzy platform (Apache on Debian, e2-micro VM)
@ -25,12 +25,24 @@ core/ — Single main app: ALL business logic, models, views, forms,
context_processors.py — Injects deployment_timestamp (cache-busting), Flatlogic branding vars
forms.py — AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm + formset
models.py — All 10 database models
utils.py — render_to_pdf() helper (lazy xhtml2pdf import)
views.py — All 28 functions (~2635 lines, includes helpers)
utils.py — render_to_pdf() helper (lazy WeasyPrint import + Windows GTK3 DLL registration)
views.py — All view functions (~52 functions, ~3,800 lines) — dashboard, attendance, payroll, reports, worker/team/project CRUD
forms.py — All form classes + validators (WorkerForm, TeamForm, ProjectForm, AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, WorkerCertificate/WarningFormSet, 5MB file validator)
admin.py — Django admin registrations for all core models + WorkerCertificate/Warning inlines on Worker
templatetags/ — format_tags.py (money filter for ZAR formatting)
management/commands/ — setup_groups, setup_test_data, import_production_data
templates/ — base.html + 7 page templates + 2 email + 2 PDF + login
templates/
base.html — App shell (topbar + mobile menu + bottom tab bar)
core/ — Page templates: index, attendance_log, work_history, payroll_dashboard,
report, create_receipt, payslip, login, _report_config_modal (partial)
core/workers/ — 4 templates: list, detail, edit, batch_report
core/teams/ — 4 templates: list, detail, edit, batch_report
core/projects/— 4 templates: list, detail, edit, batch_report
core/pdf/ — 4 PDF templates: report_pdf, payslip_pdf, receipt_pdf, workers_report_pdf
core/email/ — 2 HTML email templates
admin/ — base_site.html override (adds admin CSS tweaks, e.g. taller M2M pickers)
ai/ — Flatlogic AI proxy client (not used in app logic)
static/css/ — custom.css (CSS variables, component styles)
static/css/ — custom.css (CSS variables, component styles, tooltip overrides)
staticfiles/ — Collected static assets (Bootstrap, admin)
```
@ -44,6 +56,8 @@ staticfiles/ — Collected static assets (Bootstrap, admin)
- **PayrollAdjustment** — bonuses, deductions, overtime, loans; linked to project (FK, optional), worker, and optionally to a Loan or WorkLog
- **Loan** — worker loans AND advances with principal, remaining_balance, `loan_type` ('loan' or 'advance')
- **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
## Key Business Rules
- All business logic lives in the `core/` app — do not create additional Django apps
@ -109,6 +123,10 @@ python manage.py check # System check
- Pending Payments Table: Shows overdue badges (red) for workers with unpaid work from completed pay periods, and loan badges (yellow) for workers with active loans/advances. Filter bar has: team dropdown, "Overdue only" checkbox, and loan dropdown (All Workers / With loans only / Without loans). Overdue detection uses `get_pay_period()` cutoff logic.
- Quick Adjust Button: Each pending payments row has an "Adjust" button (slider icon) that opens the Add Adjustment modal with that worker pre-checked and their most recent project pre-selected. The header "Add Adjustment" button resets the modal to a clean state. Uses `_quickAdjustOpen` flag to distinguish between the two open paths.
- Worker Lookup Modal: Clicking any worker name on the payroll dashboard (or using the "Worker Lookup" button) opens a modal with a comprehensive report card — amount payable, outstanding loans, paid this month/year, loans this year, recent activity (last payslip, loan, repayment, advance), active loans table, current project + days on project, PPE sizing, drivers license, and notes. Uses `worker_lookup_ajax` AJAX endpoint. Worker dropdown in modal allows switching workers without closing.
- Team & Project Management UIs: Friendlier alternatives to `/admin/core/team/` and `/admin/core/project/`. Reachable via the "Resources" dropdown in the topbar (admin only). **Team pages**: `/teams/` (list + search/filter), `/teams/<id>/` (detail with Profile/Pay Schedule/Workers/History tabs — Pay Schedule tab uses the existing `get_pay_period()` helper to show current + next 2 periods), `/teams/<id>/edit/` (single-page form for name, supervisor, pay schedule, and workers M2M). **Project pages**: `/projects/`, `/projects/<id>/` (tabs: Profile/Supervisors/Teams/Workers/History), `/projects/<id>/edit/` (form for name, description, dates, supervisors M2M). Uses `TeamForm` and `ProjectForm` from `core/forms.py` (both simple ModelForms, no inline formsets). Batch reports at `/teams/report/` and `/projects/report/` with CSV exports; PDF exports deferred as a follow-up. Dashboard "Manage Resources" card now has "Manage All Workers/Projects/Teams" footer links on each tab. Django admin remains fully functional as a fallback.
- Worker Management UI: A friendlier alternative to `/admin/core/worker/`. Reachable via the "Resources" topbar dropdown → Workers (admin-only). Pages: `/workers/` (list with search + status filter), `/workers/<id>/` (detail with Profile/Certifications/Warnings/History tabs), `/workers/<id>/edit/` or `/workers/new/` (single-page form with sections for Personal & Pay, PPE, Documents, Driver's License, plus inline formsets for certifications and warnings). Uses `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` from `core/forms.py`. The "+ Add Certification" / "+ Add Warning" buttons clone a `<template>` element via `content.cloneNode()` (DOM-safe, no innerHTML) and rewrite `__PREFIX__` in input names to the next formset index. File uploads validated at 5 MB max via `validate_max_5mb()` in `forms.py`. Django admin (`/admin/core/worker/`) remains fully functional as a fallback — both UIs coexist.
- Worker Batch Report: `/workers/report/` shows every worker with aggregated lifetime history — days worked, projects worked on, teams, first/last payslip dates, total paid, cert status (active/total + expired/expiring counts), warning count. Filter by status, project, team. CSV export via `/workers/report/csv/`, PDF via `/workers/report/pdf/` (landscape A4, same amber-accent typography as the payroll report). Built on the reusable `_build_worker_report_context()` helper which uses `annotate(Min/Max/Count/Sum)` + prefetch for efficient aggregation.
- Dashboard cert-expiry card: The admin dashboard shows a "Certifications Need Attention" stat card with count of expired + expiring-within-30-days certs (active workers only). Card is CONDITIONAL — renders only when count > 0, so it disappears when everything is in good standing. Clicking it goes to the worker batch report. Counts come from `index()` view adding `certs_expired_count`, `certs_expiring_count`, `certs_alert_total` to context.
## URL Routes
| Path | View | Purpose |
@ -118,6 +136,25 @@ python manage.py check # System check
| `/history/` | `work_history` | Work logs table with filters |
| `/history/export/` | `export_work_log_csv` | Download filtered logs as CSV |
| `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV |
| `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter |
| `/workers/new/` | `worker_edit` | Admin: blank worker-create form |
| `/workers/<id>/` | `worker_detail` | Admin: worker profile with profile/certs/warnings/history tabs |
| `/workers/<id>/edit/` | `worker_edit` | Admin: edit worker + inline cert/warning formsets |
| `/workers/report/` | `worker_batch_report` | Admin: aggregated roster report (days, projects, payslips, certs) |
| `/workers/report/csv/` | `worker_batch_report_csv` | Admin: batch report as CSV download |
| `/workers/report/pdf/` | `worker_batch_report_pdf` | Admin: batch report as PDF download |
| `/teams/` | `team_list` | Admin: friendly team list with search + status filter |
| `/teams/new/` | `team_edit` | Admin: blank team-create form |
| `/teams/<id>/` | `team_detail` | Admin: team profile with profile/pay schedule/workers/history tabs |
| `/teams/<id>/edit/` | `team_edit` | Admin: edit team (name, supervisor, pay schedule, workers M2M) |
| `/teams/report/` | `team_batch_report` | Admin: aggregated team report (HTML) |
| `/teams/report/csv/` | `team_batch_report_csv` | Admin: team batch report as CSV download |
| `/projects/` | `project_list` | Admin: friendly project list with search + status filter |
| `/projects/new/` | `project_edit` | Admin: blank project-create form |
| `/projects/<id>/` | `project_detail` | Admin: project profile with profile/supervisors/teams/workers/history tabs |
| `/projects/<id>/edit/` | `project_edit` | Admin: edit project (name, description, dates, supervisors M2M) |
| `/projects/report/` | `project_batch_report` | Admin: aggregated project report (HTML) |
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: project batch report as CSV download |
| `/toggle/<model>/<id>/` | `toggle_active` | Admin: AJAX toggle active status |
| `/payroll/` | `payroll_dashboard` | Admin: pending payments, loans, charts |
| `/payroll/pay/<worker_id>/` | `process_payment` | Admin: process payment (atomic) |
@ -136,27 +173,333 @@ python manage.py check # System check
| `/run-migrate/` | `run_migrate` | Setup: run pending DB migrations from browser |
## Frontend Design Conventions
- **Dual-theme** (dark + light) driven by a single CSS variable set in `static/css/custom.css`.
The theme is dark-first; the light theme is a set of var overrides inside a `:root.light` block.
A sun/moon toggle in the topbar flips a class on `<html>` and persists the choice to localStorage.
- **CSS variables** in `static/css/custom.css` `:root` — always use `var(--name)`:
- `--primary-dark: #0f172a` (navbar), `--primary: #1e293b` (headers), `--accent: #10b981` (brand green)
- `--text-main: #334155`, `--text-secondary: #64748b`, `--background: #f1f5f9`
- `--accent: #e8851a` (warm orange/amber, brand), `--accent-hover: #f59e0b`
- `--primary-dark: #0f172a`, `--primary: #1e293b`
- `--bg-card: #161921`, `--bg-card-hover: #1c2029` (elevated surfaces)
- `--text-primary: #d8d8d8` (dark theme), `--text-secondary`, `--text-tertiary`
- Light-theme overrides flip backgrounds to white/grey and accent to `#d97706`
- **Icons**: Font Awesome 6 only (`fas fa-*`). Do NOT use Bootstrap Icons (`bi bi-*`)
- **CTA buttons**: `btn-accent` (green) for primary actions. `btn-primary` (dark slate) for modal Save/Submit
- **Page titles**: `{% block title %}Page Name | Fox Fitt{% endblock %}`
- **CTA buttons**: `btn-accent` (orange) for primary actions. `btn-primary` (dark slate) for modal Save/Submit
- **Page titles**: `{% block title %}Page Name | FoxFitt{% endblock %}`
- **Fonts**: Inter (body) + Poppins (headings) loaded in base.html via Google Fonts CDN
- **Cards**: Borderless with `box-shadow: 0 4px 6px rgba(0,0,0,0.1)`. Stat cards use `backdrop-filter: blur`
- **Cards**: Borderless with subtle shadow. Stat cards have coloured accent bars on the left.
- **Bootstrap tooltips**: Global init in `base.html` — any element with
`data-bs-toggle="tooltip" title="..."` gets a tooltip automatically. Tooltips are themed
via custom `--bs-tooltip-bg`/`--bs-tooltip-color` overrides in custom.css so they're
readable in both light and dark modes (otherwise Bootstrap's default picks the wrong
pair of body vars for dark mode).
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
## Permission Groups
Created by `setup_groups` management command:
- **Admin** — full CRUD on all core models
- **Work Logger** — add/change/view WorkLog; view-only on Project/Worker/Team
## PDF Generation (WeasyPrint)
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, `@font-face`, shadows, `border-radius`, proper cascade) that xhtml2pdf could not handle.
### Files
- `core/utils.py``render_to_pdf(template_src, context_dict)` is the single entry point; lazy-imports WeasyPrint, returns PDF bytes or `None` on failure
- `core/templates/core/pdf/report_pdf.html` — payroll report (complex layout, 600+ lines)
- `core/templates/core/pdf/payslip_pdf.html` — payslip (emailed to Spark Receipt after each payment)
- `core/templates/core/pdf/receipt_pdf.html` — expense receipt (emailed to Spark Receipt after each expense entry)
- `core/templatetags/format_tags.py::money` — South African space-separated currency formatting (`R 64 939.00`); use this instead of `floatformat:2`
### Callers
- `generate_report_pdf()` — downloads the report PDF to the browser
- `_send_payslip_email()` — attaches payslip PDF to Gmail SMTP email (called by `process_payment`, `add_adjustment` advance path, `batch_pay`)
- `create_receipt()` — attaches receipt PDF to Gmail SMTP email
- All three use the same `EmailMultiAlternatives.attach(filename, pdf_bytes, "application/pdf")` pattern — engine-agnostic
### Dependencies
- **Python package**: `weasyprint==68.1` (pinned in `requirements.txt`)
- **System libraries** (Pango, Cairo, GDK-PixBuf, FFI, shared-mime-info):
- **Flatlogic/production (Debian)**: already installed on the platform image — confirmed via Flatlogic's Gemini
- **Windows local dev**: install the GTK3 runtime via `winget install -e --id tschoonj.GTKForWindows --accept-package-agreements --accept-source-agreements --silent` (installs `C:\Program Files\GTK3-Runtime Win64\`)
- **macOS local dev**: `brew install pango` (not currently used but documented for completeness)
### Windows DLL resolution quirk
Since Python 3.8, native DLLs are *not* loaded from PATH automatically — an explicit `os.add_dll_directory()` call is required. The `_ensure_gtk_on_windows()` helper in `core/utils.py` handles this automatically: it checks common GTK3 install paths on module load and registers the first one found. No-op on Linux/macOS.
If the report page returns "PDF generation failed", check the Django log for the underlying error:
- `cannot load library 'gobject-2.0-0'` → GTK3 runtime not installed (run the winget command above)
- `'super' object has no attribute 'transform'``weasyprint`/`pydyf` version mismatch; reinstall with `pip install --upgrade weasyprint==68.1`
### Template conventions
- **Modern CSS is fine** — flexbox (`display: flex`), grid (`display: grid; gap: 20pt`), `@font-face`, `box-shadow`, `border-radius` all render correctly
- **Fonts**: WeasyPrint can load web fonts. If we ever add `@font-face` blocks pointing to `static/fonts/Inter-*.ttf` and `Poppins-*.ttf`, the PDFs can use the same typography as the web app (currently the PDFs use Helvetica by default — upgrading to Inter/Poppins is optional follow-up work)
- **Page setup**: `@page { size: a4 portrait; margin: 2cm 1.8cm 1.6cm 1.8cm; }` — standard A4 with generous margins
- **`base_url`**: `render_to_pdf()` passes `settings.STATIC_ROOT or "."` as `base_url` so relative paths in `<img src="...">` and `@font-face src: url(...)` resolve against the collected static dir
### Known lint footguns (legacy from xhtml2pdf era)
- `report_pdf.html` still uses invisible `<table class="cols">` elements for two-column layout — these work fine under WeasyPrint but could be simplified to `display: grid; grid-template-columns: 1fr 1fr; gap: 20pt` as a future cleanup pass
- The `period-detail td { padding-top: 3pt; padding-bottom: 3pt; }` split-padding workaround (from a shorthand-collision bug with xhtml2pdf) is no longer needed; safe to replace with the `padding` shorthand when cleaning up the template
## Users, Roles & Permissions
Understanding who-can-do-what in this app requires grasping **three separate layers**
of Django auth that stack on top of each other:
### Layer 1 — Django's three built-in user flags
These live on the `auth.User` model and are the foundation. Every user has exactly one
combination of these three flags:
| Flag | What it means | Who should have it |
|---|---|---|
| `is_superuser=True` | **Bypasses every permission check.** Full access to everything everywhere, including Django admin. Created by `createsuperuser`. | Konrad (the owner), and one emergency-access account. That's it. |
| `is_staff=True` | Can log into `/admin/` (the built-in Django admin interface) and sees it. Does NOT grant any model permissions by itself — those come from groups or per-user permissions. | Konrad, and any "office admin" people who need full access to edit data via Django admin. Usually combined with `is_superuser` in this app. |
| (neither) | Regular user. Can log into the friendly app at `/` but cannot enter Django admin. Sees only what they've been explicitly given access to via group membership or supervisor assignments. | Site supervisors (Work Loggers), and any future non-admin roles. |
Key mental model: **`is_superuser` beats everything**. A superuser's permission groups
and assignments don't matter — they always see everything. Use superuser sparingly
so that regular permission paths get exercised and tested.
### Layer 2 — The two app-specific permission groups
Created by `python manage.py setup_groups` (a one-time command, safe to re-run; it
updates existing groups rather than duplicating them). Assignment happens in
`/admin/auth/group/` → add users to groups:
**Admin group** — grants every Django model permission (add/change/delete/view) on
all 10 core models (Worker, Project, Team, WorkLog, PayrollRecord, Loan,
PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, and the new WorkerCertificate/
WorkerWarning via separate admin registration).
- **Practical effect**: only matters for `is_staff=True` users who are NOT
superusers. For them, the Admin group is what lets them actually use Django
admin (without it, they can log into /admin/ but see empty lists).
- For superusers, the Admin group is redundant (they bypass permissions anyway).
- For non-staff users, the Admin group is pointless (they can't reach Django
admin at all).
**Work Logger group** — grants: add/change/view `WorkLog`; view-only on
`Project`, `Worker`, `Team`. Notably does NOT grant any Payroll permissions.
- **Practical effect**: this group is the app's signal for "this user is a site
supervisor". The `is_supervisor()` helper in `views.py` explicitly checks for
membership in this group — so adding someone here marks them as a supervisor,
even if they don't own any teams or projects yet.
- Work Loggers typically have `is_staff=False` (no Django admin access). They
use the friendly app UI at `/attendance/log/`, `/history/`, and the dashboard.
### Layer 3 — Implicit supervisor roles via model relationships
Two model fields independently grant "supervisor-ness" even without group membership:
- `Team.supervisor` (ForeignKey → User) — whoever this points to is a supervisor
of that team. Set on the Team edit page or `/admin/core/team/`.
- `Project.supervisors` (ManyToManyField → User) — every user in this M2M is a
supervisor of the project. Set on the Project edit page or
`/admin/core/project/`.
The `is_supervisor()` helper treats any ONE of these as sufficient:
```python
def is_supervisor(user):
return (
user.supervised_teams.exists() # Team.supervisor FK reverse
or user.assigned_projects.exists() # Project.supervisors M2M reverse
or user.groups.filter(name='Work Logger').exists()
)
```
So a user can become a supervisor via **any** of: Work Logger group, assigned to
a Team as supervisor, or added to a Project's supervisors M2M.
### The three permission-check helpers in `core/views.py`
All three are defined near the top of `views.py` (around line 4767):
| Helper | Returns True if… | Used by |
|---|---|---|
| `is_admin(user)` | `is_staff=True` OR `is_superuser=True` | Every admin-only view (payroll, reports, worker/team/project management, CSV exports) |
| `is_supervisor(user)` | Supervises a team OR has assigned projects OR is in Work Logger group | Attendance logging, history page filtering |
| `is_staff_or_supervisor(user)` | `is_admin` OR `is_supervisor` | Views accessible to both tiers (dashboard shows different content per tier) |
**Critical**: `is_admin()` does NOT check for the "Admin" group. It checks the
Django `is_staff`/`is_superuser` flags. A user can be in the "Admin" permission
group but NOT be an admin as far as the app is concerned, and vice versa. The
group controls Django-admin model permissions; the flags control everything else.
### How views enforce permissions
1. **`@login_required`** is on every view except `import_data()` and
`run_migrate()` (temporary setup endpoints).
2. **Admin-only views** call `is_admin(request.user)` at the top and return
`HttpResponseForbidden("Admin access required.")` if false. Examples:
everything under `/payroll/`, `/workers/*`, `/teams/*`, `/projects/*`,
`/report/*`, `/workers/export/`.
3. **Supervisor-scoped data** uses `is_supervisor()` to gate access, then filters
querysets by the user's `supervised_teams` / `assigned_projects`:
- `work_history` — supervisors see only logs for their teams/projects
- `AttendanceLogForm` — pre-filters `project` and `team` dropdowns by what
the user can see; `workers` field is filtered by team membership
4. **Permission cascading** — a supervisor of a Team automatically "supervises"
every worker in that team, and every project that team has worked on.
This is implicit — there's no per-worker permission.
### The "Resources" dropdown supervisor picker
When editing a Team or Project via the friendly UI (`/teams/<id>/edit/` or
`/projects/<id>/edit/`), the Supervisor/Supervisors picker uses
`_supervisor_user_queryset()` in `core/forms.py`:
```python
User.objects.filter(is_active=True).filter(
Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger')
).distinct()
```
So anyone who's either an admin OR a Work Logger shows up as an eligible
supervisor. Deactivated accounts (`is_active=False`) are hidden.
### Typical user setups
| User | is_superuser | is_staff | Groups | Supervised Teams/Projects | What they can do |
|---|---|---|---|---|---|
| **admin** (Konrad) | ✓ | ✓ | — | — | Everything. Bypasses all checks. |
| **testadmin** (Flatlogic) | ✓ | ✓ | — | — | Same as above. |
| **eendman** | — | — | Work Logger | *(usually also supervises a team)* | Log work for assigned teams/projects, see their history. Cannot enter Django admin. Cannot view payroll or worker salary data via app UI. |
| **Office data-entry staff** (future) | — | ✓ | Admin | — | Can enter Django admin and CRUD all core models there. Does NOT see the payroll dashboard or worker salary UI (because `is_admin()` helper still returns True via `is_staff`, so actually yes they CAN see payroll — see note below). |
| **Inactive terminated employee** | — | — | — | — | Cannot log in if `is_active=False`. |
**Note on is_staff + payroll access**: `is_admin()` returns True for any
`is_staff` user. That means if you create an office-admin user with
`is_staff=True` but no superuser flag, they WILL see payroll and salary data
via the friendly UI. If you need a "Django admin only, no payroll UI" role,
we'd have to add a separate flag or group check — not currently supported.
### How to add a new supervisor (step by step)
1. Go to `/admin/auth/user/add/` and create the user with a username and
password. **Uncheck "Staff status"** on the initial form (they don't need
Django admin access).
2. On the user's change page, add them to the **Work Logger** group.
3. (Optional) Assign them as the supervisor of one or more teams via
`/teams/<id>/edit/` (Supervisor dropdown — they'll appear in the list
because of their Work Logger group membership).
4. (Optional) Add them to one or more projects via `/projects/<id>/edit/`
(Supervisors M2M checklist).
5. They can now log in at `/accounts/login/` and will land on the Dashboard
with a supervisor view — their teams + projects only.
### Common misconceptions (read these)
- **"Admin" group ≠ Django admin access.** Django admin access requires
`is_staff=True`. The "Admin" group is just a permission bundle.
- **Work Logger doesn't need to supervise a team to be "a supervisor".**
Group membership alone satisfies `is_supervisor()`.
- **Superuser bypasses the "Admin" group.** They don't need it.
- **Deactivating a user** (`is_active=False`) blocks login entirely; their
team/project assignments remain in the DB for audit purposes but stop
having any effect.
- **The `supervisor` field on WorkLog is historical, not authoritative.** It
records who logged the work that day — not who currently supervises the
workers.
## Authentication
- Django's built-in auth (`django.contrib.auth`)
- Login: `/accounts/login/` → redirects to `/` (home)
- Logout: POST to `/accounts/logout/` → redirects to login
- All views use `@login_required` except `import_data()`
- All views use `@login_required` except `import_data()` and `run_migrate()`
- No PIN auth in v5 (simplified from v2)
- Passwords: Django's default PBKDF2 hashing, no custom password policy
- Sessions: cookie-based, server-side session store (default Django)
## Django Admin Customisation
The `/admin/` interface is Django's built-in admin with two targeted customisations:
### Model registrations (`core/admin.py`)
- Every core model is registered with `list_display`, `list_filter`, `search_fields`, and
(where relevant) `filter_horizontal` / `filter_vertical` for M2M pickers.
- `WorkerAdmin` has `WorkerCertificateInline` + `WorkerWarningInline` so you can edit a
worker's certs and warnings inline on the worker change page.
- `WorkerCertificateAdmin` + `WorkerWarningAdmin` are also standalone (useful for
"show me all expiring certs across all workers" type queries via list_filter).
### Template override — `core/templates/admin/base_site.html`
Extends `admin/base.html` and injects a small `<style>` block into every admin page.
Currently used for:
- Taller `FilteredSelectMultiple` widgets (the "Choose" / "Available" boxes used by
`filter_horizontal` on Groups, WorkLogs etc.) — default Django height is ~16em which
is too short for long permission lists; we set 30em (40em on tall screens).
Add more admin-only CSS tweaks inside that `<style>` block rather than polluting
`static/css/custom.css`.
### Why the override works — `TEMPLATES.DIRS` setting
Django's template loader tries `TEMPLATES[0].DIRS` before the app-dirs loader. Since
`django.contrib.admin` comes before `core` in `INSTALLED_APPS` (the standard order),
its `admin/base_site.html` would normally win. Adding `BASE_DIR / 'core' / 'templates'`
to `TEMPLATES[0]['DIRS']` in `config/settings.py` makes our override take priority
without reordering `INSTALLED_APPS` (which would risk subtle side effects on signals,
migrations, and admin URL registration).
## Backup & Restore (production safety net)
Flatlogic doesn't expose MySQL directly (no SSH, no `mysqldump`, no DB console).
Instead, the app ships two management commands + two admin-only browser URLs
that back up and restore every row via Django's ORM — platform-independent,
works anywhere Django does.
### Making a backup (before any risky deploy)
**From the browser (production)**:
1. Log in as admin
2. Visit `/backup-data/`
3. Browser downloads `foxlog_backup_<timestamp>.json` to your laptop
4. Move that file somewhere safe (Google Drive, local disk — NOT the repo)
**From the command line (local dev or SSH-able host)**:
```
python manage.py backup_data # → backups/foxlog_<timestamp>.json
python manage.py backup_data --output=custom.json # → custom.json
```
Either method produces an identical JSON file covering:
- All auth tables: User, Group, Permission, ContentType (so accounts restore correctly)
- UserProfile, Project, Worker, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning
File size is roughly 1 KB per row (14 workers / 100 work logs → ~90 KB).
### Restoring a backup
**From the browser**:
1. Log in as admin
2. Visit `/restore-data/`
3. Upload your `.json` file
4. Tick "Yes, I understand" checkbox
5. Click Restore — runs inside a database transaction, all-or-nothing
**Behaviour**:
- Rows with matching primary key are **UPDATED** (no duplicates)
- Rows with primary keys not yet in the DB are **INSERTED**
- Rows in the DB but NOT in the backup are **KEPT** (restore doesn't delete)
- If any row fails to load, the whole restore is rolled back (no partial state)
**For a clean restore** (wipe everything first, then load the backup):
```
python manage.py flush # irreversible — deletes ALL data
python manage.py restore_data backup.json
```
### Recommended workflow before any deploy
1. `/backup-data/` on production before pushing the change
2. Push the change to `ai-dev`, let Flatlogic rebuild
3. Verify the new version works
4. If broken: restore from the backup you just took via `/restore-data/`
5. Delete the backup file from your laptop once you're confident the deploy is stable
### Files involved
- `core/management/commands/backup_data.py` — CLI command + reusable `build_backup_payload()` helper
- `core/management/commands/restore_data.py` — CLI command + reusable `restore_from_json_string()` helper
- `core/views.py::backup_data` — browser view that reuses the helper
- `core/views.py::restore_data` — browser view with minimal HTML upload UI
- URLs: `/backup-data/`, `/restore-data/` (both `@login_required` + `is_admin()` gated)
## Environment Variables
```
@ -186,5 +529,5 @@ PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
## Important Context
- The owner (Konrad) is not a developer — explain changes clearly and avoid unnecessary complexity
- This system handles real payroll for field workers — accuracy is critical
- `render_to_pdf()` uses lazy import of xhtml2pdf to prevent app crash if library missing
- `render_to_pdf()` uses lazy import of WeasyPrint to prevent app crash if library missing; on Windows it also auto-registers the GTK3 runtime's DLL directory so `ctypes.find_library()` can locate `gobject-2.0-0` (Python 3.8+ requires explicit `os.add_dll_directory()`)
- Django admin is available at `/admin/` with full model registration and search/filter

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. "
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
"to generate a new one."
)
ALLOWED_HOSTS = [
"127.0.0.1",
@ -27,17 +54,33 @@ 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.
Accepts any of: 'example.com' / 'https://example.com' / 'http://localhost'
Returns a string with a scheme every time.
"""
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
@ -78,7 +121,11 @@ ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
# Explicitly load core/templates first so we can override specific
# Django admin templates (e.g. admin/base_site.html) without having
# to reorder INSTALLED_APPS. Without this entry, the app-dirs loader
# finds django.contrib.admin's version before ours.
'DIRS': [BASE_DIR / 'core' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -163,26 +210,55 @@ MEDIA_ROOT = BASE_DIR / 'media'
# 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.
# === EMAIL CONFIGURATION ===
# NO FALLBACKS for credentials — they MUST come from environment variables.
# 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 on deploy platform
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set on deploy platform
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 — but override
# via env var for flexibility. Set to empty string if you want to disable sending.
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# Fail loudly at startup in production if credentials 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:
# Don't crash — email sending isn't critical for the app to boot —
# but log a loud warning so it's visible in deploy logs.
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

@ -1,8 +1,9 @@
from django.contrib import admin
from .models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
)
@admin.register(UserProfile)
@ -17,27 +18,72 @@ class ProjectAdmin(admin.ModelAdmin):
search_fields = ('name', 'description')
filter_horizontal = ('supervisors',)
# === INLINE ADMINS FOR WORKER ===
# Let admins manage a worker's certifications and warnings directly
# from the Worker change page, without navigating to a separate screen.
class WorkerCertificateInline(admin.TabularInline):
model = WorkerCertificate
extra = 0 # no blank rows by default — admin clicks "Add another" to create
readonly_fields = ('created_at',)
fields = ('cert_type', 'document', 'issued_date', 'valid_until', 'notes', 'created_at')
class WorkerWarningInline(admin.TabularInline):
model = WorkerWarning
extra = 0
readonly_fields = ('created_at',)
fields = ('date', 'severity', 'reason', 'description', 'issued_by', 'document', 'created_at')
@admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_number', 'monthly_salary', 'active')
list_filter = ('active', 'has_drivers_license')
search_fields = ('name', 'id_number', 'phone_number')
# Inline sections for certs + warnings appear below the main Worker form
inlines = [WorkerCertificateInline, WorkerWarningInline]
# === FIELDSETS ===
# Organise the worker edit form into clear sections
# Organise the worker edit form into clear sections.
# Banking & Tax fields (UIF, Bank, Acc No.) live inside Personal Info
# per product requirement — help_text strings render as hints under
# each field in admin (and as tooltips on the friendly edit page).
fieldsets = (
('Personal Info', {
'fields': ('name', 'id_number', 'phone_number', 'monthly_salary',
'tax_number', 'uif_number',
'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes'),
}),
('Sizing', {
'fields': ('shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size'),
}),
('Documents & License', {
'fields': ('photo', 'id_document', 'has_drivers_license', 'drivers_license'),
'fields': ('photo', 'id_document',
'has_drivers_license', 'drivers_license', 'drivers_license_code'),
}),
)
# === STANDALONE ADMINS FOR CERTS + WARNINGS ===
# Separate pages for bulk operations across workers — "show me all
# certs expiring this month" or "show me all final warnings".
@admin.register(WorkerCertificate)
class WorkerCertificateAdmin(admin.ModelAdmin):
list_display = ('worker', 'cert_type', 'issued_date', 'valid_until', 'is_expired')
list_filter = ('cert_type',)
search_fields = ('worker__name', 'worker__id_number')
date_hierarchy = 'valid_until'
@admin.register(WorkerWarning)
class WorkerWarningAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'severity', 'reason', 'issued_by')
list_filter = ('severity',)
search_fields = ('worker__name', 'reason', 'description')
date_hierarchy = 'date'
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')

View File

@ -3,10 +3,34 @@
# - AttendanceLogForm: daily work log creation with date ranges and conflict detection
# - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments
# - ExpenseReceiptForm + ExpenseLineItemFormSet: expense receipt creation with dynamic line items
# - WorkerForm + WorkerCertificateFormSet + WorkerWarningFormSet: friendly
# worker management (alternative to the Django admin)
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.forms import inlineformset_factory
from .models import WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
from .models import (
WorkLog, Project, Team, Worker, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
)
# === FILE SIZE VALIDATOR ===
# Reusable 5 MB ceiling for uploads (photos, IDs, certificates, warning docs).
# Keeps the MEDIA_ROOT from being filled with a single accidental 50 MB scan.
MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_max_5mb(f):
"""Raise ValidationError if the uploaded file exceeds 5 MB."""
if f and hasattr(f, 'size') and f.size > MAX_UPLOAD_SIZE:
mb = f.size / (1024 * 1024)
raise ValidationError(
f'File is {mb:.1f} MB — maximum allowed is 5 MB. '
'Please reduce the file size (e.g. scan at a lower resolution) and try again.'
)
class AttendanceLogForm(forms.ModelForm):
@ -213,3 +237,220 @@ ExpenseLineItemFormSet = inlineformset_factory(
}),
}
)
# =============================================================
# === WORKER MANAGEMENT FORMS ===
# =============================================================
class WorkerForm(forms.ModelForm):
"""Main worker edit form — covers all the flat fields on Worker.
Certifications and warnings are handled separately by the formsets
below (they have their own rows in their own tables).
"""
class Meta:
model = Worker
fields = [
'name', 'id_number', 'phone_number', 'monthly_salary',
'tax_number', 'uif_number', 'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes',
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
'photo', 'id_document',
'has_drivers_license', 'drivers_license', 'drivers_license_code',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'id_number': forms.TextInput(attrs={'class': 'form-control'}),
'phone_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+27...'}),
'monthly_salary': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
# Banking & Tax
'tax_number': forms.TextInput(attrs={'class': 'form-control'}),
'uif_number': forms.TextInput(attrs={'class': 'form-control'}),
'bank_name': forms.TextInput(attrs={'class': 'form-control',
'placeholder': 'e.g. FNB, Standard Bank, Capitec'}),
'bank_account_number': forms.TextInput(attrs={'class': 'form-control'}),
'employment_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'shoe_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 9 / 43'}),
'overall_top_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
'pants_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 34'}),
'tshirt_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
'photo': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'id_document': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'has_drivers_license': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'drivers_license': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'drivers_license_code': forms.TextInput(attrs={'class': 'form-control',
'placeholder': 'e.g. EB, C1'}),
}
def clean_photo(self):
f = self.cleaned_data.get('photo')
validate_max_5mb(f)
return f
def clean_id_document(self):
f = self.cleaned_data.get('id_document')
validate_max_5mb(f)
return f
def clean_drivers_license(self):
f = self.cleaned_data.get('drivers_license')
validate_max_5mb(f)
return f
class WorkerCertificateForm(forms.ModelForm):
"""Single certificate row. Used inside the formset — not rendered directly."""
class Meta:
model = WorkerCertificate
fields = ['cert_type', 'document', 'issued_date', 'valid_until', 'notes']
widgets = {
'cert_type': forms.Select(attrs={'class': 'form-select form-select-sm'}),
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
'issued_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'valid_until': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'notes': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2}),
}
def clean_document(self):
f = self.cleaned_data.get('document')
validate_max_5mb(f)
return f
class WorkerWarningForm(forms.ModelForm):
"""Single warning row. Used inside the formset — not rendered directly."""
class Meta:
model = WorkerWarning
fields = ['date', 'severity', 'reason', 'description', 'document']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'severity': forms.Select(attrs={'class': 'form-select form-select-sm'}),
'reason': forms.TextInput(attrs={'class': 'form-control form-control-sm',
'placeholder': 'Short summary'}),
'description': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2,
'placeholder': 'Full context...'}),
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
}
def clean_document(self):
f = self.cleaned_data.get('document')
validate_max_5mb(f)
return f
# === WORKER CERTIFICATE FORMSET ===
# extra=0: don't render blank rows by default — user clicks "+ Add" to create.
# can_delete: user can tick the delete checkbox to remove a cert on save.
WorkerCertificateFormSet = inlineformset_factory(
Worker, WorkerCertificate,
form=WorkerCertificateForm,
extra=0,
can_delete=True,
)
# === WORKER WARNING FORMSET ===
WorkerWarningFormSet = inlineformset_factory(
Worker, WorkerWarning,
form=WorkerWarningForm,
extra=0,
can_delete=True,
)
# =============================================================
# === TEAM & PROJECT MANAGEMENT FORMS ===
# =============================================================
# Friendly edit forms for Teams and Projects — alternative to Django
# admin. Both are simple ModelForms (no inline formsets — these models
# only have M2M relationships, handled by standard multi-select widgets).
def _supervisor_user_queryset():
"""Users eligible to supervise a team or project.
Matches the app's role model (see `is_supervisor` in views.py):
anyone who is a Django admin (is_staff/is_superuser) OR is a member
of the "Work Logger" group. Active accounts only no deactivated
users in the picker.
"""
from django.db.models import Q
return (
User.objects.filter(is_active=True)
.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger'))
.distinct()
.order_by('username')
)
class TeamForm(forms.ModelForm):
"""Team edit form — covers every Team field plus the `workers` M2M."""
class Meta:
model = Team
fields = [
'name', 'supervisor', 'active',
'pay_frequency', 'pay_start_date',
'workers',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'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'}),
# CheckboxSelectMultiple is kinder for small worker lists; the
# template groups active/inactive visually via template logic.
'workers': forms.CheckboxSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Supervisor dropdown — show anyone who is either admin (is_staff/
# is_superuser) OR a member of the "Work Logger" group. This matches
# the app's role model: team supervisors are typically Work Loggers,
# not admins, so filtering by is_staff alone hides the people who
# actually supervise teams day-to-day. We use `active=True` to drop
# deactivated accounts from the picker.
self.fields['supervisor'].queryset = _supervisor_user_queryset()
self.fields['supervisor'].required = False
# Include inactive workers too — matches admin parity. The template
# badges inactive ones so users can tell at a glance.
self.fields['workers'].queryset = Worker.objects.all().order_by('-active', 'name')
self.fields['workers'].required = False
class ProjectForm(forms.ModelForm):
"""Project edit form — covers every Project field plus the `supervisors` M2M."""
class Meta:
model = Project
fields = [
'name', 'description', 'active',
'start_date', 'end_date',
'supervisors',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'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'}),
'supervisors': forms.CheckboxSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Project supervisors follow the same rule as team supervisors — admins
# or Work Loggers are eligible.
self.fields['supervisors'].queryset = _supervisor_user_queryset()
self.fields['supervisors'].required = False
def clean(self):
cleaned = super().clean()
start = cleaned.get('start_date')
end = cleaned.get('end_date')
if start and end and end < start:
raise ValidationError("End date must be on or after the start date.")
return cleaned

View File

@ -0,0 +1,132 @@
# === 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, WorkerWarning,
)
# === 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,
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

@ -0,0 +1,51 @@
# Generated by Django 5.2.7 on 2026-04-21 12:50
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_vat_type_default_none'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WorkerWarning',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now)),
('severity', models.CharField(choices=[('verbal', 'Verbal Warning'), ('written', 'Written Warning'), ('final', 'Final Warning')], max_length=20)),
('reason', models.CharField(help_text='Short summary — e.g. "Repeated lateness"', max_length=200)),
('description', models.TextField(blank=True, help_text='Full context of what happened')),
('document', models.FileField(blank=True, help_text='Signed warning form (optional)', null=True, upload_to='workers/warnings/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('issued_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warnings_issued', to=settings.AUTH_USER_MODEL)),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warnings', to='core.worker')),
],
options={
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='WorkerCertificate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cert_type', models.CharField(choices=[('skills', 'Skills Certificate'), ('pdp', 'PDP (Professional Driving Permit)'), ('first_aid', 'First Aid'), ('medical', 'Medical'), ('work_at_height', 'Work at Height')], max_length=30)),
('document', models.FileField(blank=True, help_text='Scan or photo of the certificate', null=True, upload_to='workers/certificates/')),
('issued_date', models.DateField(blank=True, null=True)),
('valid_until', models.DateField(blank=True, help_text='Expiry date — leave blank if the cert does not expire', null=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='core.worker')),
],
options={
'ordering': ['worker', 'cert_type'],
'constraints': [models.UniqueConstraint(fields=('worker', 'cert_type'), name='unique_cert_per_worker')],
},
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2026-04-21 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_workerwarning_workercertificate'),
]
operations = [
migrations.AddField(
model_name='worker',
name='bank_account_number',
field=models.CharField(blank=True, help_text='Bank account number', max_length=50, verbose_name='Acc No.'),
),
migrations.AddField(
model_name='worker',
name='bank_name',
field=models.CharField(blank=True, help_text='Account at which Institution', max_length=100, verbose_name='Bank'),
),
migrations.AddField(
model_name='worker',
name='drivers_license_code',
field=models.CharField(blank=True, help_text='Drivers License Code (e.g. A, B, C, EB, EC)', max_length=20, verbose_name='Code'),
),
migrations.AddField(
model_name='worker',
name='uif_number',
field=models.CharField(blank=True, help_text='Unemployment Insurance Fund number', max_length=50, verbose_name='UIF'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-21 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_worker_bank_account_number_worker_bank_name_and_more'),
]
operations = [
migrations.AddField(
model_name='worker',
name='tax_number',
field=models.CharField(blank=True, help_text='Registered Tax Number', max_length=50, verbose_name='Tax No'),
),
]

View File

@ -1,3 +1,4 @@
import datetime
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
@ -38,6 +39,33 @@ class Worker(models.Model):
id_number = models.CharField(max_length=50, unique=True)
phone_number = models.CharField(max_length=20, blank=True)
monthly_salary = models.DecimalField(max_digits=10, decimal_places=2)
# === BANKING & TAX ===
# Payroll-related identifiers. Shown in the "Personal Info" fieldset in
# Django admin and the "Personal & Pay" section of the friendly edit form.
# verbose_name becomes the form label; help_text becomes the tooltip
# (friendly page) or the admin's under-field hint.
tax_number = models.CharField(
'Tax No',
max_length=50, blank=True,
help_text='Registered Tax Number',
)
uif_number = models.CharField(
'UIF',
max_length=50, blank=True,
help_text='Unemployment Insurance Fund number',
)
bank_name = models.CharField(
'Bank',
max_length=100, blank=True,
help_text='Account at which Institution',
)
bank_account_number = models.CharField(
'Acc No.',
max_length=50, blank=True,
help_text='Bank account number',
)
photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True)
id_document = models.FileField(upload_to='workers/documents/', blank=True, null=True)
employment_date = models.DateField(default=timezone.now)
@ -55,6 +83,11 @@ class Worker(models.Model):
# Track which workers have a valid drivers license and store a scanned copy
has_drivers_license = models.BooleanField(default=False)
drivers_license = models.FileField(upload_to='workers/documents/', blank=True, null=True)
drivers_license_code = models.CharField(
'Code',
max_length=20, blank=True,
help_text='Drivers License Code (e.g. A, B, C, EB, EC)',
)
@property
def daily_rate(self):
@ -195,7 +228,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)
@ -210,3 +243,115 @@ class ExpenseLineItem(models.Model):
def __str__(self):
return self.product_name
# =============================================================
# === WORKER CERTIFICATIONS ===
# =============================================================
# Each row means "this worker currently holds this certificate".
# Delete the row to record that they no longer hold it.
# Use valid_until to track when the cert expires — certs without a
# valid_until date are treated as non-expiring (e.g. a completed
# skills course with no expiry).
class WorkerCertificate(models.Model):
# === CERT TYPES ===
# Fixed list for now; add more entries here when new cert types
# become relevant (e.g. scaffolding, electrical, confined spaces).
CERT_TYPES = [
('skills', 'Skills Certificate'),
('pdp', 'PDP (Professional Driving Permit)'),
('first_aid', 'First Aid'),
('medical', 'Medical'),
('work_at_height', 'Work at Height'),
]
worker = models.ForeignKey(
Worker, related_name='certificates', on_delete=models.CASCADE,
)
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
document = models.FileField(
upload_to='workers/certificates/', blank=True, null=True,
help_text='Scan or photo of the certificate',
)
issued_date = models.DateField(blank=True, null=True)
valid_until = models.DateField(
blank=True, null=True,
help_text='Expiry date — leave blank if the cert does not expire',
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# One row per (worker, cert_type) — no duplicate cert types per worker
constraints = [
models.UniqueConstraint(
fields=['worker', 'cert_type'],
name='unique_cert_per_worker',
),
]
ordering = ['worker', 'cert_type']
def __str__(self):
return f'{self.worker.name}{self.get_cert_type_display()}'
@property
def is_expired(self):
"""True if the certificate's valid_until date is in the past."""
if not self.valid_until:
return False
return self.valid_until < timezone.now().date()
@property
def expires_soon(self):
"""True if the cert expires within the next 30 days (but not yet expired)."""
if not self.valid_until:
return False
today = timezone.now().date()
return today <= self.valid_until <= today + datetime.timedelta(days=30)
# =============================================================
# === WORKER WARNINGS / DISCIPLINARY ===
# =============================================================
# A disciplinary record per worker. Severity escalates: Verbal →
# Written → Final. Keep all historical warnings for audit purposes;
# don't delete rows. If a warning was issued in error, update the
# reason/description to note that rather than removing it.
class WorkerWarning(models.Model):
# === SEVERITY LEVELS ===
# Standard South African labour-relations escalation order.
SEVERITY_CHOICES = [
('verbal', 'Verbal Warning'),
('written', 'Written Warning'),
('final', 'Final Warning'),
]
worker = models.ForeignKey(
Worker, related_name='warnings', on_delete=models.CASCADE,
)
date = models.DateField(default=timezone.now)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
reason = models.CharField(
max_length=200,
help_text='Short summary — e.g. "Repeated lateness"',
)
description = models.TextField(
blank=True,
help_text='Full context of what happened',
)
issued_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='warnings_issued',
)
document = models.FileField(
upload_to='workers/warnings/', blank=True, null=True,
help_text='Signed warning form (optional)',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# Newest warnings first — that's what the UI will show at the top
ordering = ['-date']
def __str__(self):
return f'{self.worker.name}{self.get_severity_display()} ({self.date})'

View File

@ -0,0 +1,45 @@
{% extends "admin/base.html" %}
{# ===========================================================
Minimal override of the default admin/base_site.html.
The sole purpose right now is to inject a small <style> block
into every Django admin page. Add more admin CSS tweaks here
as needed — keeps them in one place and isolated from the
main app's custom.css.
=========================================================== #}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block branding %}
<h1 id="site-header"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
{% endblock %}
{% block extrastyle %}{{ block.super }}
<style>
/* === FILTERED SELECT WIDGET (M2M pickers like Group permissions) ===
The default Django admin filtered-select has a fairly short
`height: 16em` on each side, so long permission lists need lots
of scrolling. Make both "Available" and "Chosen" boxes taller
by default — they expand with the viewport up to a cap.
Applies to any filter_horizontal / filter_vertical M2M field. */
.selector .selector-available select,
.selector .selector-chosen select {
min-height: 30em; /* was ~16em by default */
height: 30em;
}
/* On large screens, push a bit taller */
@media (min-height: 900px) {
.selector .selector-available select,
.selector .selector-chosen select {
min-height: 40em;
height: 40em;
}
}
/* Match the titles' layout so the boxes stay aligned */
.selector .selector-available,
.selector .selector-chosen {
height: auto;
}
</style>
{% endblock %}
{% block nav-global %}{% endblock %}

View File

@ -33,79 +33,149 @@
{% if user.is_authenticated %}
<!-- ===================================================================
APP LAYOUT — sidebar (desktop) + top bar (mobile) + content
APP LAYOUT — top bar (desktop + mobile) + bottom tab bar (mobile)
=================================================================== -->
<div class="app-layout">
<!-- === SIDEBAR (desktop only, hidden on mobile via CSS) === -->
<aside class="app-sidebar d-print-none">
<!-- === TOP BAR (always visible — nav links on desktop, brand-only on mobile) === -->
<header class="app-topbar d-print-none">
<div class="topbar-inner">
<!-- Brand / Logo -->
<div class="sidebar-brand">
<div class="sidebar-brand__icon">
<i class="fas fa-bolt"></i>
</div>
<a href="{% url 'home' %}" class="sidebar-brand__text">
<span>Fox</span>Fitt
<!-- Brand / Logo -->
<a href="{% url 'home' %}" class="topbar-brand">
<div class="sidebar-brand__icon">
<i class="fas fa-bolt"></i>
</div>
<span class="topbar-brand__text">
<span>Fox</span>Fitt
</span>
</a>
</div>
<!-- Navigation Links -->
<nav class="sidebar-nav">
<a href="{% url 'home' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i>
<span>Log Work</span>
</a>
<a href="{% url 'work_history' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>History</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i>
<span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'create_receipt' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i>
<span>Receipts</span>
</a>
{% if user.is_staff %}
<a href="{% url 'admin:index' %}" class="sidebar-nav__link">
<i class="fas fa-cog"></i>
<span>Admin</span>
</a>
{% endif %}
</nav>
<!-- Desktop Navigation Links (hidden on mobile — bottom tab bar handles it) -->
<!-- Order: Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
<nav class="topbar-nav">
<a href="{% url 'home' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i><span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i><span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'work_history' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i><span>History</span>
</a>
{% if user.is_staff %}
<!-- Resources dropdown: Workers, Teams, Projects -->
<div class="dropdown">
<a href="#" class="topbar-nav__link dropdown-toggle {% if 'worker' in request.resolver_match.url_name|default:'' or 'team' in request.resolver_match.url_name|default:'' or 'project' in request.resolver_match.url_name|default:'' %}active{% endif %}"
data-bs-toggle="dropdown" aria-expanded="false" role="button">
<i class="fas fa-hard-hat"></i><span>Resources</span>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'worker_list' %}"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</a></li>
<li><a class="dropdown-item" href="{% url 'team_list' %}"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Teams</a></li>
<li><a class="dropdown-item" href="{% url 'project_list' %}"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Projects</a></li>
</ul>
</div>
{% endif %}
<a href="{% url 'create_receipt' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i><span>Receipts</span>
</a>
{% if user.is_staff %}
<a href="{% url 'admin:index' %}" class="topbar-nav__link">
<i class="fas fa-cog"></i><span>Admin</span>
</a>
{% endif %}
</nav>
<!-- Sidebar Footer: theme toggle + user -->
<div class="sidebar-footer">
<div class="d-flex align-items-center justify-content-between mb-3">
<!-- Right side: theme toggle + user + logout + hamburger (mobile) -->
<div class="topbar-actions">
<button type="button" class="theme-toggle" id="themeToggle" title="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
<form method="post" action="{% url 'logout' %}">
<div class="topbar-user d-none d-md-flex">
<div class="topbar-user__avatar">
{{ user.username|make_list|first|upper }}
</div>
<span class="topbar-user__name">{{ user.first_name|default:user.username }}</span>
</div>
<form method="post" action="{% url 'logout' %}" class="d-none d-lg-block">
{% csrf_token %}
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
<i class="fas fa-sign-out-alt"></i>
</button>
</form>
<!-- Hamburger button (mobile only) -->
<button type="button" class="hamburger-btn d-lg-none" id="hamburgerBtn" aria-label="Open menu">
<i class="fas fa-bars" id="hamburgerIcon"></i>
</button>
</div>
<div class="sidebar-user">
<div class="sidebar-user__avatar">
</div>
</header>
<!-- === MOBILE MENU (slides down from topbar when hamburger is tapped) === -->
<div class="mobile-menu d-lg-none d-print-none" id="mobileMenu">
<!-- Mobile nav — same order as desktop:
Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
<nav class="mobile-menu__nav">
<a href="{% url 'home' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i><span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i><span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'work_history' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i><span>History</span>
</a>
{% if user.is_staff %}
<!-- Resources: flat list on mobile (dropdowns are clumsy in slide-down drawers) -->
<a href="{% url 'worker_list' %}" class="mobile-menu__link">
<i class="fas fa-hard-hat"></i><span>Workers</span>
</a>
<a href="{% url 'team_list' %}" class="mobile-menu__link">
<i class="fas fa-users"></i><span>Teams</span>
</a>
<a href="{% url 'project_list' %}" class="mobile-menu__link">
<i class="fas fa-project-diagram"></i><span>Projects</span>
</a>
{% endif %}
<a href="{% url 'create_receipt' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i><span>Receipts</span>
</a>
{% if user.is_staff %}
<a href="{% url 'admin:index' %}" class="mobile-menu__link">
<i class="fas fa-cog"></i><span>Admin</span>
</a>
{% endif %}
</nav>
<!-- User info + logout at bottom of mobile menu -->
<div class="mobile-menu__footer">
<div class="d-flex align-items-center gap-2">
<div class="topbar-user__avatar">
{{ user.username|make_list|first|upper }}
</div>
<div>
<div class="sidebar-user__name">{{ user.first_name|default:user.username }}</div>
<div class="sidebar-user__role">{% if user.is_staff %}Administrator{% else %}Supervisor{% endif %}</div>
<div style="color: var(--text-on-nav); font-size: 0.85rem; font-weight: 500;">{{ user.first_name|default:user.username }}</div>
<div style="color: var(--text-on-nav-muted); font-size: 0.7rem;">{% if user.is_staff %}Administrator{% else %}Supervisor{% endif %}</div>
</div>
</div>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
<i class="fas fa-sign-out-alt"></i>
</button>
</form>
</div>
</aside>
</div>
<!-- === MAIN CONTENT AREA === -->
<div class="app-main">
@ -113,24 +183,6 @@
<!-- Decorative gradient glows (separate from app-main to avoid stacking context trapping modals) -->
<div class="app-glow d-print-none"></div>
<!-- === TOP BAR (mobile only, hidden on desktop via CSS) === -->
<div class="app-topbar d-print-none">
<a href="{% url 'home' %}" style="text-decoration: none; font-family: 'Poppins', sans-serif; font-weight: 700; font-size: 1.2rem;">
<span style="color: var(--accent);">Fox</span><span style="color: var(--text-on-nav);">Fitt</span>
</a>
<div class="d-flex align-items-center gap-2">
<button type="button" class="theme-toggle" id="themeToggleMobile" title="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIconMobile"></i>
</button>
<form method="post" action="{% url 'logout' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
<i class="fas fa-sign-out-alt"></i>
</button>
</form>
</div>
</div>
<!-- === Flash messages (Django messages framework) === -->
{% if messages %}
<div class="container-fluid px-3 px-lg-4 mt-3">
@ -205,40 +257,137 @@
<!-- Bootstrap 5.3 JS Bundle (includes Popper) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
<!-- === GLOBAL TOOLTIP INIT ===
Any element on any page with `data-bs-toggle="tooltip"` and a `title`
attribute will automatically become a Bootstrap tooltip. Expose
`window.initTooltipsIn(element)` so dynamic content (e.g. newly-added
formset rows) can re-init without a full page reload.
Cost: one querySelectorAll on page load + ~1KB state per tooltip.
Negligible for this app's scale. -->
<script>
(function() {
// Both desktop sidebar and mobile top bar toggle buttons
var toggles = [
{ btn: document.getElementById('themeToggle'), icon: document.getElementById('themeIcon') },
{ btn: document.getElementById('themeToggleMobile'), icon: document.getElementById('themeIconMobile') }
];
function updateIcons() {
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
toggles.forEach(function(t) {
if (t.icon) {
t.icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
}
if (t.btn) {
t.btn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
function initTooltipsIn(root) {
root = root || document;
var triggers = root.querySelectorAll('[data-bs-toggle="tooltip"]');
triggers.forEach(function(el) {
// Avoid double-init if called on the same element twice
if (!bootstrap.Tooltip.getInstance(el)) {
new bootstrap.Tooltip(el, { container: 'body' });
}
});
}
window.initTooltipsIn = initTooltipsIn;
document.addEventListener('DOMContentLoaded', function() { initTooltipsIn(document); });
})();
</script>
updateIcons();
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
<script>
(function() {
var btn = document.getElementById('themeToggle');
var icon = document.getElementById('themeIcon');
toggles.forEach(function(t) {
if (t.btn) {
t.btn.addEventListener('click', function() {
var current = document.documentElement.getAttribute('data-theme');
var next = (current === 'light') ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('foxfitt-theme', next);
updateIcons();
});
function updateIcon() {
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
if (icon) icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
if (btn) btn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
}
updateIcon();
if (btn) {
btn.addEventListener('click', function() {
var current = document.documentElement.getAttribute('data-theme');
var next = (current === 'light') ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('foxfitt-theme', next);
updateIcon();
});
}
})();
</script>
<!-- === HAMBURGER MENU — toggles mobile navigation panel open/closed === -->
<!-- Menu is position: fixed below the topbar so it stays visible when scrolled down -->
<script>
(function() {
var hamburger = document.getElementById('hamburgerBtn');
var menu = document.getElementById('mobileMenu');
var icon = document.getElementById('hamburgerIcon');
// Create a backdrop overlay — closes the menu when tapping outside it
var backdrop = document.createElement('div');
backdrop.className = 'mobile-menu-backdrop';
document.body.appendChild(backdrop);
function closeMenu() {
menu.classList.remove('open');
backdrop.classList.remove('open');
if (icon) icon.className = 'fas fa-bars';
}
function openMenu() {
menu.classList.add('open');
backdrop.classList.add('open');
if (icon) icon.className = 'fas fa-times';
}
if (hamburger && menu) {
hamburger.addEventListener('click', function() {
if (menu.classList.contains('open')) {
closeMenu();
} else {
openMenu();
}
});
// Close menu when backdrop is tapped
backdrop.addEventListener('click', closeMenu);
// Close menu when a nav link is tapped (instant navigation feel)
var links = menu.querySelectorAll('.mobile-menu__link');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', closeMenu);
}
});
}
})();
</script>
<!-- === NUMBER FORMATTING — adds space thousands separators to R amounts === -->
<!-- Finds text like "R 8000.00" and reformats to "R 8 000.00" (SA convention) -->
<!-- Uses non-breaking spaces (\u00A0) so "R 6 666.00" never wraps mid-number -->
<script>
(function() {
var NBSP = '\u00A0'; // non-breaking space — prevents line break inside number
function formatMoney(text) {
// Match "R" optionally followed by space, then a number (with optional decimals and minus)
return text.replace(/R\s*(-?\d[\d]*(?:\.\d+)?)/g, function(match, num) {
var parts = num.split('.');
var whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, NBSP);
return 'R' + NBSP + whole + (parts[1] ? '.' + parts[1] : '');
});
}
// Walk all text nodes inside the main content area and format monetary values
function formatAllMoney() {
var root = document.querySelector('.app-content') || document.body;
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var node;
while (node = walker.nextNode()) {
// Only process nodes that have "R" followed by 4+ digit numbers (worth formatting)
if (/R\s*-?\d{4,}/.test(node.nodeValue)) {
node.nodeValue = formatMoney(node.nodeValue);
}
}
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', formatAllMoney);
} else {
formatAllMoney();
}
})();
</script>

View File

@ -0,0 +1,134 @@
{% comment %}
=== REPORT CONFIGURATION MODAL (shared partial) ===
Renders the "Generate Report" modal and its month-vs-custom-dates
toggle script. Included by both the Dashboard (index.html) and the
Report page (report.html) so users can launch a new report from
either place without duplicating the modal HTML or the JS.
Requires in the parent template context:
- `projects` (queryset of Project, for the project dropdown)
- `teams` (queryset of Team, for the team dropdown)
If those are missing, the dropdowns simply show "All Projects" /
"All Teams" — no crash.
{% endcomment %}
<div class="modal fade" id="reportConfigModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Generate Report</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="get" action="{% url 'generate_report' %}" id="reportForm">
<div class="modal-body">
<div class="row g-3">
<!-- Date Mode Toggle -->
<div class="col-12">
<label class="form-label fw-semibold">Date Selection</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="date_mode" id="modeMonth" value="month" checked>
<label class="btn btn-outline-secondary" for="modeMonth">
<i class="fas fa-calendar-alt me-1"></i>Month(s)
</label>
<input type="radio" class="btn-check" name="date_mode" id="modeCustom" value="custom">
<label class="btn btn-outline-secondary" for="modeCustom">
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</label>
</div>
</div>
<!-- Month Range Picker (shown by default) -->
<div class="col-6" id="fromMonthGroup">
<label class="form-label fw-semibold">From</label>
<input type="month" name="from_month" class="form-control" id="reportFromMonth">
</div>
<div class="col-6" id="toMonthGroup">
<label class="form-label fw-semibold">To</label>
<input type="month" name="to_month" class="form-control" id="reportToMonth">
</div>
<!-- Custom Date Range (hidden by default) -->
<div class="col-6 d-none" id="startDateGroup">
<label class="form-label fw-semibold">Start Date</label>
<input type="date" name="start_date" class="form-control" id="reportStartDate">
</div>
<div class="col-6 d-none" id="endDateGroup">
<label class="form-label fw-semibold">End Date</label>
<input type="date" name="end_date" class="form-control" id="reportEndDate">
</div>
<!-- Project Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
<select name="project" class="form-select">
<option value="">All Projects</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<!-- Team Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
<select name="team" class="form-select">
<option value="">All Teams</option>
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-accent"><i class="fas fa-chart-bar me-1"></i>Generate</button>
</div>
</form>
</div>
</div>
</div>
<!--
=== REPORT MODAL — toggle month range vs custom dates ===
Defaults both month pickers to the current month on page load so
clicking "Generate" without changing anything produces a
current-month report. Guarded by `if (!modeMonth)` so it's a no-op
on pages that don't include the modal.
-->
<script>
(function() {
var modeMonth = document.getElementById('modeMonth');
if (!modeMonth) return; // modal not on this page — skip
var modeCustom = document.getElementById('modeCustom');
var fromMonthGroup = document.getElementById('fromMonthGroup');
var toMonthGroup = document.getElementById('toMonthGroup');
var startGroup = document.getElementById('startDateGroup');
var endGroup = document.getElementById('endDateGroup');
var fromMonth = document.getElementById('reportFromMonth');
var toMonth = document.getElementById('reportToMonth');
// Default both month pickers to current month
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
if (fromMonth) fromMonth.value = curMonth;
if (toMonth) toMonth.value = curMonth;
function toggleMode() {
if (modeMonth.checked) {
fromMonthGroup.classList.remove('d-none');
toMonthGroup.classList.remove('d-none');
startGroup.classList.add('d-none');
endGroup.classList.add('d-none');
} else {
fromMonthGroup.classList.add('d-none');
toMonthGroup.classList.add('d-none');
startGroup.classList.remove('d-none');
endGroup.classList.remove('d-none');
}
}
modeMonth.addEventListener('change', toggleMode);
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
})();
</script>

View File

@ -4,11 +4,13 @@
{% block title %}Dashboard | FoxFitt{% endblock %}
{% block content %}
<!-- === DASHBOARD HEADER — gradient banner with welcome + CTA === -->
<div class="dashboard-header mb-5 rounded-0 p-4 d-flex justify-content-between align-items-center d-print-none">
<div class="container py-4">
<!-- === DASHBOARD HEADER — welcome + CTA button === -->
<div class="d-flex justify-content-between align-items-center mb-4 d-print-none">
<div>
<h1 class="h3 mb-1 text-white" style="font-family: 'Poppins', sans-serif;">Dashboard</h1>
<p class="mb-0" style="color: rgba(255,255,255,0.6); font-size: 0.9rem;">
<h1 class="page-title"><i class="fas fa-th-large me-2" style="color: var(--accent);"></i>Dashboard</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
Welcome back, {{ user.first_name|default:user.username }}
</p>
</div>
@ -17,8 +19,6 @@
</a>
</div>
<div class="container py-2" style="margin-top: -3rem;">
{% if is_admin %}
<!-- ===================================================================
ADMIN VIEW — stats, quick actions, activity, resources
@ -96,6 +96,29 @@
</div>
</div>
<!-- Certifications Expiring — shown ONLY when count > 0
Clicking it goes to the Worker Batch Report which shows per-worker cert columns. -->
{% if certs_alert_total %}
<div class="col-xl-3 col-md-6">
<a href="{% url 'worker_batch_report' %}?status=active" class="stat-card stat-card--danger h-100 p-3 d-block" style="text-decoration: none; color: inherit;">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Certifications Need Attention</div>
<div class="stat-value" style="font-size: 1.5rem;">{{ certs_alert_total }}</div>
<div style="font-size: 0.75rem; margin-top: 0.35rem; color: var(--text-secondary);">
{% if certs_expired_count %}{{ certs_expired_count }} expired{% endif %}
{% if certs_expired_count and certs_expiring_count %} &nbsp;|&nbsp; {% endif %}
{% if certs_expiring_count %}{{ certs_expiring_count }} expiring in 30 days{% endif %}
</div>
</div>
<div class="stat-icon stat-icon--danger">
<i class="fas fa-certificate"></i>
</div>
</div>
</a>
</div>
{% endif %}
<!-- Outstanding by Project -->
<div class="col-xl-3 col-md-6">
<div class="stat-card stat-card--info h-100 p-3">
@ -161,6 +184,10 @@
<i class="fas fa-receipt"></i>
<span>New Receipt</span>
</a>
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-file-alt"></i>
<span>Generate Report</span>
</a>
</div>
</div>
</div>
@ -205,13 +232,13 @@
</div>
<!-- Manage Resources -->
<!-- Note: the worker CSV export lives on the Workers page now
(nav: Workers → Export CSV). Dashboard card stays focused on
toggling active/inactive status, not on data export. -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-sliders-h me-2" style="color: var(--accent);"></i>Manage Resources</h6>
<a href="{% url 'export_workers_csv' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-file-csv me-1"></i> Export
</a>
</div>
<div class="card-body p-0 pt-2">
<p class="px-3 mb-2" style="font-size: 0.75rem; color: var(--text-tertiary);">
@ -254,6 +281,11 @@
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No workers found.</p>
{% endfor %}
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
<div class="text-center py-2 border-top">
<a href="{% url 'worker_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
<i class="fas fa-arrow-right me-1"></i>Manage All Workers
</a>
</div>
</div>
{# === PROJECTS === #}
@ -269,6 +301,11 @@
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No projects found.</p>
{% endfor %}
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
<div class="text-center py-2 border-top">
<a href="{% url 'project_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
<i class="fas fa-arrow-right me-1"></i>Manage All Projects
</a>
</div>
</div>
{# === TEAMS === #}
@ -284,6 +321,11 @@
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No teams found.</p>
{% endfor %}
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
<div class="text-center py-2 border-top">
<a href="{% url 'team_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
<i class="fas fa-arrow-right me-1"></i>Manage All Teams
</a>
</div>
</div>
</div>
@ -292,6 +334,23 @@
</div>
</div>
{# === CONFIRM DEACTIVATION MODAL (Bootstrap — works in all browsers unlike confirm()) === #}
<div class="modal fade" id="confirmDeactivateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<i class="fas fa-exclamation-triangle fa-2x mb-3" style="color: var(--color-warning);"></i>
<p class="mb-1 fw-bold" id="deactivateTitle">Deactivate?</p>
<p class="text-muted small mb-0">This will hide it from forms and dropdowns.</p>
</div>
<div class="modal-footer justify-content-center border-0 pt-0">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-danger" id="confirmDeactivateBtn">Deactivate</button>
</div>
</div>
</div>
</div>
{% else %}
<!-- ===================================================================
SUPERVISOR VIEW — projects, teams, workers + activity
@ -432,40 +491,117 @@ document.addEventListener('DOMContentLoaded', function() {
applyFilter();
// === TOGGLE HANDLER — AJAX POST to activate/deactivate resources ===
//
// How this works — and why the old code had a bug:
// ---------------------------------------------------
// The authoritative state of a resource lives on the row's
// `data-active` attribute (written by the server). The checkbox's
// `checked` property is just a visual mirror that the browser
// flips when the user clicks.
//
// We determine intent by comparing the row's CURRENT state
// (data-active) against the user's CLICK (which flips it):
// wasActive=false + click → intent is "activate" (no confirm)
// wasActive=true + click → intent is "deactivate" (confirm first)
//
// The old code read `this.checked` directly, which works in the
// happy path but got confused after a confirmed-deactivation
// because the hidden.bs.modal handler was re-setting checked=true
// whether the modal was cancelled OR confirmed. That desynced the
// UI from the server, and subsequent clicks fired deactivate
// modals on what looked to the user like "reactivation".
//
// Uses a Bootstrap modal for deactivation confirmation (native
// confirm() is blocked by Chrome in some popup-blocker configs).
var deactivateModal = new bootstrap.Modal(document.getElementById('confirmDeactivateModal'));
var deactivateTitle = document.getElementById('deactivateTitle');
var confirmBtn = document.getElementById('confirmDeactivateBtn');
var pendingSwitch = null; // toggle awaiting confirmation
var userConfirmed = false; // true if the user clicked "Deactivate" (not "Cancel")
// User confirmed deactivation — do the AJAX call
confirmBtn.addEventListener('click', function() {
userConfirmed = true;
deactivateModal.hide();
if (pendingSwitch) {
doToggle(pendingSwitch, false);
}
});
// Modal closed — either via Confirm, Cancel, Esc, or backdrop click.
// Only revert the toggle visually if the user CANCELLED.
document.getElementById('confirmDeactivateModal').addEventListener('hidden.bs.modal', function() {
if (pendingSwitch && !userConfirmed) {
// User cancelled — snap toggle back to checked (its pre-click state)
pendingSwitch.checked = true;
}
pendingSwitch = null;
userConfirmed = false;
});
// Shared function that performs the actual AJAX toggle.
// `wantsActive` is the desired NEW state (true = activate, false = deactivate).
function doToggle(switchEl, wantsActive) {
var type = switchEl.getAttribute('data-type');
var id = switchEl.getAttribute('data-id');
var row = switchEl.closest('.resource-row');
fetch('/toggle/' + type + '/' + id + '/', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
}
})
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(data) {
if (data.status === 'success') {
// Keep data-active and the checkbox visual in sync with the server
row.dataset.active = wantsActive ? 'true' : 'false';
switchEl.checked = wantsActive;
applyFilter();
} else {
// Server rejected — revert the checkbox to its pre-click state
switchEl.checked = !wantsActive;
}
})
.catch(function() {
// Network error — revert the checkbox to its pre-click state
switchEl.checked = !wantsActive;
});
}
document.querySelectorAll('.toggle-active').forEach(function(switchEl) {
switchEl.addEventListener('change', function() {
var type = this.getAttribute('data-type');
var id = this.getAttribute('data-id');
var isChecked = this.checked;
var row = this.closest('.resource-row');
// Use data-active (server truth) as the "before" state, not
// this.checked (which has already been flipped by the browser).
var wasActive = row.dataset.active === 'true';
var wantsActive = !wasActive; // user's intent = flip it
fetch('/toggle/' + type + '/' + id + '/', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
}
})
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(data) {
if (data.status === 'success') {
row.dataset.active = isChecked ? 'true' : 'false';
applyFilter();
} else {
switchEl.checked = !isChecked;
alert('Error updating status.');
}
})
.catch(function() {
switchEl.checked = !isChecked;
alert('Error updating status.');
});
if (wantsActive) {
// Reactivating — no confirmation needed
doToggle(this, true);
} else {
// Deactivating — show Bootstrap confirmation modal
var type = this.getAttribute('data-type');
var name = row.querySelector('.fw-medium').textContent.trim();
deactivateTitle.textContent = 'Deactivate ' + type + ' "' + name + '"?';
pendingSwitch = this;
userConfirmed = false;
deactivateModal.show();
}
});
});
});
</script>
<!-- === REPORT CONFIGURATION MODAL === -->
<!-- Extracted to a shared partial so the report page can use the same
modal without duplicating the HTML or the toggle script. -->
{% include 'core/_report_config_modal.html' %}
{% endblock %}

View File

@ -23,27 +23,69 @@
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="page-title"><i class="fas fa-wallet me-2" style="color: var(--accent);"></i>Payroll Dashboard</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-info shadow-sm" id="workerLookupBtn">
{# On desktop: title left, buttons right in a row #}
{# On mobile: title on top, buttons below in a 2x2 grid #}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<h1 class="page-title mb-0"><i class="fas fa-wallet me-2" style="color: var(--accent);"></i>Payroll Dashboard</h1>
<div class="d-flex flex-wrap gap-2 payroll-actions">
<button type="button" class="btn btn-outline-info shadow-sm btn-sm btn-md-normal" id="workerLookupBtn">
<i class="fas fa-id-card fa-sm me-1"></i> Worker Lookup
</button>
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
<button type="button" class="btn btn-primary shadow-sm btn-sm btn-md-normal" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
</button>
<button type="button" class="btn btn-accent shadow-sm" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<button type="button" class="btn btn-outline-success shadow-sm btn-sm btn-md-normal fw-bold" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
</button>
<button type="button" class="btn btn-outline-warning shadow-sm" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<button type="button" class="btn btn-outline-warning shadow-sm btn-sm btn-md-normal" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<i class="fas fa-clock fa-sm me-1"></i> Price Overtime
</button>
</div>
</div>
{# === ANALYTICS CARDS === #}
{# Left side: 3 single-value stat cards (2 on top + 1 below) #}
{# Right side: Project breakdown card spanning full height — no scroll #}
{# === ANALYTICS SUMMARY BAR — compact row of key numbers === #}
{# Always visible. Clicking "Show Details" expands the full stat cards and charts below. #}
<div class="card mb-3">
<div class="card-body py-2 px-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
{# Key numbers in a compact row #}
<div class="d-flex flex-wrap gap-3 analytics-summary">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-exclamation-circle" style="color: var(--color-danger); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Outstanding</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ outstanding_total|floatformat:2 }}</div>
</div>
</div>
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-check-circle" style="color: var(--color-success); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Paid (60d)</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
</div>
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-hand-holding-usd" style="color: var(--color-warning); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Loans ({{ active_loans_count }})</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
</div>
</div>
{# Toggle button to expand/collapse full analytics #}
<button type="button" class="btn btn-sm btn-outline-secondary" id="analyticsToggle" style="font-size: 0.75rem;">
<i class="fas fa-chart-bar me-1"></i><span id="analyticsToggleText">Show Details</span>
</button>
</div>
</div>
</div>
{# === FULL ANALYTICS (hidden by default — toggled by button above) === #}
<div id="analyticsDetail" style="display: none;">
{# --- Stat cards row --- #}
<div class="row g-3 mb-4">
{# --- Left column: stat cards --- #}
@ -149,7 +191,7 @@
</div>
{# === CHARTS === #}
{# --- Charts row --- #}
<div class="row mb-4">
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card h-100">
@ -204,6 +246,8 @@
</div>
</div>
</div>{# /analyticsDetail #}
{# === TAB NAVIGATION === #}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
@ -258,14 +302,17 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="pendingTable">
{# On mobile: hide Days, Day Rate, Log Amount, Adjustments, Net Adj columns #}
{# Only show: Worker (with badges), Total, Adjust + Pay buttons #}
{# All details are accessible by tapping the worker name (opens lookup modal) #}
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col">Days</th>
<th scope="col">Day Rate</th>
<th scope="col">Log Amount</th>
<th scope="col">Adjustments</th>
<th scope="col">Net Adj</th>
<th scope="col" class="d-none d-md-table-cell">Days</th>
<th scope="col" class="d-none d-md-table-cell">Day Rate</th>
<th scope="col" class="d-none d-md-table-cell">Log Amount</th>
<th scope="col" class="d-none d-lg-table-cell">Adjustments</th>
<th scope="col" class="d-none d-md-table-cell">Net Adj</th>
<th scope="col" class="fw-bold">Total</th>
<th scope="col" class="pe-4 text-end">Actions</th>
</tr>
@ -276,22 +323,32 @@
data-overdue="{{ wd.is_overdue|yesno:'true,false' }}"
data-has-loan="{{ wd.has_loan|yesno:'true,false' }}">
<td class="ps-4 align-middle">
<a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
{% if wd.is_overdue %}
<span class="badge bg-danger ms-1" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
{% endif %}
{% if wd.has_loan %}
<span class="badge bg-warning ms-1" title="Has active loan or advance">Loan</span>
<div>
<a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
</div>
{% if wd.is_overdue or wd.has_loan %}
<div class="mt-1 d-flex gap-1">
{% if wd.is_overdue %}
<span class="badge bg-danger" style="font-size: 0.6rem;" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
{% endif %}
{% if wd.has_loan %}
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
{% endif %}
</div>
{% endif %}
</td>
<td class="align-middle">{{ wd.unpaid_count }}</td>
<td class="align-middle">R {{ wd.day_rate }}</td>
<td class="align-middle">R {{ wd.unpaid_amount|floatformat:2 }}</td>
<td class="align-middle">
<td class="align-middle d-none d-md-table-cell">{{ wd.unpaid_count }}</td>
<td class="align-middle d-none d-md-table-cell">R {{ wd.day_rate }}</td>
<td class="align-middle d-none d-md-table-cell">R {{ wd.unpaid_amount|floatformat:2 }}</td>
<td class="align-middle d-none d-lg-table-cell">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
{# Badge colour logic: #}
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
{# RED = deductions (Deduction) #}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
@ -309,7 +366,7 @@
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="align-middle {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
<td class="align-middle d-none d-md-table-cell {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
{% if wd.adj_amount >= 0 %}+{% endif %}R {{ wd.adj_amount|floatformat:2 }}
</td>
<td class="align-middle fw-bold">R {{ wd.total_payable|floatformat:2 }}</td>
@ -331,7 +388,7 @@
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-accent"
title="Pay all pending items. Use Preview (eye icon) for selective payment.">
<i class="fas fa-money-bill-wave me-1"></i> Pay
<i class="fas fa-money-bill-wave me-1"></i><span class="d-none d-sm-inline"> Pay</span>
</button>
</form>
</div>
@ -360,29 +417,31 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
{# On mobile: hide Date, Work Logs, Adjustments columns #}
{# Only show: Worker, Amount Paid, View button #}
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Date</th>
<th scope="col">Worker</th>
<th scope="col" class="ps-4 d-none d-md-table-cell">Date</th>
<th scope="col" class="ps-4 ps-md-0">Worker</th>
<th scope="col">Amount Paid</th>
<th scope="col">Work Logs</th>
<th scope="col">Adjustments</th>
<th scope="col" class="d-none d-md-table-cell">Work Logs</th>
<th scope="col" class="d-none d-lg-table-cell">Adjustments</th>
<th scope="col" class="pe-4 text-end">Payslip</th>
</tr>
</thead>
<tbody>
{% for record in paid_records %}
<tr>
<td class="ps-4 align-middle">{{ record.date }}</td>
<td class="align-middle"><a href="#" class="worker-lookup-link fw-bold"
<td class="ps-4 align-middle d-none d-md-table-cell">{{ record.date }}</td>
<td class="align-middle ps-4 ps-md-0"><a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ record.worker.id }}">{{ record.worker.name }}</a></td>
<td class="align-middle">R {{ record.amount_paid|floatformat:2 }}</td>
<td class="align-middle">
<td class="align-middle d-none d-md-table-cell">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td>
<td class="align-middle">
<td class="align-middle d-none d-lg-table-cell">
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} me-1">
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
@ -391,7 +450,7 @@
</td>
<td class="pe-4 align-middle text-end">
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-file-alt me-1"></i> View
<i class="fas fa-file-alt me-1"></i><span class="d-none d-sm-inline"> View</span>
</a>
</td>
</tr>
@ -428,15 +487,17 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
{# On mobile: hide Principal, Date, Reason, Status columns #}
{# Only show: Worker, Type, Balance #}
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col">Type</th>
<th scope="col">Principal</th>
<th scope="col">Balance</th>
<th scope="col">Date</th>
<th scope="col">Reason</th>
<th scope="col" class="pe-4">Status</th>
<th scope="col" class="d-none d-md-table-cell">Date</th>
<th scope="col" class="d-none d-lg-table-cell">Reason</th>
<th scope="col" class="pe-4 d-none d-md-table-cell">Status</th>
</tr>
</thead>
<tbody>
@ -453,9 +514,9 @@
</td>
<td class="align-middle">R {{ loan.principal_amount|floatformat:2 }}</td>
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
<td class="align-middle">{{ loan.date }}</td>
<td class="align-middle">{{ loan.reason|default:"-" }}</td>
<td class="pe-4 align-middle">
<td class="align-middle d-none d-md-table-cell">{{ loan.date }}</td>
<td class="align-middle d-none d-lg-table-cell">{{ loan.reason|default:"-" }}</td>
<td class="pe-4 align-middle d-none d-md-table-cell">
{% if loan.active %}
<span class="badge bg-warning text-dark">Active</span>
{% else %}
@ -2832,9 +2893,11 @@ document.addEventListener('DOMContentLoaded', function() {
];
activities.forEach(function(act) {
var row = el('div', 'd-flex justify-content-between align-items-center py-2 border-bottom');
var row = el('div', 'd-flex justify-content-between align-items-center py-1 border-bottom');
row.style.fontSize = '0.78rem';
var left = el('div', '');
var icon = el('i', act.icon + ' me-2 ' + act.color);
icon.style.fontSize = '0.7rem';
left.appendChild(icon);
left.appendChild(document.createTextNode(act.label));
row.appendChild(left);
@ -2842,14 +2905,14 @@ document.addEventListener('DOMContentLoaded', function() {
if (act.data) {
var right = el('div', 'text-end');
right.appendChild(el('span', 'fw-bold me-2', formatRand(act.data.amount)));
right.appendChild(el('span', 'text-muted small', formatDate(act.data.date)));
right.appendChild(el('span', 'text-muted', formatDate(act.data.date)));
if (act.data.reason) {
right.appendChild(document.createTextNode(' '));
right.appendChild(el('span', 'text-muted small fst-italic', '(' + act.data.reason + ')'));
right.appendChild(el('span', 'text-muted fst-italic', '(' + act.data.reason + ')'));
}
row.appendChild(right);
} else {
row.appendChild(el('span', 'text-muted small', 'None'));
row.appendChild(el('span', 'text-muted', 'None'));
}
actSection.appendChild(row);
@ -2970,6 +3033,27 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// === ANALYTICS TOGGLE — show/hide the full stat cards and charts ===
var analyticsToggle = document.getElementById('analyticsToggle');
var analyticsDetail = document.getElementById('analyticsDetail');
var analyticsToggleText = document.getElementById('analyticsToggleText');
if (analyticsToggle && analyticsDetail) {
analyticsToggle.addEventListener('click', function() {
var isHidden = analyticsDetail.style.display === 'none';
analyticsDetail.style.display = isHidden ? '' : 'none';
analyticsToggleText.textContent = isHidden ? 'Hide Details' : 'Show Details';
// Remember preference
localStorage.setItem('foxfitt-analytics', isHidden ? 'open' : 'closed');
});
// Restore saved preference
if (localStorage.getItem('foxfitt-analytics') === 'open') {
analyticsDetail.style.display = '';
analyticsToggleText.textContent = 'Hide Details';
}
}
}); // end DOMContentLoaded
</script>

View File

@ -0,0 +1,623 @@
{% load format_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* ==========================================================
PAGE SETUP
========================================================== */
@page {
size: a4 portrait;
margin: 2cm 1.8cm 1.6cm 1.8cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 0.6cm;
margin-left: 1.8cm;
margin-right: 1.8cm;
height: 0.8cm;
}
}
/* ==========================================================
TYPOGRAPHY
========================================================== */
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 9.5pt;
line-height: 1.45;
color: #334155;
}
p { margin: 3pt 0; }
/* ==========================================================
COVER
========================================================== */
.brand-eyebrow {
font-size: 7.5pt;
font-weight: bold;
color: #10b981;
letter-spacing: 3pt;
margin-bottom: 4pt;
}
table.cover-band {
width: 100%;
border-collapse: collapse;
margin: 0;
}
table.cover-band td {
border-top: 1pt solid #10b981;
border-bottom: 1pt solid #10b981;
padding: 9pt 0;
vertical-align: middle;
}
table.cover-band td.cover-title {
font-size: 22pt;
font-weight: bold;
color: #0f172a;
line-height: 1;
width: 60%;
}
table.cover-band td.cover-date {
font-size: 11pt;
color: #1e293b;
text-align: right;
white-space: nowrap;
}
.cover-filters {
font-size: 10pt;
color: #64748b;
letter-spacing: 0.3pt;
margin: 4pt 0 14pt 0;
}
/* ==========================================================
SECTION STRUCTURE
========================================================== */
.section {
margin-top: 16pt;
}
h2.section-title {
page-break-after: avoid;
}
.break-before {
page-break-before: always;
}
.eyebrow {
font-size: 7pt;
font-weight: bold;
color: #10b981;
letter-spacing: 2.5pt;
margin-bottom: 3pt;
}
h2.section-title {
font-size: 13pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 10pt 0;
padding-bottom: 4pt;
border-bottom: 0.5pt solid #cbd5e1;
}
h3.sub-title {
font-size: 9pt;
font-weight: bold;
color: #1e293b;
letter-spacing: 1pt;
margin: 8pt 0 3pt 0;
}
/* ==========================================================
HERO CARD — 50% SMALLER
Halved the overall visual weight per feedback:
• padding dropped from 9pt → 4pt top/bottom
• hero-value dropped from 22pt → 14pt
• label/caption scaled down in proportion
Result: card is roughly half the height it was before.
========================================================== */
table.hero {
width: 100%;
border-collapse: collapse;
margin: 4pt 0 14pt 0;
}
table.hero td {
background-color: #f8fafc;
vertical-align: top;
}
table.hero td.hero-accent {
background-color: #10b981;
width: 3pt;
padding: 0;
}
table.hero td.hero-body {
padding: 4pt 14pt;
}
/* Hero spacing is dominated by line-height, not margin.
line-height: 1 collapses the phantom "leading" above/below
the value glyphs → ~50% tighter gaps around "R 64 939.00". */
.hero-label {
font-size: 7pt;
font-weight: bold;
color: #64748b;
letter-spacing: 2pt;
line-height: 1;
margin: 0;
}
.hero-value {
font-size: 14pt;
font-weight: bold;
color: #0f172a;
line-height: 1;
margin: 1pt 0 0 0;
}
.hero-caption {
font-size: 8pt;
color: #64748b;
line-height: 1.1;
margin: 1pt 0 0 0;
}
/* ==========================================================
LEDGER LINES — with R-symbol aligned in its own column
Splitting the value cell into two cells (rsym + rnum) means
every "R" in a column appears at the same x-position, while
the numbers right-align neatly on their own edge.
========================================================== */
table.ledger {
width: 100%;
border-collapse: collapse;
margin: 2pt 0 6pt 0;
}
table.ledger td {
padding: 4pt 0 4pt 0;
border-bottom: 0.4pt solid #e2e8f0;
vertical-align: middle;
}
table.ledger td.rank {
color: #94a3b8;
font-size: 8pt;
font-weight: bold;
width: 16pt;
padding-right: 4pt;
}
table.ledger td.lbl {
color: #334155;
font-size: 9.5pt;
}
table.ledger td.meta {
color: #64748b;
font-size: 8.5pt;
text-align: right;
white-space: nowrap;
padding-right: 10pt;
width: 55pt;
}
/* The two cells that together form a money value.
rsym: left-aligned "R" anchored at a fixed x-position
rnum: right-aligned number, bold black */
table.ledger td.rsym {
text-align: left;
color: #0f172a;
font-weight: bold;
font-size: 10pt;
width: 12pt;
padding-left: 6pt;
white-space: nowrap;
}
table.ledger td.rnum {
text-align: right;
color: #0f172a;
font-weight: bold;
font-size: 10pt;
white-space: nowrap;
width: 65pt;
}
/* ==========================================================
TWO-COLUMN LAYOUT
========================================================== */
table.cols {
width: 100%;
border-collapse: collapse;
margin: 0;
}
table.cols td {
vertical-align: top;
padding: 0;
}
table.cols td.colL { width: 45%; }
table.cols td.gap { width: 10%; }
table.cols td.colR { width: 45%; }
/* Extra breathing room between the two rows of the Period
Breakdown section (Labour Cost row ⇢ Payments/Adjustments row) */
table.cols-spaced {
margin-top: 18pt;
}
/* ==========================================================
PERIOD DETAIL — 15% smaller text in this section only
Scoped via the .period-detail wrapper so other sections keep
their normal size.
========================================================== */
.period-detail h3.sub-title {
font-size: 8pt; /* was 9pt */
}
.period-detail table.ledger td.lbl {
font-size: 8pt; /* was 9.5pt */
}
.period-detail table.ledger td.meta {
font-size: 7.5pt; /* was 8.5pt */
}
.period-detail table.ledger td.rsym,
.period-detail table.ledger td.rnum {
font-size: 8.5pt; /* was 10pt */
}
/* Use split padding-top/bottom (NOT the shorthand) so horizontal
padding defined on .meta and .rsym is preserved — otherwise the
shorthand clobbers it and you get "130 daysR" with no gap. */
.period-detail table.ledger td {
padding-top: 3pt;
padding-bottom: 3pt;
}
/* ==========================================================
WORKER BREAKDOWN TABLE
Money values inside use a nested mini-table so R and number
live in their own columns (same alignment trick as ledger).
========================================================== */
table.worker {
width: 100%;
border-collapse: collapse;
margin-top: 4pt;
font-size: 8.5pt;
}
table.worker th {
text-align: left;
font-size: 7pt;
font-weight: bold;
color: #64748b;
letter-spacing: 0.8pt;
padding: 4pt 5pt 5pt 5pt;
border-bottom: 1pt solid #0f172a;
white-space: nowrap;
}
table.worker th.r { text-align: right; }
table.worker td {
padding: 5pt;
border-bottom: 0.4pt solid #e2e8f0;
color: #334155;
vertical-align: middle;
}
table.worker td.name {
font-weight: bold;
color: #0f172a;
}
table.worker td.r {
text-align: right;
white-space: nowrap;
}
/* Total Paid column: bolder, darker for emphasis */
table.worker td.total {
font-weight: bold;
color: #0f172a;
text-align: right;
white-space: nowrap;
}
/* Empty-value variant (em-dash) */
table.worker td.dim {
color: #cbd5e1;
text-align: right;
}
/* ==========================================================
MISC
========================================================== */
.empty {
color: #94a3b8;
font-size: 9pt;
padding: 5pt 0;
}
#footerContent {
font-size: 7pt;
color: #94a3b8;
text-align: center;
letter-spacing: 0.5pt;
border-top: 0.3pt solid #e2e8f0;
padding-top: 4pt;
}
</style>
</head>
<body>
<!-- ==============================================================
COVER
============================================================== -->
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
<table class="cover-band">
<tr>
<td class="cover-title">Payroll Report</td>
<td class="cover-date">{{ start_date|date:"d F Y" }} &ndash; {{ end_date|date:"d F Y" }}</td>
</tr>
</table>
<div class="cover-filters">{{ project_name }} &nbsp;&bull;&nbsp; {{ team_name }}</div>
<!-- ==============================================================
ALL TIME
(All-time and year-to-date sections now appear FIRST, before the
Selected Period block — the big-picture lifetime view sets context
before we zoom in to the selected date range.)
============================================================== -->
<div class="section">
<div class="eyebrow">LIFETIME PERFORMANCE</div>
<h2 class="section-title">All Time &mdash; Labour Cost</h2>
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">BY PROJECT</h3>
{% if alltime_projects %}
<table class="ledger">
{% for item in alltime_projects %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.project }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No project data yet.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">BY TEAM</h3>
{% if alltime_teams %}
<table class="ledger">
{% for item in alltime_teams %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.team }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No team data yet.</p>{% endif %}
</td>
</tr>
</table>
</div>
<!-- ==============================================================
THIS YEAR
============================================================== -->
<div class="section">
<div class="eyebrow">YEAR-TO-DATE</div>
<h2 class="section-title">{{ current_year }} &mdash; Labour Cost</h2>
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">BY PROJECT</h3>
{% if year_projects %}
<table class="ledger">
{% for item in year_projects %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.project }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No project data for {{ current_year }}.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">BY TEAM</h3>
{% if year_teams %}
<table class="ledger">
{% for item in year_teams %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.team }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No team data for {{ current_year }}.</p>{% endif %}
</td>
</tr>
</table>
</div>
<!-- ==============================================================
SELECTED PERIOD (new page, compact text via .period-detail)
All summary figures for the chosen date range live here:
- Hero: Total Paid Out (headline KPI)
- Loans pair (issued + outstanding)
- Advances pair (issued + outstanding)
- Labour Cost by project / team
- Payments by date / Adjustments
- Worker breakdown (next section, flows naturally)
The .period-detail wrapper shrinks ledger text 15%; the hero
card uses its own classes so its headline stays prominent.
============================================================== -->
<div class="section break-before period-detail">
<div class="eyebrow">SELECTED PERIOD</div>
<h2 class="section-title">Period Breakdown</h2>
<!-- Hero: the headline KPI for this period -->
<table class="hero">
<tr>
<td class="hero-accent"></td>
<td class="hero-body">
<div class="hero-label">TOTAL PAID OUT</div>
<div class="hero-value">R {{ total_paid_out|money }}</div>
<div class="hero-caption">across {{ total_worker_days }} worker-days in this period</div>
</td>
</tr>
</table>
<!-- Loans pair (left) + Advances pair (right) -->
<!-- Each column shows issued first, then outstanding — grouped
by instrument type for easier scanning. -->
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">LOANS</h3>
<table class="ledger">
<tr>
<td class="lbl">Loans issued</td>
<td class="rsym">R</td>
<td class="rnum">{{ loans_issued|money }}</td>
</tr>
<tr>
<td class="lbl">Loans outstanding</td>
<td class="rsym">R</td>
<td class="rnum">{{ loans_outstanding|money }}</td>
</tr>
</table>
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">ADVANCES</h3>
<table class="ledger">
<tr>
<td class="lbl">Advances issued</td>
<td class="rsym">R</td>
<td class="rnum">{{ advances_issued|money }}</td>
</tr>
<tr>
<td class="lbl">Advances outstanding</td>
<td class="rsym">R</td>
<td class="rnum">{{ advances_outstanding|money }}</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- First row: Labour Cost by project / team -->
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">LABOUR COST &mdash; PROJECTS</h3>
{% if cost_per_project %}
<table class="ledger">
{% for item in cost_per_project %}
<tr>
<td class="lbl">{{ item.project }}</td>
<td class="meta">{{ item.worker_days }} days</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No project cost data.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">LABOUR COST &mdash; TEAMS</h3>
{% if cost_per_team %}
<table class="ledger">
{% for item in cost_per_team %}
<tr>
<td class="lbl">{{ item.team }}</td>
<td class="meta">{{ item.worker_days }} days</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No team cost data.</p>{% endif %}
</td>
</tr>
</table>
<!-- Second row: extra top margin creates clear visual gap -->
<table class="cols cols-spaced">
<tr>
<td class="colL">
<h3 class="sub-title">PAYMENTS BY DATE</h3>
{% if payments_by_date %}
<table class="ledger">
{% for item in payments_by_date %}
<tr>
<td class="lbl">{{ item.date|date:"d M Y" }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No payments in this period.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">ADJUSTMENTS</h3>
{% if adjustment_totals %}
<table class="ledger">
{% for item in adjustment_totals %}
<tr>
<td class="lbl">{{ item.label }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No adjustments in this period.</p>{% endif %}
</td>
</tr>
</table>
</div>
<!-- ==============================================================
WORKER BREAKDOWN
Uses nested mini-tables inside each money cell so the R and the
number line up column-wise across every row.
============================================================== -->
<div class="section">
<div class="eyebrow">PER-WORKER DETAIL</div>
<h2 class="section-title">Worker Breakdown</h2>
{% if worker_breakdown %}
<table class="worker">
<tr>
<th>WORKER</th>
<th class="r">DAYS</th>
<th class="r">TOTAL PAID</th>
{% for label in active_adj_labels %}
<th class="r">{{ label|upper }}</th>
{% endfor %}
</tr>
{% for w in worker_breakdown %}
<tr>
<td class="name">{{ w.name }}</td>
<td class="r">{{ w.days }}</td>
<td class="total">R&nbsp;{{ w.total_paid|money }}</td>
{% for val in w.adj_values %}
{% if val %}
<td class="r">R&nbsp;{{ val|money }}</td>
{% else %}
<td class="dim">&mdash;</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No worker payment data for this period.</p>
{% endif %}
</div>
<!-- ==============================================================
FOOTER
============================================================== -->
<div id="footerContent">
GENERATED {{ now|date:"d M Y H:i" }} &nbsp;&bull;&nbsp; FOXFITT CONSTRUCTION &nbsp;&bull;&nbsp; CONFIDENTIAL
</div>
</body>
</html>

View File

@ -0,0 +1,188 @@
{% load format_tags %}<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* === PAGE SETUP (A4 landscape — many columns) === */
@page {
size: a4 landscape;
margin: 1.5cm 1.5cm 1.2cm 1.5cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 0.4cm;
margin-left: 1.5cm;
margin-right: 1.5cm;
height: 0.7cm;
}
}
/* === TYPOGRAPHY === */
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 8.5pt;
line-height: 1.35;
color: #334155;
}
/* === COVER === */
.brand-eyebrow {
font-size: 7pt;
font-weight: bold;
color: #10b981;
letter-spacing: 3pt;
margin-bottom: 3pt;
}
table.cover-band {
width: 100%;
border-collapse: collapse;
margin: 0;
}
table.cover-band td {
border-top: 1pt solid #10b981;
border-bottom: 1pt solid #10b981;
padding: 7pt 0;
vertical-align: middle;
}
table.cover-band td.cover-title {
font-size: 18pt;
font-weight: bold;
color: #0f172a;
line-height: 1;
width: 60%;
}
table.cover-band td.cover-date {
font-size: 10pt;
color: #1e293b;
text-align: right;
white-space: nowrap;
}
.cover-filters {
font-size: 9pt;
color: #64748b;
letter-spacing: 0.2pt;
margin: 4pt 0 10pt 0;
}
/* === MAIN TABLE === */
table.report {
width: 100%;
border-collapse: collapse;
margin-top: 6pt;
font-size: 7.5pt;
}
table.report th {
text-align: left;
font-size: 6.5pt;
font-weight: bold;
color: #64748b;
letter-spacing: 0.5pt;
padding: 4pt 4pt 5pt 4pt;
border-bottom: 1pt solid #0f172a;
white-space: nowrap;
}
table.report th.r { text-align: right; }
table.report th.c { text-align: center; }
table.report td {
padding: 4pt;
border-bottom: 0.4pt solid #e2e8f0;
color: #334155;
vertical-align: top;
}
table.report td.r {
text-align: right;
white-space: nowrap;
}
table.report td.c {
text-align: center;
}
table.report td.name {
font-weight: bold;
color: #0f172a;
}
table.report td.total {
font-weight: bold;
color: #0f172a;
text-align: right;
white-space: nowrap;
}
table.report td.dim {
color: #94a3b8;
}
.empty {
color: #94a3b8;
font-size: 9pt;
padding: 10pt 0;
text-align: center;
}
#footerContent {
font-size: 6.5pt;
color: #94a3b8;
text-align: center;
letter-spacing: 0.5pt;
border-top: 0.3pt solid #e2e8f0;
padding-top: 3pt;
}
</style>
</head>
<body>
<!-- === COVER === -->
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
<table class="cover-band">
<tr>
<td class="cover-title">Worker Roster Report</td>
<td class="cover-date">{{ total_workers }} worker{{ total_workers|pluralize }}</td>
</tr>
</table>
<div class="cover-filters">
Status: {{ status|capfirst }}
{% if project_name %} &nbsp;&bull;&nbsp; {{ project_name }}{% endif %}
{% if team_name %} &nbsp;&bull;&nbsp; {{ team_name }}{% endif %}
</div>
<!-- === REPORT TABLE === -->
{% if rows %}
<table class="report">
<tr>
<th>NAME</th>
<th>ID</th>
<th class="r">SALARY</th>
<th class="c">ACTIVE</th>
<th class="r">DAYS</th>
<th>PROJECTS</th>
<th>TEAMS</th>
<th>FIRST PAYSLIP</th>
<th>LAST PAYSLIP</th>
<th class="r">TOTAL PAID</th>
<th class="c">CERTS</th>
<th class="c">WARN</th>
</tr>
{% for r in rows %}
<tr>
<td class="name">{{ r.worker.name }}</td>
<td class="dim">{{ r.worker.id_number }}</td>
<td class="r">R&nbsp;{{ r.worker.monthly_salary|money }}</td>
<td class="c">{% if r.worker.active %}Yes{% else %}&mdash;{% endif %}</td>
<td class="r">{{ r.days_worked }}</td>
<td>{{ r.projects|join:", " }}</td>
<td>{{ r.teams|join:", " }}</td>
<td>{{ r.first_payslip_date|date:"d M Y"|default:"—" }}</td>
<td>{{ r.last_payslip_date|date:"d M Y"|default:"—" }}</td>
<td class="total">R&nbsp;{{ r.total_paid_lifetime|money }}</td>
<td class="c">{{ r.certs_active }}/{{ r.certs_total }}</td>
<td class="c">{% if r.warnings_count %}{{ r.warnings_count }}{% else %}&mdash;{% endif %}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">No workers match the filter.</p>
{% endif %}
<div id="footerContent">
GENERATED {{ now|date:"d M Y H:i" }} &nbsp;&bull;&nbsp; FOXFITT CONSTRUCTION &nbsp;&bull;&nbsp; CONFIDENTIAL
</div>
</body>
</html>

View File

@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Project Batch Report | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Project Batch Report</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_projects }} project{{ total_projects|pluralize }} — lifetime aggregates
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'project_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
<i class="fas fa-file-csv me-1"></i>Export CSV
</a>
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Projects
</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="all" {% if status == 'all' %}selected{% endif %}>All projects</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if rows %}
<div class="table-responsive">
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th>Project</th>
<th class="text-center">Active</th>
<th>Supervisors</th>
<th>Teams</th>
<th class="text-end">Workers</th>
<th class="text-end">Worker-Days</th>
<th>First Activity</th>
<th>Last Activity</th>
<th class="text-end">Labour Cost</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="fw-medium">
<a href="{% url 'project_detail' r.project.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.project.name }}</a>
</td>
<td class="text-center">
{% if r.project.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ r.supervisors|join:", "|default:"—" }}</td>
<td style="max-width: 200px;">
{% for t in r.teams %}<span class="badge me-1 mb-1" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6;">{{ t }}</span>{% endfor %}
</td>
<td class="text-end">{{ r.distinct_workers }}</td>
<td class="text-end">{{ r.worker_days }}</td>
<td>{{ r.first_date|date:"d M Y"|default:'—' }}</td>
<td>{{ r.last_date|date:"d M Y"|default:'—' }}</td>
<td class="text-end fw-semibold">R {{ r.total_labour_cost|money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No projects match the filter. <a href="{% url 'project_batch_report' %}">Clear filters</a>.
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,184 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{{ project.name }} | Projects | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title">
<i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>{{ project.name }}
{% if project.active %}<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
{% else %}<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
{{ project.supervisors.count }} supervisor{{ project.supervisors.count|pluralize }}
{% if project.start_date or project.end_date %}
&nbsp;|&nbsp; {{ project.start_date|date:"d M Y"|default:'?' }} → {{ project.end_date|date:"d M Y"|default:'ongoing' }}
{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'project_edit' project.id %}" class="btn btn-accent shadow-sm"><i class="fas fa-pencil-alt me-1"></i>Edit</a>
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back</a>
</div>
</div>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#supervisors" type="button">Supervisors <span class="badge bg-secondary ms-1">{{ project.supervisors.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#teams" type="button">Teams <span class="badge bg-secondary ms-1">{{ teams_worked.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers" type="button">Workers <span class="badge bg-secondary ms-1">{{ workers_worked.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="profile">
<div class="card">
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-3">Name</dt> <dd class="col-sm-9 fw-semibold">{{ project.name }}</dd>
<dt class="col-sm-3">Description</dt> <dd class="col-sm-9" style="color: var(--text-secondary);">{{ project.description|default:'—'|linebreaksbr }}</dd>
<dt class="col-sm-3">Start Date</dt> <dd class="col-sm-9">{{ project.start_date|date:"d M Y"|default:'—' }}</dd>
<dt class="col-sm-3">End Date</dt> <dd class="col-sm-9">{{ project.end_date|date:"d M Y"|default:'—' }}</dd>
<dt class="col-sm-3">Active</dt> <dd class="col-sm-9">{% if project.active %}<span class="text-success"><i class="fas fa-check-circle me-1"></i>Yes</span>{% else %}<span class="text-muted">No</span>{% endif %}</dd>
</dl>
</div>
</div>
</div>
<div class="tab-pane fade" id="supervisors">
<div class="card">
<div class="card-body p-0">
{% if project.supervisors.all %}
<table class="table table-hover mb-0">
<thead><tr><th>Username</th><th>Full Name</th><th>Email</th></tr></thead>
<tbody>
{% for s in project.supervisors.all %}
<tr>
<td class="fw-medium">{{ s.username }}</td>
<td>{{ s.get_full_name|default:'—' }}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ s.email|default:'—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center py-4 mb-0">No supervisors assigned. <a href="{% url 'project_edit' project.id %}">Assign some</a>.</p>
{% endif %}
</div>
</div>
</div>
<div class="tab-pane fade" id="teams">
<div class="card">
<div class="card-body p-0">
{% if teams_worked %}
<table class="table table-hover mb-0">
<thead><tr><th>Team</th><th>Supervisor</th><th class="text-end">Workers</th></tr></thead>
<tbody>
{% for t in teams_worked %}
<tr>
<td class="fw-medium"><a href="{% url 'team_detail' t.id %}" style="color: var(--text-main); text-decoration: none;">{{ t.name }}</a></td>
<td style="color: var(--text-secondary);">{{ t.supervisor.username|default:'—' }}</td>
<td class="text-end">{{ t.workers.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center py-4 mb-0">No teams have logged work on this project.</p>
{% endif %}
</div>
</div>
</div>
<div class="tab-pane fade" id="workers">
<div class="card">
<div class="card-body p-0">
{% if workers_worked %}
<table class="table table-hover mb-0">
<thead><tr><th>Worker</th><th>ID</th><th class="text-end">Salary</th></tr></thead>
<tbody>
{% for w in workers_worked %}
<tr>
<td class="fw-medium"><a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">{{ w.name }}</a></td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
<td class="text-end">R {{ w.monthly_salary|money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center py-4 mb-0">No workers have logged work on this project.</p>
{% endif %}
</div>
</div>
</div>
<div class="tab-pane fade" id="history">
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Activity Summary</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-7">Total Days Worked</dt> <dd class="col-sm-5 fw-semibold">{{ days_worked }}</dd>
<dt class="col-sm-7">Total Labour Cost</dt> <dd class="col-sm-5 fw-semibold">R {{ total_labour_cost|money }}</dd>
<dt class="col-sm-7">First Activity</dt> <dd class="col-sm-5">{{ first_activity|date:"d M Y"|default:'—' }}</dd>
<dt class="col-sm-7">Last Activity</dt> <dd class="col-sm-5">{{ last_activity|date:"d M Y"|default:'—' }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Labour Cost by Team</h6></div>
<div class="card-body p-0">
{% if cost_breakdown %}
<table class="table table-sm mb-0">
<thead><tr><th>Team</th><th class="text-end">Days</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for c in cost_breakdown %}
<tr>
<td>{{ c.team }}</td>
<td class="text-end">{{ c.worker_days }}</td>
<td class="text-end fw-semibold">R {{ c.total|money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h6 class="m-0 fw-bold">Recent Work Logs (last 10)</h6></div>
<div class="card-body p-0">
{% if recent_logs %}
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Team</th><th class="text-end">Workers</th></tr></thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.team.name|default:'—' }}</td>
<td class="text-end">{{ log.workers.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,104 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{% if is_new %}Add Project{% else %}Edit {{ project.name }}{% endif %} | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title">
<i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>
{% if is_new %}Add Project{% else %}Edit {{ project.name }}{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if is_new %}All fields except Name are optional.
{% else %}Update any section and Save.{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% if project %}{% url 'project_detail' project.id %}{% else %}{% url 'project_list' %}{% endif %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>Cancel
</a>
</div>
</div>
{% if form.errors %}
<div class="alert alert-danger">
<strong>Please fix the errors below.</strong>
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-info-circle me-2" style="color: var(--accent);"></i>Project Basics</h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label fw-semibold">Name *</label>
{{ form.name }}
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
</div>
<div class="col-md-4 align-self-end">
<div class="form-check form-switch pt-2">
{{ form.active }}
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active</label>
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Description</label>
{{ form.description }}
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-calendar-alt me-2" style="color: var(--accent);"></i>Timeline</h6></div>
<div class="card-body">
<p class="text-muted small mb-3">Optional. Use to record when the project started and expected completion date.</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Start Date</label>
{{ form.start_date }}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">End Date</label>
{{ form.end_date }}
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-user-shield me-2" style="color: var(--accent);"></i>Supervisors</h6></div>
<div class="card-body">
<p class="text-muted small mb-2">Tick staff users responsible for this project. A project can have multiple supervisors.</p>
<div style="max-height: 320px; overflow-y: auto; padding-right: 6px;">
{% for choice in form.supervisors %}
<div class="form-check">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">{{ choice.choice_label }}</label>
</div>
{% empty %}
<p class="text-muted small mb-0">No staff users available to assign. Create users in Django admin first.</p>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-5">
<a href="{% if project %}{% url 'project_detail' project.id %}{% else %}{% url 'project_list' %}{% endif %}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-accent btn-lg">
<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,103 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Projects | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Projects</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_count }} project{{ total_count|pluralize }}
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'project_new' %}" class="btn btn-accent shadow-sm">
<i class="fas fa-plus me-1"></i>Add Project
</a>
<a href="{% url 'project_batch_report' %}" class="btn btn-primary shadow-sm">
<i class="fas fa-table me-1"></i>Batch Report
</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small fw-semibold mb-1">Search</label>
<input type="text" name="q" value="{{ q }}" class="form-control" placeholder="Project name or description...">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if status == 'all' %}selected{% endif %}>All projects</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-search me-1"></i>Filter</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Supervisors</th>
<th class="text-end">Workers</th>
<th>Start</th>
<th>End</th>
<th class="text-center">Active</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr>
<td class="fw-medium">
<a href="{% url 'project_detail' p.id %}" style="color: var(--text-main); text-decoration: none;">{{ p.name }}</a>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{% for s in p.supervisors.all|slice:":2" %}{{ s.username }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if p.supervisors.count > 2 %}<span class="text-muted"> +{{ p.supervisors.count|add:"-2" }}</span>{% endif %}
{% if p.supervisors.count == 0 %}—{% endif %}
</td>
<td class="text-end">{{ p.workers_count }}</td>
<td style="font-size: 0.85rem;">{{ p.start_date|date:"d M Y"|default:'—' }}</td>
<td style="font-size: 0.85rem;">{{ p.end_date|date:"d M Y"|default:'—' }}</td>
<td class="text-center">
{% if p.active %}<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
{% else %}<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>{% endif %}
</td>
<td class="text-end">
<a href="{% url 'project_detail' p.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="View details"><i class="fas fa-eye"></i></a>
<a href="{% url 'project_edit' p.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Edit"><i class="fas fa-pencil-alt"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No projects{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
{% if q or status != 'active' %}<br><a href="{% url 'project_list' %}">Clear filters</a>{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,329 @@
{% extends 'base.html' %}
{% load static %}
{% load format_tags %}
{% block title %}Payroll Report | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === REPORT HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 d-print-none">
<div>
<h1 class="page-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Payroll Report</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
&nbsp;|&nbsp; {{ project_name }} &nbsp;|&nbsp; {{ team_name }}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<!-- New Report: opens the same config modal as the Dashboard -->
<button type="button" class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
<i class="fas fa-download me-1"></i>Download PDF
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Dashboard
</a>
</div>
</div>
<!-- === PRINT HEADER === -->
<div class="d-none d-print-block mb-4">
<h2 class="text-center fw-bold mb-1">FoxFitt Construction &mdash; Payroll Report</h2>
<p class="text-center mb-0" style="font-size: 0.9rem;">
{{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
&nbsp;|&nbsp; {{ project_name }} &nbsp;|&nbsp; {{ team_name }}
</p>
</div>
<!-- ===================================================================
ALL TIME & THIS YEAR — big-picture context, now shown FIRST so
readers see the lifetime/YTD picture before the selected period.
=================================================================== -->
<div class="row g-3 mb-4">
<!-- All Time by Project -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time &mdash; Projects</h6>
</div>
<div class="card-body p-0">
{% if alltime_projects %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in alltime_projects %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
<!-- All Time by Team -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time &mdash; Teams</h6>
</div>
<div class="card-body p-0">
{% if alltime_teams %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in alltime_teams %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
<!-- This Year by Project -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} &mdash; Projects</h6>
</div>
<div class="card-body p-0">
{% if year_projects %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in year_projects %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
<!-- This Year by Team -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} &mdash; Teams</h6>
</div>
<div class="card-body p-0">
{% if year_teams %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in year_teams %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
</div>
<!-- ===================================================================
SELECTED PERIOD — detailed breakdown
Summary cards (totals for the chosen date range) now live here
under the Selected Period heading, grouped as Loans pair and
Advances pair for quick scanning.
=================================================================== -->
<h5 class="fw-bold mb-3" style="color: var(--text-primary);">
<i class="fas fa-filter me-2" style="color: var(--accent);"></i>Selected Period: {{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
</h5>
<!-- === SUMMARY CARDS — scoped to the selected period === -->
<!-- Order: Total Paid Out, Worker-Days, Loans pair, Advances pair -->
<div class="row g-3 mb-4">
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--danger h-100 p-3">
<div class="stat-label">Total Paid Out</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ total_paid_out|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--info h-100 p-3">
<div class="stat-label">Worker-Days</div>
<div class="stat-value" style="font-size: 1.1rem;">{{ total_worker_days }}</div>
</div>
</div>
<!-- Loans pair (issued, then outstanding) -->
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="stat-label">Loans Issued</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ loans_issued|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="stat-label">Loans Outstanding</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ loans_outstanding|money }}</div>
</div>
</div>
<!-- Advances pair (issued, then outstanding) -->
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="stat-label">Advances Issued</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ advances_issued|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="stat-label">Advances Outstanding</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ advances_outstanding|money }}</div>
</div>
</div>
</div>
<!-- Payments by Date + Adjustment Summary -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-calendar-day me-2" style="color: var(--accent);"></i>Payments by Date</h6>
</div>
<div class="card-body p-0">
{% if payments_by_date %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th class="text-end">Amount Paid</th></tr></thead>
<tbody>
{% for item in payments_by_date %}
<tr><td>{{ item.date|date:"d M Y" }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No payments in this period.</p>{% endif %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-sliders-h me-2" style="color: var(--accent);"></i>Adjustment Summary</h6>
</div>
<div class="card-body p-0">
{% if adjustment_totals %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Category</th><th class="text-end">Total</th></tr></thead>
<tbody>
{% for item in adjustment_totals %}
<tr><td>{{ item.label }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No adjustments in this period.</p>{% endif %}
</div>
</div>
</div>
</div>
<!-- Labour Cost by Project + by Team (selected period) -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Labour Cost by Project</h6>
</div>
<div class="card-body p-0">
{% if cost_per_project %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Project</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
<tbody>
{% for item in cost_per_project %}
<tr><td>{{ item.project }}</td><td class="text-end">{{ item.worker_days }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No project cost data.</p>{% endif %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Labour Cost by Team</h6>
</div>
<div class="card-body p-0">
{% if cost_per_team %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Team</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
<tbody>
{% for item in cost_per_team %}
<tr><td>{{ item.team }}</td><td class="text-end">{{ item.worker_days }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No team cost data.</p>{% endif %}
</div>
</div>
</div>
</div>
<!-- Worker Breakdown -->
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-user-friends me-2" style="color: var(--accent);"></i>Worker Breakdown</h6>
</div>
<div class="card-body p-0">
{% if worker_breakdown %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Worker</th>
<th class="text-end">Days</th>
<th class="text-end">Total Paid</th>
{% for label in active_adj_labels %}
<th class="text-end d-none d-md-table-cell" style="font-size: 0.75rem;">{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for w in worker_breakdown %}
<tr>
<td class="fw-medium">{{ w.name }}</td>
<td class="text-end">{{ w.days }}</td>
<td class="text-end fw-semibold">R {{ w.total_paid|money }}</td>
{% for val in w.adj_values %}
<td class="text-end d-none d-md-table-cell" style="font-size: 0.8rem;">{% if val %}R {{ val|money }}{% else %}-{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No worker payment data for this period.</p>{% endif %}
</div>
</div>
<!-- Bottom Action Bar -->
<div class="d-flex justify-content-between align-items-center d-print-none">
<a href="{% url 'home' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back to Dashboard</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-plus me-1"></i>New Report
</button>
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent"><i class="fas fa-download me-1"></i>Download PDF</a>
</div>
</div>
</div>
<!-- === REPORT CONFIGURATION MODAL ===
Shared partial — same modal the Dashboard uses, so clicking
"New Report" here opens the familiar config screen without
navigating away. -->
{% include 'core/_report_config_modal.html' %}
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Team Batch Report | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Team Batch Report</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_teams }} team{{ total_teams|pluralize }} — lifetime aggregates
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'team_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
<i class="fas fa-file-csv me-1"></i>Export CSV
</a>
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Teams
</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="all" {% if status == 'all' %}selected{% endif %}>All teams</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if rows %}
<div class="table-responsive">
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th>Team</th>
<th>Supervisor</th>
<th class="text-center">Active</th>
<th>Pay Schedule</th>
<th class="text-end">Workers</th>
<th class="text-end">Days</th>
<th>Projects</th>
<th class="text-end">Labour Cost</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="fw-medium">
<a href="{% url 'team_detail' r.team.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.team.name }}</a>
</td>
<td style="color: var(--text-secondary);">{{ r.team.supervisor.username|default:'—' }}</td>
<td class="text-center">
{% if r.team.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{% if r.team.pay_frequency %}{{ r.team.get_pay_frequency_display }}{% else %}—{% endif %}
</td>
<td class="text-end">{{ r.worker_count }}</td>
<td class="text-end">{{ r.days_worked }}</td>
<td style="max-width: 220px;">
{% for p in r.projects %}<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p }}</span>{% endfor %}
</td>
<td class="text-end fw-semibold">R {{ r.total_labour_cost|money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No teams match the filter. <a href="{% url 'team_batch_report' %}">Clear filters</a>.
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,175 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{{ team.name }} | Teams | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title">
<i class="fas fa-users me-2" style="color: var(--accent);"></i>{{ team.name }}
{% if team.active %}<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
{% else %}<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
Supervised by <strong>{{ team.supervisor.username|default:'— no supervisor —' }}</strong>
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'team_edit' team.id %}" class="btn btn-accent shadow-sm"><i class="fas fa-pencil-alt me-1"></i>Edit</a>
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back</a>
</div>
</div>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#pay-schedule" type="button">Pay Schedule</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers" type="button">Workers <span class="badge bg-secondary ms-1">{{ workers.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="profile">
<div class="card">
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-3">Name</dt> <dd class="col-sm-9 fw-semibold">{{ team.name }}</dd>
<dt class="col-sm-3">Supervisor</dt> <dd class="col-sm-9">{{ team.supervisor.username|default:'—' }}</dd>
<dt class="col-sm-3">Pay Frequency</dt> <dd class="col-sm-9">{{ team.get_pay_frequency_display|default:'— not set —' }}</dd>
<dt class="col-sm-3">Pay Start Date</dt> <dd class="col-sm-9">{{ team.pay_start_date|date:"d M Y"|default:'—' }}</dd>
<dt class="col-sm-3">Active</dt> <dd class="col-sm-9">{% if team.active %}<span class="text-success"><i class="fas fa-check-circle me-1"></i>Yes</span>{% else %}<span class="text-muted">No</span>{% endif %}</dd>
</dl>
</div>
</div>
</div>
<div class="tab-pane fade" id="pay-schedule">
<div class="card">
<div class="card-body">
{% if pay_periods %}
<p class="text-muted small mb-3">Current + upcoming pay periods (based on the team's pay frequency and start date).</p>
<table class="table table-sm mb-0" style="font-size: 0.9rem;">
<thead><tr><th>#</th><th>Period Start</th><th>Period End</th></tr></thead>
<tbody>
{% for p in pay_periods %}
<tr>
<td class="fw-semibold">{% if forloop.first %}Current{% else %}+{{ forloop.counter0 }}{% endif %}</td>
<td>{{ p.0|date:"d M Y" }}</td>
<td>{{ p.1|date:"d M Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center py-4 mb-0">No pay schedule set — assign a pay frequency and start date to see upcoming periods.</p>
{% endif %}
</div>
</div>
</div>
<div class="tab-pane fade" id="workers">
<div class="card">
<div class="card-body p-0">
{% if workers %}
<table class="table table-hover mb-0">
<thead><tr><th>Name</th><th>ID</th><th class="text-end">Salary</th><th class="text-center">Active</th></tr></thead>
<tbody>
{% for w in workers %}
<tr>
<td class="fw-medium"><a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">{{ w.name }}</a></td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
<td class="text-end">R {{ w.monthly_salary|money }}</td>
<td class="text-center">
{% if w.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center py-4 mb-0">No workers in this team. <a href="{% url 'team_edit' team.id %}">Add some</a>.</p>
{% endif %}
</div>
</div>
</div>
<div class="tab-pane fade" id="history">
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Work Summary</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-7">Total Days Worked</dt> <dd class="col-sm-5 fw-semibold">{{ days_worked }}</dd>
<dt class="col-sm-7">Total Labour Cost</dt> <dd class="col-sm-5 fw-semibold">R {{ total_labour_cost|money }}</dd>
<dt class="col-sm-7">Projects Worked On</dt> <dd class="col-sm-5">{{ projects_worked.count }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Projects</h6></div>
<div class="card-body">
{% if projects_worked %}
{% for p in projects_worked %}
<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p.name }}</span>
{% endfor %}
{% else %}
<p class="text-muted small mb-0">No projects yet.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h6 class="m-0 fw-bold">Labour Cost Breakdown by Project</h6></div>
<div class="card-body p-0">
{% if cost_breakdown %}
<table class="table table-sm mb-0">
<thead><tr><th>Project</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
<tbody>
{% for c in cost_breakdown %}
<tr>
<td>{{ c.project }}</td>
<td class="text-end">{{ c.worker_days }}</td>
<td class="text-end fw-semibold">R {{ c.total|money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h6 class="m-0 fw-bold">Recent Work Logs (last 10)</h6></div>
<div class="card-body p-0">
{% if recent_logs %}
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Project</th><th class="text-end">Workers</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-end">{{ log.workers.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,115 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{% if is_new %}Add Team{% else %}Edit {{ team.name }}{% endif %} | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title">
<i class="fas fa-users me-2" style="color: var(--accent);"></i>
{% if is_new %}Add Team{% else %}Edit {{ team.name }}{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if is_new %}Give the team a name; supervisor and workers are optional but recommended.
{% else %}Update any section and Save.{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% if team %}{% url 'team_detail' team.id %}{% else %}{% url 'team_list' %}{% endif %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>Cancel
</a>
</div>
</div>
{% if form.errors %}
<div class="alert alert-danger">
<strong>Please fix the errors below.</strong>
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-info-circle me-2" style="color: var(--accent);"></i>Team Basics</h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Name *</label>
{{ form.name }}
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Supervisor
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Staff user responsible for this team's daily work logs and payroll"></i>
</label>
{{ form.supervisor }}
</div>
<div class="col-12">
<div class="form-check form-switch">
{{ form.active }}
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active (shown in forms and dropdowns)</label>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-calendar-alt me-2" style="color: var(--accent);"></i>Pay Schedule</h6></div>
<div class="card-body">
<p class="text-muted small mb-3">Optional. If set, payroll calculations use this schedule to determine pay periods. Leave both blank if this team doesn't have a fixed schedule.</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Pay Frequency</label>
{{ form.pay_frequency }}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Pay Start Date
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Anchor date — the FIRST day of the very first pay period. Future periods are calculated forward from this date. Never needs updating once set."></i>
</label>
{{ form.pay_start_date }}
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</h6></div>
<div class="card-body">
<p class="text-muted small mb-2">Tick workers to include in this team. Inactive workers are marked with a grey badge — you can still select them.</p>
<div style="max-height: 400px; overflow-y: auto; padding-right: 6px;">
{% for choice in form.workers %}
<div class="form-check">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
{% with worker=choice.choice_value %}
{# show an inactive badge next to inactive workers for visual scanning #}
{% endwith %}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-5">
<a href="{% if team %}{% url 'team_detail' team.id %}{% else %}{% url 'team_list' %}{% endif %}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-accent btn-lg">
<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,99 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Teams | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Teams</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_count }} team{{ total_count|pluralize }}
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'team_new' %}" class="btn btn-accent shadow-sm">
<i class="fas fa-plus me-1"></i>Add Team
</a>
<a href="{% url 'team_batch_report' %}" class="btn btn-primary shadow-sm">
<i class="fas fa-table me-1"></i>Batch Report
</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small fw-semibold mb-1">Search</label>
<input type="text" name="q" value="{{ q }}" class="form-control" placeholder="Team name...">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if status == 'all' %}selected{% endif %}>All teams</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-search me-1"></i>Filter</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body p-0">
{% if teams %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Supervisor</th>
<th class="text-end">Workers</th>
<th>Pay Schedule</th>
<th class="text-center">Active</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for t in teams %}
<tr>
<td class="fw-medium">
<a href="{% url 'team_detail' t.id %}" style="color: var(--text-main); text-decoration: none;">{{ t.name }}</a>
</td>
<td style="color: var(--text-secondary);">{{ t.supervisor.username|default:'—' }}</td>
<td class="text-end">{{ t.workers_count }}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{% if t.pay_frequency %}{{ t.get_pay_frequency_display }}{% if t.pay_start_date %} from {{ t.pay_start_date|date:"d M Y" }}{% endif %}{% else %}—{% endif %}
</td>
<td class="text-center">
{% if t.active %}<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
{% else %}<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>{% endif %}
</td>
<td class="text-end">
<a href="{% url 'team_detail' t.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="View details"><i class="fas fa-eye"></i></a>
<a href="{% url 'team_edit' t.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Edit"><i class="fas fa-pencil-alt"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No teams{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
{% if q or status != 'active' %}<br><a href="{% url 'team_list' %}">Clear filters</a>{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,130 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Worker Batch Report | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Worker Batch Report</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_workers }} worker{{ total_workers|pluralize }} — lifetime aggregates
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'worker_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
<i class="fas fa-file-csv me-1"></i>Export CSV
</a>
<a href="{% url 'worker_batch_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
<i class="fas fa-download me-1"></i>Download PDF
</a>
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Workers
</a>
</div>
</div>
<!-- === FILTER BAR === -->
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Project <span class="text-muted fw-normal">(optional)</span></label>
<select name="project" class="form-select">
<option value="">All projects</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if project_id|stringformat:"s" == p.id|stringformat:"s" %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Team <span class="text-muted fw-normal">(optional)</span></label>
<select name="team" class="form-select">
<option value="">All teams</option>
{% for t in teams %}
<option value="{{ t.id }}" {% if team_id|stringformat:"s" == t.id|stringformat:"s" %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
</div>
</form>
</div>
</div>
<!-- === REPORT TABLE === -->
<div class="card">
<div class="card-body p-0">
{% if rows %}
<div class="table-responsive">
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th class="text-end">Salary</th>
<th class="text-center">Active</th>
<th class="text-end">Days</th>
<th>Projects</th>
<th>Teams</th>
<th>First Payslip</th>
<th>Last Payslip</th>
<th class="text-end">Total Paid</th>
<th class="text-center">Certs</th>
<th class="text-center">Warnings</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="fw-medium">
<a href="{% url 'worker_detail' r.worker.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.worker.name }}</a>
</td>
<td style="color: var(--text-secondary);">{{ r.worker.id_number }}</td>
<td class="text-end">R {{ r.worker.monthly_salary|money }}</td>
<td class="text-center">
{% if r.worker.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
</td>
<td class="text-end">{{ r.days_worked }}</td>
<td style="font-size: 0.8rem; max-width: 200px;">{% for p in r.projects %}<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p }}</span>{% endfor %}</td>
<td style="font-size: 0.8rem; max-width: 160px;">{% for t in r.teams %}<span class="badge me-1 mb-1" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6;">{{ t }}</span>{% endfor %}</td>
<td>{{ r.first_payslip_date|date:"d M Y"|default:'—' }}</td>
<td>{{ r.last_payslip_date|date:"d M Y"|default:'—' }}</td>
<td class="text-end fw-semibold">R {{ r.total_paid_lifetime|money }}</td>
<td class="text-center">
<span title="Active / Total">{{ r.certs_active }}/{{ r.certs_total }}</span>
{% if r.certs_expiring %}<br><small style="color: #f59e0b;"><i class="fas fa-clock"></i> {{ r.certs_expiring }} expiring</small>{% endif %}
{% if r.certs_expired %}<br><small style="color: #ef4444;"><i class="fas fa-exclamation-circle"></i> {{ r.certs_expired }} expired</small>{% endif %}
</td>
<td class="text-center">
{% if r.warnings_count %}<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">{{ r.warnings_count }}</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No workers match the filter. <a href="{% url 'worker_batch_report' %}">Clear filters</a>.
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,333 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{{ worker.name }} | Workers | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div class="d-flex align-items-center">
{% if worker.photo %}
<img src="{{ worker.photo.url }}" alt="{{ worker.name }}" class="me-3"
style="width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid var(--accent);">
{% else %}
<div class="me-3 d-flex align-items-center justify-content-center"
style="width: 64px; height: 64px; border-radius: 50%; background: var(--accent-subtle); color: var(--accent); font-size: 1.5rem; font-weight: bold;">
{{ worker.name|make_list|first|upper }}
</div>
{% endif %}
<div>
<h1 class="page-title mb-0">
{{ worker.name }}
{% if worker.active %}
<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
{% else %}
<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>
{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
{{ worker.id_number }}
{% if worker.phone_number %} &nbsp;|&nbsp; <i class="fas fa-phone me-1"></i>{{ worker.phone_number }}{% endif %}
</p>
</div>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'worker_edit' worker.id %}" class="btn btn-accent shadow-sm">
<i class="fas fa-pencil-alt me-1"></i>Edit
</a>
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- === TABS === -->
<ul class="nav nav-tabs mb-3" id="workerTabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#certs" type="button">Certifications <span class="badge bg-secondary ms-1">{{ certs.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#warnings" type="button">Warnings <span class="badge bg-secondary ms-1">{{ warnings.count }}</span></button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
</ul>
<div class="tab-content">
<!-- === PROFILE TAB === -->
<div class="tab-pane fade show active" id="profile">
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Personal & Pay</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-5">Monthly Salary</dt><dd class="col-sm-7 fw-semibold">R {{ worker.monthly_salary|money }}</dd>
<dt class="col-sm-5">Daily Rate</dt> <dd class="col-sm-7">R {{ worker.daily_rate|money }}</dd>
<dt class="col-sm-5">Employment Date</dt><dd class="col-sm-7">{{ worker.employment_date|date:"d M Y" }}</dd>
<dt class="col-sm-5">
Tax No
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75em;"
data-bs-toggle="tooltip" title="Registered Tax Number"></i>
</dt>
<dd class="col-sm-7">{{ worker.tax_number|default:'—' }}</dd>
<dt class="col-sm-5">
UIF
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75em;"
data-bs-toggle="tooltip" title="Unemployment Insurance Fund number"></i>
</dt>
<dd class="col-sm-7">{{ worker.uif_number|default:'—' }}</dd>
<dt class="col-sm-5">
Bank
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75em;"
data-bs-toggle="tooltip" title="Account at which Institution"></i>
</dt>
<dd class="col-sm-7">{{ worker.bank_name|default:'—' }}</dd>
<dt class="col-sm-5">Acc No.</dt>
<dd class="col-sm-7">{{ worker.bank_account_number|default:'—' }}</dd>
<dt class="col-sm-5">Notes</dt> <dd class="col-sm-7" style="color: var(--text-secondary);">{{ worker.notes|default:'—'|linebreaksbr }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">PPE Sizing</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-5">Shoe Size</dt> <dd class="col-sm-7">{{ worker.shoe_size|default:'—' }}</dd>
<dt class="col-sm-5">Overall Top</dt> <dd class="col-sm-7">{{ worker.overall_top_size|default:'—' }}</dd>
<dt class="col-sm-5">Pants</dt> <dd class="col-sm-7">{{ worker.pants_size|default:'—' }}</dd>
<dt class="col-sm-5">T-Shirt</dt> <dd class="col-sm-7">{{ worker.tshirt_size|default:'—' }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Documents</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-5">Photo</dt>
<dd class="col-sm-7">{% if worker.photo %}<a href="{{ worker.photo.url }}" target="_blank">View</a>{% else %}—{% endif %}</dd>
<dt class="col-sm-5">ID Document</dt>
<dd class="col-sm-7">{% if worker.id_document %}<a href="{{ worker.id_document.url }}" target="_blank">View / Download</a>{% else %}—{% endif %}</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Driver's License</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-5">Has License</dt>
<dd class="col-sm-7">{% if worker.has_drivers_license %}<span class="text-success"><i class="fas fa-check-circle me-1"></i>Yes</span>{% else %}<span class="text-muted">No</span>{% endif %}</dd>
<dt class="col-sm-5">
Code
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75em;"
data-bs-toggle="tooltip" title="Drivers License Code (e.g. A, B, C, EB, EC)"></i>
</dt>
<dd class="col-sm-7">{{ worker.drivers_license_code|default:'—' }}</dd>
<dt class="col-sm-5">License File</dt>
<dd class="col-sm-7">{% if worker.drivers_license %}<a href="{{ worker.drivers_license.url }}" target="_blank">View / Download</a>{% else %}—{% endif %}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- === CERTS TAB === -->
<div class="tab-pane fade" id="certs">
<div class="card">
<div class="card-body p-0">
{% if certs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Type</th>
<th>Issued</th>
<th>Valid Until</th>
<th>Status</th>
<th>Document</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for c in certs %}
<tr>
<td class="fw-medium">{{ c.get_cert_type_display }}</td>
<td>{{ c.issued_date|date:"d M Y"|default:'—' }}</td>
<td>{{ c.valid_until|date:"d M Y"|default:'—' }}</td>
<td>
{% if c.is_expired %}
<span class="badge" style="background: rgba(239, 68, 68, 0.15); color: #ef4444;"><i class="fas fa-exclamation-circle me-1"></i>Expired</span>
{% elif c.expires_soon %}
<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;"><i class="fas fa-clock me-1"></i>Expires soon</span>
{% else %}
<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;"><i class="fas fa-check-circle me-1"></i>Valid</span>
{% endif %}
</td>
<td>{% if c.document %}<a href="{{ c.document.url }}" target="_blank"><i class="fas fa-file-alt me-1"></i>View</a>{% else %}—{% endif %}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ c.notes|default:'—'|truncatechars:80 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-4 mb-0" style="color: var(--text-secondary);">
No certifications recorded. <a href="{% url 'worker_edit' worker.id %}">Add one</a>.
</p>
{% endif %}
</div>
</div>
</div>
<!-- === WARNINGS TAB === -->
<div class="tab-pane fade" id="warnings">
<div class="card">
<div class="card-body p-0">
{% if warnings %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Date</th>
<th>Severity</th>
<th>Reason</th>
<th>Issued By</th>
<th>Document</th>
</tr>
</thead>
<tbody>
{% for wr in warnings %}
<tr>
<td>{{ wr.date|date:"d M Y" }}</td>
<td>
{% if wr.severity == 'verbal' %}
<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-main);">Verbal</span>
{% elif wr.severity == 'written' %}
<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">Written</span>
{% else %}
<span class="badge" style="background: rgba(239, 68, 68, 0.15); color: #ef4444;">Final</span>
{% endif %}
</td>
<td class="fw-medium">{{ wr.reason }}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ wr.issued_by.username|default:'—' }}</td>
<td>{% if wr.document %}<a href="{{ wr.document.url }}" target="_blank"><i class="fas fa-file-alt me-1"></i>View</a>{% else %}—{% endif %}</td>
</tr>
{% if wr.description %}
<tr>
<td colspan="5" style="color: var(--text-secondary); font-size: 0.85rem; padding-left: 2rem;">{{ wr.description|linebreaksbr }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-4 mb-0" style="color: var(--text-secondary);">
No warnings recorded.
</p>
{% endif %}
</div>
</div>
</div>
<!-- === HISTORY TAB === -->
<div class="tab-pane fade" id="history">
<div class="row g-3">
<!-- Summary card -->
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Work Summary</h6></div>
<div class="card-body">
<dl class="row mb-0" style="font-size: 0.9rem;">
<dt class="col-sm-6">Days Worked</dt> <dd class="col-sm-6 fw-semibold">{{ days_worked }}</dd>
<dt class="col-sm-6">Total Paid</dt> <dd class="col-sm-6 fw-semibold">R {{ total_paid|money }}</dd>
<dt class="col-sm-6">First Payslip</dt> <dd class="col-sm-6">{{ first_payslip.date|date:"d M Y"|default:'—' }}</dd>
<dt class="col-sm-6">Last Payslip</dt> <dd class="col-sm-6">{{ last_payslip.date|date:"d M Y"|default:'—' }}</dd>
</dl>
</div>
</div>
</div>
<!-- Projects card -->
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h6 class="m-0 fw-bold">Projects & Teams</h6></div>
<div class="card-body">
<p class="small fw-semibold mb-1" style="color: var(--text-secondary);">Projects</p>
{% if projects_worked %}
{% for p in projects_worked %}
<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p.name }}</span>
{% endfor %}
{% else %}
<p class="text-muted small mb-3">None</p>
{% endif %}
<p class="small fw-semibold mb-1 mt-3" style="color: var(--text-secondary);">Teams</p>
{% if worker.teams.all %}
{% for t in worker.teams.all %}
<span class="badge me-1 mb-1" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6;">{{ t.name }}</span>
{% endfor %}
{% else %}
<p class="text-muted small">None</p>
{% endif %}
</div>
</div>
</div>
<!-- Active loans -->
{% if active_loans %}
<div class="col-12">
<div class="card">
<div class="card-header"><h6 class="m-0 fw-bold">Active Loans & Advances</h6></div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead><tr><th>Type</th><th>Date</th><th class="text-end">Principal</th><th class="text-end">Remaining</th><th>Reason</th></tr></thead>
<tbody>
{% for l in active_loans %}
<tr>
<td>{{ l.get_loan_type_display }}</td>
<td>{{ l.date|date:"d M Y" }}</td>
<td class="text-end">R {{ l.principal_amount|money }}</td>
<td class="text-end fw-semibold">R {{ l.remaining_balance|money }}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ l.reason|truncatechars:60 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Recent payslips -->
<div class="col-12">
<div class="card">
<div class="card-header"><h6 class="m-0 fw-bold">Recent Payslips (last 10)</h6></div>
<div class="card-body p-0">
{% if payslips %}
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th class="text-end">Amount Paid</th><th>Action</th></tr></thead>
<tbody>
{% for p in payslips %}
<tr>
<td>{{ p.date|date:"d M Y" }}</td>
<td class="text-end fw-semibold">R {{ p.amount_paid|money }}</td>
<td><a href="{% url 'payslip_detail' p.id %}">View payslip</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-center py-4 mb-0" style="color: var(--text-secondary);">No payslips yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,490 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}{% if is_new %}Add Worker{% else %}Edit {{ worker.name }}{% endif %} | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title">
<i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>
{% if is_new %}Add Worker{% else %}Edit {{ worker.name }}{% endif %}
</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if is_new %}Fill in the sections below. All fields except Name, ID Number, and Monthly Salary are optional.
{% else %}Update any section. File uploads max 5 MB each.{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
{% if not is_new %}
<a href="{% url 'worker_detail' worker.id %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>Cancel
</a>
{% else %}
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>Cancel
</a>
{% endif %}
</div>
</div>
{% if form.errors or cert_formset.errors or warn_formset.errors %}
<div class="alert alert-danger">
<strong>Please fix the errors below.</strong>
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<!-- === SECTION 1: PERSONAL & PAY === -->
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-user me-2" style="color: var(--accent);"></i>Personal & Pay</h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Name *</label>
{{ form.name }}
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">ID Number *</label>
{{ form.id_number }}
{% if form.id_number.errors %}<div class="invalid-feedback d-block">{{ form.id_number.errors|first }}</div>{% endif %}
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Phone</label>
{{ form.phone_number }}
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Monthly Salary (R) *</label>
{{ form.monthly_salary }}
{% if form.monthly_salary.errors %}<div class="invalid-feedback d-block">{{ form.monthly_salary.errors|first }}</div>{% endif %}
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Employment Date</label>
{{ form.employment_date }}
</div>
<!-- === BANKING & TAX === -->
<!-- Each label carries a small info-icon with a Bootstrap tooltip
explaining what the field is for. Uses the global tooltip
init from base.html. -->
<div class="col-md-3">
<label class="form-label fw-semibold">
Tax No
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Registered Tax Number"></i>
</label>
{{ form.tax_number }}
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">
UIF
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Unemployment Insurance Fund number"></i>
</label>
{{ form.uif_number }}
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">
Bank
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Account at which Institution"></i>
</label>
{{ form.bank_name }}
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">
Acc No.
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Bank account number"></i>
</label>
{{ form.bank_account_number }}
</div>
<div class="col-12">
<label class="form-label fw-semibold">Notes</label>
{{ form.notes }}
</div>
<div class="col-12">
<div class="form-check form-switch">
{{ form.active }}
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active (shown in forms and dropdowns)</label>
</div>
</div>
</div>
</div>
</div>
<!-- === SECTION 2: PPE SIZING === -->
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-tshirt me-2" style="color: var(--accent);"></i>PPE Sizing</h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3"><label class="form-label fw-semibold">Shoe</label> {{ form.shoe_size }}</div>
<div class="col-md-3"><label class="form-label fw-semibold">Overall Top</label>{{ form.overall_top_size }}</div>
<div class="col-md-3"><label class="form-label fw-semibold">Pants</label> {{ form.pants_size }}</div>
<div class="col-md-3"><label class="form-label fw-semibold">T-Shirt</label> {{ form.tshirt_size }}</div>
</div>
</div>
</div>
<!-- === SECTION 3: DOCUMENTS === -->
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Documents <span class="text-muted fw-normal small ms-2">5 MB max per file</span></h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Photo</label>
{{ form.photo }}
{% if form.photo.errors %}<div class="invalid-feedback d-block">{{ form.photo.errors|first }}</div>{% endif %}
{% if worker.photo %}<small class="d-block mt-1"><a href="{{ worker.photo.url }}" target="_blank">View current</a></small>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">ID Document</label>
{{ form.id_document }}
{% if form.id_document.errors %}<div class="invalid-feedback d-block">{{ form.id_document.errors|first }}</div>{% endif %}
{% if worker.id_document %}<small class="d-block mt-1"><a href="{{ worker.id_document.url }}" target="_blank">View current</a></small>{% endif %}
</div>
</div>
</div>
</div>
<!-- === SECTION 4: DRIVER'S LICENSE === -->
<div class="card mb-3">
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-id-card me-2" style="color: var(--accent);"></i>Driver's License</h6></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch pt-2">
{{ form.has_drivers_license }}
<label class="form-check-label fw-semibold" for="{{ form.has_drivers_license.id_for_label }}">Has driver's license</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">
Code
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
data-bs-toggle="tooltip" title="Drivers License Code (e.g. A, B, C, EB, EC)"></i>
</label>
{{ form.drivers_license_code }}
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">License File</label>
{{ form.drivers_license }}
{% if form.drivers_license.errors %}<div class="invalid-feedback d-block">{{ form.drivers_license.errors|first }}</div>{% endif %}
{% if worker.drivers_license %}<small class="d-block mt-1"><a href="{{ worker.drivers_license.url }}" target="_blank">View current</a></small>{% endif %}
</div>
</div>
</div>
</div>
<!-- === SECTION 5: CERTIFICATIONS === -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold"><i class="fas fa-certificate me-2" style="color: var(--accent);"></i>Certifications</h6>
<button type="button" class="btn btn-sm btn-accent" id="addCertBtn"><i class="fas fa-plus me-1"></i>Add Certification</button>
</div>
<div class="card-body">
{{ cert_formset.management_form }}
<div id="certRows">
{% for cf in cert_formset %}
<div class="cert-row formset-row border rounded p-3 mb-2 {% if cf.DELETE.value %}row-marked-delete{% endif %}" data-index="{{ forloop.counter0 }}">
{{ cf.id }}
<!-- Hidden DELETE flag — toggled by the trash button below -->
<input type="checkbox" class="row-delete-flag d-none" name="{{ cf.DELETE.html_name }}" {% if cf.DELETE.value %}checked{% endif %}>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1 fw-semibold">Type</label>
{{ cf.cert_type }}
</div>
<div class="col-md-2">
<label class="form-label small mb-1 fw-semibold">Issued</label>
{{ cf.issued_date }}
</div>
<div class="col-md-2">
<label class="form-label small mb-1 fw-semibold">Valid Until</label>
{{ cf.valid_until }}
</div>
<div class="col-md-4">
<label class="form-label small mb-1 fw-semibold">Document</label>
{{ cf.document }}
{% if cf.instance.document %}<small class="d-block mt-1"><a href="{{ cf.instance.document.url }}" target="_blank">Current</a></small>{% endif %}
</div>
<div class="col-md-1 text-end">
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
data-bs-toggle="tooltip" title="Remove this certification when you save">
<i class="fas fa-trash"></i>
</button>
<a href="#" class="row-undo-btn small text-decoration-none d-none"
data-bs-toggle="tooltip" title="Keep this certification — undo removal">
<i class="fas fa-undo me-1"></i>Undo
</a>
</div>
<div class="col-12">
<label class="form-label small mb-1 fw-semibold">Notes</label>
{{ cf.notes }}
</div>
</div>
</div>
{% endfor %}
</div>
<p class="text-muted small mb-0 mt-2">Types: Skills, PDP (Professional Driving Permit), First Aid, Medical, Work at Height.</p>
<!-- Hidden blank template. JS clones the <template>.content node (a
safe DocumentFragment — no innerHTML) and rewrites its name
attributes to replace __PREFIX__ with the new index. -->
<template id="certBlankTemplate">
<div class="cert-row formset-row border rounded p-3 mb-2" data-index="__PREFIX__">
<input type="checkbox" class="row-delete-flag d-none" name="certificates-__PREFIX__-DELETE">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1 fw-semibold">Type</label>
<select name="certificates-__PREFIX__-cert_type" class="form-select form-select-sm">
<option value="skills">Skills Certificate</option>
<option value="pdp">PDP (Professional Driving Permit)</option>
<option value="first_aid">First Aid</option>
<option value="medical">Medical</option>
<option value="work_at_height">Work at Height</option>
</select>
</div>
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Issued</label>
<input type="date" name="certificates-__PREFIX__-issued_date" class="form-control form-control-sm"></div>
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Valid Until</label>
<input type="date" name="certificates-__PREFIX__-valid_until" class="form-control form-control-sm"></div>
<div class="col-md-4"><label class="form-label small mb-1 fw-semibold">Document</label>
<input type="file" name="certificates-__PREFIX__-document" class="form-control form-control-sm"></div>
<div class="col-md-1 text-end">
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
data-bs-toggle="tooltip" title="Remove this certification when you save">
<i class="fas fa-trash"></i>
</button>
<a href="#" class="row-undo-btn small text-decoration-none d-none"
data-bs-toggle="tooltip" title="Keep this certification — undo removal">
<i class="fas fa-undo me-1"></i>Undo
</a>
</div>
<div class="col-12"><label class="form-label small mb-1 fw-semibold">Notes</label>
<textarea name="certificates-__PREFIX__-notes" rows="2" class="form-control form-control-sm"></textarea></div>
</div>
</div>
</template>
</div>
</div>
<!-- === SECTION 6: WARNINGS === -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold"><i class="fas fa-exclamation-triangle me-2" style="color: var(--color-warning);"></i>Warnings & Disciplinary</h6>
<button type="button" class="btn btn-sm btn-accent" id="addWarnBtn"><i class="fas fa-plus me-1"></i>Add Warning</button>
</div>
<div class="card-body">
{{ warn_formset.management_form }}
<div id="warnRows">
{% for wf in warn_formset %}
<div class="warn-row formset-row border rounded p-3 mb-2 {% if wf.DELETE.value %}row-marked-delete{% endif %}" data-index="{{ forloop.counter0 }}">
{{ wf.id }}
<input type="checkbox" class="row-delete-flag d-none" name="{{ wf.DELETE.html_name }}" {% if wf.DELETE.value %}checked{% endif %}>
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small mb-1 fw-semibold">Date</label>
{{ wf.date }}
</div>
<div class="col-md-2">
<label class="form-label small mb-1 fw-semibold">Severity</label>
{{ wf.severity }}
</div>
<div class="col-md-4">
<label class="form-label small mb-1 fw-semibold">Reason</label>
{{ wf.reason }}
</div>
<div class="col-md-3">
<label class="form-label small mb-1 fw-semibold">Document</label>
{{ wf.document }}
{% if wf.instance.document %}<small class="d-block mt-1"><a href="{{ wf.instance.document.url }}" target="_blank">Current</a></small>{% endif %}
</div>
<div class="col-md-1 text-end">
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
data-bs-toggle="tooltip" title="Remove this warning when you save">
<i class="fas fa-trash"></i>
</button>
<a href="#" class="row-undo-btn small text-decoration-none d-none"
data-bs-toggle="tooltip" title="Keep this warning — undo removal">
<i class="fas fa-undo me-1"></i>Undo
</a>
</div>
<div class="col-12">
<label class="form-label small mb-1 fw-semibold">Description</label>
{{ wf.description }}
</div>
</div>
</div>
{% endfor %}
</div>
<template id="warnBlankTemplate">
<div class="warn-row formset-row border rounded p-3 mb-2" data-index="__PREFIX__">
<input type="checkbox" class="row-delete-flag d-none" name="warnings-__PREFIX__-DELETE">
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Date</label>
<input type="date" name="warnings-__PREFIX__-date" class="form-control form-control-sm"></div>
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Severity</label>
<select name="warnings-__PREFIX__-severity" class="form-select form-select-sm">
<option value="verbal">Verbal</option>
<option value="written">Written</option>
<option value="final">Final</option>
</select>
</div>
<div class="col-md-4"><label class="form-label small mb-1 fw-semibold">Reason</label>
<input type="text" name="warnings-__PREFIX__-reason" class="form-control form-control-sm" placeholder="Short summary"></div>
<div class="col-md-3"><label class="form-label small mb-1 fw-semibold">Document</label>
<input type="file" name="warnings-__PREFIX__-document" class="form-control form-control-sm"></div>
<div class="col-md-1 text-end">
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
data-bs-toggle="tooltip" title="Remove this warning when you save">
<i class="fas fa-trash"></i>
</button>
<a href="#" class="row-undo-btn small text-decoration-none d-none"
data-bs-toggle="tooltip" title="Keep this warning — undo removal">
<i class="fas fa-undo me-1"></i>Undo
</a>
</div>
<div class="col-12"><label class="form-label small mb-1 fw-semibold">Description</label>
<textarea name="warnings-__PREFIX__-description" rows="2" class="form-control form-control-sm" placeholder="Full context..."></textarea></div>
</div>
</div>
</template>
</div>
</div>
<!-- === SUBMIT === -->
<div class="d-flex justify-content-between align-items-center mb-5">
{% if not is_new %}
<a href="{% url 'worker_detail' worker.id %}" class="btn btn-outline-secondary">Cancel</a>
{% else %}
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">Cancel</a>
{% endif %}
<button type="submit" class="btn btn-accent btn-lg">
<i class="fas fa-save me-1"></i>
{% if is_new %}Create Worker{% else %}Save Changes{% endif %}
</button>
</div>
</form>
</div>
<!-- === FORMSET INTERACTION JAVASCRIPT (DOM-safe) ===
Handles three concerns:
1. "+ Add Certification / Warning" — clones a <template> safely via
content.cloneNode (no innerHTML), rewrites __PREFIX__ in name
attributes to the new formset index, and re-inits tooltips on the
newly-added row.
2. Trash button — marks a row for deletion by ticking the hidden
DELETE checkbox and adding .row-marked-delete. Swaps the trash
button out for an Undo link.
3. Undo link — reverses the above.
Uses event delegation on the row containers so new rows work without
extra wiring. -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- "+ Add" button: clone the blank template and append ---
function wireAdd(formsetPrefix, containerId, templateId, btnId) {
var btn = document.getElementById(btnId);
var template = document.getElementById(templateId);
var container = document.getElementById(containerId);
var totalForms = document.querySelector('input[name="' + formsetPrefix + '-TOTAL_FORMS"]');
if (!btn || !template || !container || !totalForms) return;
btn.addEventListener('click', function() {
var current = parseInt(totalForms.value, 10);
var fragment = template.content.cloneNode(true);
// Replace __PREFIX__ in every `name` attribute
fragment.querySelectorAll('[name]').forEach(function(el) {
if (el.name && el.name.indexOf('__PREFIX__') !== -1) {
el.name = el.name.split('__PREFIX__').join(current);
}
});
var outer = fragment.querySelector('[data-index]');
if (outer) outer.setAttribute('data-index', current);
// Append, then init tooltips on the new row (so the trash button
// tooltip works immediately without a page reload).
container.appendChild(fragment);
if (window.initTooltipsIn) {
// Re-query the last added row for tooltip init
var rows = container.querySelectorAll('.formset-row');
var newRow = rows[rows.length - 1];
if (newRow) window.initTooltipsIn(newRow);
}
totalForms.value = current + 1;
});
}
wireAdd('certificates', 'certRows', 'certBlankTemplate', 'addCertBtn');
wireAdd('warnings', 'warnRows', 'warnBlankTemplate', 'addWarnBtn');
// --- Trash + Undo: mark/unmark a row for deletion ---
// Event delegation on each container — works for both existing rows
// (rendered server-side) and dynamically added rows.
function markForDelete(row) {
row.classList.add('row-marked-delete');
var flag = row.querySelector('.row-delete-flag');
if (flag) flag.checked = true;
// Hide the remove button, show the undo link (Bootstrap utility)
var removeBtn = row.querySelector('.row-remove-btn');
var undoLink = row.querySelector('.row-undo-btn');
if (removeBtn) removeBtn.classList.add('d-none');
if (undoLink) undoLink.classList.remove('d-none');
// Close any hovered tooltip on the remove button to clean up the UI
if (removeBtn && bootstrap.Tooltip.getInstance(removeBtn)) {
bootstrap.Tooltip.getInstance(removeBtn).hide();
}
}
function undoDelete(row) {
row.classList.remove('row-marked-delete');
var flag = row.querySelector('.row-delete-flag');
if (flag) flag.checked = false;
var removeBtn = row.querySelector('.row-remove-btn');
var undoLink = row.querySelector('.row-undo-btn');
if (removeBtn) removeBtn.classList.remove('d-none');
if (undoLink) undoLink.classList.add('d-none');
if (undoLink && bootstrap.Tooltip.getInstance(undoLink)) {
bootstrap.Tooltip.getInstance(undoLink).hide();
}
}
// Delegate on both row containers
['certRows', 'warnRows'].forEach(function(containerId) {
var container = document.getElementById(containerId);
if (!container) return;
container.addEventListener('click', function(ev) {
var removeBtn = ev.target.closest('.row-remove-btn');
var undoLink = ev.target.closest('.row-undo-btn');
if (removeBtn) {
ev.preventDefault();
var row = removeBtn.closest('.formset-row');
if (row) markForDelete(row);
} else if (undoLink) {
ev.preventDefault();
var row = undoLink.closest('.formset-row');
if (row) undoDelete(row);
}
});
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% load format_tags %}
{% block title %}Workers | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ total_count }} worker{{ total_count|pluralize }}
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
<a href="{% url 'worker_new' %}" class="btn btn-accent shadow-sm">
<i class="fas fa-plus me-1"></i>Add Worker
</a>
<a href="{% url 'worker_batch_report' %}" class="btn btn-primary shadow-sm">
<i class="fas fa-table me-1"></i>Batch Report
</a>
<a href="{% url 'export_workers_csv' %}" class="btn btn-outline-secondary">
<i class="fas fa-file-csv me-1"></i>Export CSV
</a>
</div>
</div>
<!-- === SEARCH + FILTER BAR === -->
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small fw-semibold mb-1">Search</label>
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="Name, ID number, or phone...">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-1"></i>Filter
</button>
</div>
</form>
</div>
</div>
<!-- === WORKER TABLE === -->
<div class="card">
<div class="card-body p-0">
{% if workers %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>ID Number</th>
<th>Phone</th>
<th class="text-end">Salary</th>
<th class="text-end">Days Worked</th>
<th class="text-center">Active</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for w in workers %}
<tr>
<td class="fw-medium">
<a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">
{{ w.name }}
</a>
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.phone_number|default:'—' }}</td>
<td class="text-end fw-semibold">R {{ w.monthly_salary|money }}</td>
<td class="text-end">{{ w.days_worked }}</td>
<td class="text-center">
{% if w.active %}
<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
{% else %}
<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<a href="{% url 'worker_detail' w.id %}" class="btn btn-sm btn-outline-secondary" title="View details">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'worker_edit' w.id %}" class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="fas fa-pencil-alt"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No workers{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
{% if q or status != 'active' %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

View File

@ -0,0 +1,27 @@
# === CUSTOM TEMPLATE FILTERS ===
# Number formatting filters for South African currency display.
# Usage: {% load format_tags %} then {{ value|money }}
from django import template
register = template.Library()
@register.filter
def money(value):
"""Format a number with space thousands separators and 2 decimal places.
South African convention uses spaces instead of commas:
8000 8 000.00
22500 22 500.00
400.0 400.00
-300.00 -300.00
"""
try:
num = float(value)
except (ValueError, TypeError):
return value
# Python's :, format gives comma separators — swap commas for spaces
formatted = f"{num:,.2f}"
return formatted.replace(",", " ")

View File

@ -59,6 +59,40 @@ urlpatterns = [
# View a completed payslip (print-friendly page)
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
# === REPORTS ===
# Generate payroll reports filtered by date range, project, or team
path('report/', views.generate_report, name='generate_report'),
path('report/pdf/', views.generate_report_pdf, name='generate_report_pdf'),
# === WORKERS ===
# Admin-friendly worker management UI (alternative to /admin/core/worker/)
path('workers/', views.worker_list, name='worker_list'),
path('workers/new/', views.worker_edit, name='worker_new'),
path('workers/<int:worker_id>/', views.worker_detail, name='worker_detail'),
path('workers/<int:worker_id>/edit/', views.worker_edit, name='worker_edit'),
# Batch report (table of all workers with aggregated history)
path('workers/report/', views.worker_batch_report, name='worker_batch_report'),
path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'),
path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'),
# === TEAMS ===
# Admin-friendly team management UI (alternative to /admin/core/team/)
path('teams/', views.team_list, name='team_list'),
path('teams/new/', views.team_edit, name='team_new'),
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'),
path('teams/<int:team_id>/', views.team_detail, name='team_detail'),
path('teams/<int:team_id>/edit/', views.team_edit, name='team_edit'),
# === PROJECTS ===
# Admin-friendly project management UI (alternative to /admin/core/project/)
path('projects/', views.project_list, name='project_list'),
path('projects/new/', views.project_edit, name='project_new'),
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'),
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
path('projects/<int:project_id>/edit/', views.project_edit, name='project_edit'),
# === EXPENSE RECEIPTS ===
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
path('receipts/create/', views.create_receipt, name='create_receipt'),
@ -70,4 +104,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

@ -1,26 +1,94 @@
# === PDF GENERATION ===
# Converts a Django HTML template into a PDF file using xhtml2pdf.
# Used for payslip and receipt PDF attachments sent via email.
# Converts a Django HTML template into a PDF file using WeasyPrint.
# Used for payslip, receipt, and payroll report PDFs (both email and
# browser download).
#
# IMPORTANT: xhtml2pdf is imported LAZILY (inside the function, not at the
# top of the file). This is intentional — if xhtml2pdf fails to install on
# the server (missing C libraries), the rest of the app still works.
# Only PDF generation will fail gracefully.
# Why WeasyPrint?
# ----------------
# Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade
# HTML-to-PDF renderer — it supports modern CSS features that xhtml2pdf
# could not: flexbox, grid, @font-face (custom web fonts), box-shadow,
# border-radius, transforms, and proper CSS cascade handling.
#
# WeasyPrint has system dependencies (Pango, Cairo, GDK-PixBuf) that
# xhtml2pdf did not need. On Flatlogic's Linux environment these are
# already installed system-wide — no extra setup. On Windows dev
# machines, we install the GTK3 runtime (via `winget install
# tschoonj.GTKForWindows`) and then the `_ensure_gtk_on_windows()`
# helper below tells Python where to find the DLLs.
#
# Why the Windows DLL dance?
# --------------------------
# Since Python 3.8, `PATH` alone is no longer sufficient to load native
# DLLs — Python requires explicit `os.add_dll_directory()` calls for
# security reasons. This helper walks common GTK3 install locations
# and registers the first one found. On Linux it's a no-op.
#
# IMPORTANT: WeasyPrint is imported LAZILY (inside the function, not
# at the top of the file). This is intentional — if WeasyPrint or its
# system libraries are missing, the rest of the app still works;
# only PDF generation fails gracefully and returns None.
import logging
from io import BytesIO
import os
import sys
from django.conf import settings
from django.template.loader import get_template
logger = logging.getLogger(__name__)
# Common install locations for the GTK3 runtime on Windows.
# Checked in order; first hit wins.
_WINDOWS_GTK_PATHS = (
r"C:\Program Files\GTK3-Runtime Win64\bin",
r"C:\Program Files (x86)\GTK3-Runtime Win64\bin",
)
# Module-level flag so we only add the DLL directory once per process.
_gtk_registered = False
def _ensure_gtk_on_windows():
"""Register the GTK3 runtime DLL directory on Windows.
WeasyPrint loads DLLs two ways:
1. `ctypes.util.find_library('gobject-2.0-0')` reads os.environ['PATH']
2. `ctypes.CDLL(...)` uses Windows DLL search, affected by
`os.add_dll_directory()` since Python 3.8
We hit both paths so the library is discoverable regardless of
which route WeasyPrint chooses. This is a no-op on Linux/macOS
and on Windows when GTK is already registered.
"""
global _gtk_registered
if _gtk_registered or sys.platform != "win32":
return
for path in _WINDOWS_GTK_PATHS:
if os.path.isdir(path):
# Prepend to PATH so find_library() locates the DLLs.
current_path = os.environ.get("PATH", "")
if path.lower() not in current_path.lower():
os.environ["PATH"] = path + os.pathsep + current_path
# Register via add_dll_directory for ctypes.CDLL() loads.
if hasattr(os, "add_dll_directory"):
os.add_dll_directory(path)
_gtk_registered = True
logger.debug("Registered GTK3 runtime directory: %s", path)
return
logger.debug(
"No GTK3 runtime directory found on Windows in %s", _WINDOWS_GTK_PATHS,
)
def render_to_pdf(template_src, context_dict=None):
"""
Render a Django template to PDF bytes.
Render a Django template to PDF bytes using WeasyPrint.
Args:
template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html')
context_dict: Template context variables
template_src: Path to the template
(e.g. 'core/pdf/payslip_pdf.html')
context_dict: Template context variables
Returns:
PDF content as bytes, or None if there was an error.
@ -28,25 +96,37 @@ def render_to_pdf(template_src, context_dict=None):
if context_dict is None:
context_dict = {}
# --- Lazy import: only load xhtml2pdf when actually generating a PDF ---
# This prevents the entire app from crashing if xhtml2pdf isn't installed.
# On Windows we need to tell Python where the GTK3 DLLs live
# BEFORE importing weasyprint. Harmless no-op on Linux/macOS.
_ensure_gtk_on_windows()
# --- Lazy import: only load WeasyPrint when actually generating a PDF ---
# ImportError covers the Python package being missing.
# OSError covers missing native libs (Pango, Cairo) at runtime.
try:
from xhtml2pdf import pisa
except ImportError:
from weasyprint import HTML
except (ImportError, OSError) as e:
logger.error(
"xhtml2pdf is not installed — cannot generate PDF. "
"Install it with: pip install xhtml2pdf"
"WeasyPrint is not available — cannot generate PDF. "
"Install with: pip install weasyprint. "
"On Windows, also install the GTK3 runtime via "
"`winget install tschoonj.GTKForWindows`. "
"Underlying error: %s", e,
)
return None
# Load and render the HTML template
# Render the Django template to HTML first
template = get_template(template_src)
html = template.render(context_dict)
# Convert HTML to PDF
result = BytesIO()
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
if not pdf.err:
return result.getvalue()
return None
# Convert HTML to PDF bytes.
# base_url lets WeasyPrint resolve relative paths to static files
# (e.g. fonts in static/fonts/, images in static/img/). We fall back
# to "." so the call still succeeds when STATIC_ROOT isn't set
# (e.g. during local dev before collectstatic has run).
base = getattr(settings, "STATIC_ROOT", None) or "."
try:
return HTML(string=html, base_url=base).write_pdf()
except Exception as e:
logger.exception("WeasyPrint render failed: %s", e)
return None

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,294 @@
# Deploy Readiness Audit + Fork Plan
**Created:** 21 April 2026
**Status:** Draft plan — not yet executed
**Author:** Claude, based on full-repo audit
---
## Goal
Get the app into a known-good state before attempting to test-deploy it on a non-Flatlogic platform, so that any issues surfaced during the test deploy are *real deploy issues*, not latent code bugs. Then fork the repo into a clean branch for the test-deploy.
---
## ⛔ BLOCKING QUESTION — Platform choice
You said "test deploy this using **SpacetimeDB**." I want to confirm before we proceed, because **SpacetimeDB is not a platform where this Django app can run**.
**SpacetimeDB** (Clockwork Labs) is a specialized relational database designed for real-time multiplayer games — you write your app logic in **Rust or C#**, compile it to WebAssembly, and it runs *inside* the database. It doesn't speak the PostgreSQL/MySQL wire protocol, doesn't host HTTP apps, and isn't a Django deploy target. Using SpacetimeDB would mean **rewriting the entire payroll app from scratch in Rust** (multiple weeks, not a test deploy).
I think you may have meant one of these instead:
| Candidate | What it is | Django fit |
|---|---|---|
| **Fly.io** | Containerised Django hosting, cheap, persistent volumes | ✅ Drop-in |
| **Railway** | Similar to Fly.io, simpler UX | ✅ Drop-in |
| **Render** | Mature PaaS, handles Django well | ✅ Drop-in |
| **Supabase** | PostgreSQL-based BaaS (not Django-host, but could replace MySQL) | Partial — you'd still need a Django host |
| **PlanetScale** | Serverless MySQL replacement | Partial — same |
| **DigitalOcean App Platform** | Containerised Django hosting | ✅ Drop-in |
**Please confirm which you meant.** The rest of this plan assumes **Fly.io** as a sensible default for "test deploy a Django app cheaply on a platform other than Flatlogic" — swap the platform in if you meant something different.
---
## Part A — Audit findings (prioritised)
I did a full-repo audit looking at performance, latent bugs, and Flatlogic-specific deploy risks. Findings below with **severity** — fix CRITICALs before any deploy (Flatlogic or elsewhere).
### 🔴 CRITICAL — fix before ANY deploy
#### 1. Gmail App Password committed to source control
**File:** `config/settings.py:177`
```python
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
```
The fallback value `cwvhpcwyijneukax` is a real 16-character Gmail App Password. Anyone with read access to this repo (public GitHub history included) has full send-access to `konrad@foxfitt.co.za`.
**Also on lines 176, 188** — `EMAIL_HOST_USER` defaults to Konrad's real email, and `SPARK_RECEIPT_EMAIL` defaults to a real inbound address.
**Fix:** Remove the string fallbacks. If env var missing, either raise an error on startup or disable email features. ~10 minutes.
**Additional action:** Rotate the Gmail App Password immediately after fix lands, since it's already exposed in git history.
---
#### 2. SECRET_KEY has a weak default
**File:** `config/settings.py:20`
```python
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
```
If `DJANGO_SECRET_KEY` env var isn't set on the deploy platform, Django boots with `"change-me"` — sessions become forgeable, password reset tokens become predictable.
**Fix:** Remove the fallback. If env var missing, raise `ImproperlyConfigured`. ~5 minutes.
---
### 🟠 HIGH — fix before test deploy (and before production)
#### 3. `DEBUG` defaults to `true`
**File:** `config/settings.py:21`
```python
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
```
If `DJANGO_DEBUG` env var isn't set, the app runs in DEBUG mode in production — full tracebacks on 500 errors expose DB schema, file paths, secret fragments.
**Fix:** Change default to `"false"`. ~2 minutes.
---
#### 4. Media uploads will be lost on Flatlogic rebuilds
`MEDIA_ROOT = BASE_DIR / 'media'`. Worker photos, ID documents, cert PDFs, warning PDFs all upload here. On Flatlogic, the app container is rebuilt on every deploy from git — **local filesystem is ephemeral**, so any uploaded file disappears on the next "Pull Latest".
Currently you have no uploaded files (you mentioned this), so the problem isn't visible yet. The moment you upload a worker photo and do a deploy, it's gone.
**Fix options:**
- **(a) S3/Cloudflare R2 bucket** via `django-storages` — durable, costs cents/month
- **(b) Ask Flatlogic to mount a persistent volume** at `/app/media`
- **(c) For the test deploy only:** use ephemeral storage, accept the risk for testing
Recommend (a) for production, (c) for the test deploy since no uploaded files exist yet.
---
#### 5. Batch reports load entire tables into Python memory
`worker_batch_report`, `team_batch_report`, `project_batch_report` all build a full list in memory before rendering. At ~14 workers this is fine; at 1,000+ it will strain the 512MB e2-micro RAM especially when WeasyPrint PDF rendering runs concurrently.
**Fix:** Add pagination (50 rows per page) on the HTML view. CSV/PDF can still be "export all" but should use `.iterator()` to stream rows rather than building a full list. ~30 minutes.
**Not urgent at current data scale — deferrable.**
---
#### 6. `CSRF_TRUSTED_ORIGINS` has a URL-joining bug
**File:** `config/settings.py:30-40`
If someone sets `HOST_FQDN=https://example.com` (with scheme), the settings code prepends `https://` again → `https://https://example.com`, which Django rejects and breaks CSRF validation entirely.
**Fix:** Check if the value already has a scheme before prepending. ~5 minutes.
---
### 🟡 MEDIUM — worth fixing, not deploy-blocking
#### 7. PDF generation failures not handled in email send path
`_send_payslip_email()` and `create_receipt()` both call `render_to_pdf()` which can return `None` if WeasyPrint fails. Some paths check `if pdf:`, but not all — if the return is `None`, `email.attach(filename, None, "application/pdf")` may raise or silently send an unattached email. The user would get a notification but no payslip.
**Fix:** Guard every `render_to_pdf()` return with `if pdf_bytes is None: log + skip attachment`. ~15 minutes.
---
#### 8. `price_overtime()` silently swallows all exceptions
The view loops over overtime entries and any exception inside the loop is caught and ignored — including typos, DB errors, and missing-record issues. The UI reports "Priced 5" even if 10 silently failed.
**Fix:** Catch specific expected exceptions only; log the rest. ~10 minutes.
---
#### 9. `X_FRAME_OPTIONS = 'ALLOWALL'` — clickjacking risk
Deliberately disabled for Flatlogic's iframe preview. Any third party can embed the app in their own iframe and attack logged-in users.
**Fix options:**
- On any platform *other* than Flatlogic: remove the middleware exclusion entirely
- On Flatlogic: restrict to Flatlogic's iframe parent domain via a custom middleware
Relevant mainly if deploying off Flatlogic.
---
#### 10. Hard-coded daily-rate divisor = 20
`Worker.daily_rate` = `monthly_salary / 20`. Fine as a business rule, but hardcoded with no validation. If `monthly_salary == 0`, every payslip becomes R 0.00 silently.
**Fix:** Add `monthly_salary > 0` validation in `Worker.save()` or the form. Low priority. ~10 minutes.
---
### 🟢 LOW — performance, N+1 refinements
The `index` dashboard, `payroll_dashboard`, and `work_history` views all have some `for-loop-with-related-access` patterns that would be N+1 without the existing `prefetch_related` calls. The prefetches ARE present — they're just not documented clearly enough that a future Claude/dev will preserve them.
**Fix:** Add a comment above each loop explaining the prefetch, to prevent regression. No code change needed. ~30 minutes of doc comments.
---
## Part B — Fork & test-deploy strategy
### Overview
The test deploy goal is to verify: *can this app run and function correctly on a non-Flatlogic platform, so we're not locked in*. The fork gives us an isolated place to experiment without touching the working `ai-dev`/`master` branches or our current `redesign-weasyprint` work.
### Fork strategy (git)
Since this repo is already on GitHub at `Konradzar/LabourPay_v5`, create a fork branch (not a separate GitHub fork — we don't need the ceremony):
1. From `redesign-weasyprint` branch (which has all the recent work including WeasyPrint, worker/team/project management), cut a new branch:
```
git checkout -b test-deploy-<platform>
```
2. Commit the CRITICAL fixes (#1, #2, #3) on this branch **first** — those changes should eventually land everywhere, not just in the test deploy branch.
3. Add platform-specific deploy config on top (Dockerfile, fly.toml / railway.json / render.yaml, depending on platform).
4. Push only this branch — not the other local branches — so GitHub sees it but `ai-dev` and `master` stay unaffected.
Flatlogic keeps syncing only `ai-dev`. The test-deploy branch is invisible to Flatlogic.
### Platform-specific deploy config (assuming Fly.io — adjust if you meant different)
1. **`Dockerfile`** — single-stage Python 3.13 image with WeasyPrint system deps:
```dockerfile
FROM python:3.13-slim
RUN apt-get update && apt-get install -y \
libpango-1.0-0 libcairo2 libgdk-pixbuf2.0-0 \
libffi-dev shared-mime-info \
default-libmysqlclient-dev pkg-config build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
```
2. **`fly.toml`** — minimal Fly config:
```toml
app = "foxfitt-test"
primary_region = "jnb" # Johannesburg, closest to South Africa
[env]
DJANGO_DEBUG = "false"
USE_SQLITE = "false"
HOST_FQDN = "foxfitt-test.fly.dev"
[[services]]
internal_port = 8000
protocol = "tcp"
[[services.ports]]
handlers = ["http", "tls"]
port = 443
```
3. **Secrets** (set via `fly secrets set`):
- `DJANGO_SECRET_KEY`
- `DB_NAME`, `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT` (for managed MySQL/Postgres)
- `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`
- `SPARK_RECEIPT_EMAIL`
- `DEFAULT_FROM_EMAIL`
4. **Add WhiteNoise** for static file serving (one-liner in `MIDDLEWARE` + add to `requirements.txt`) — Apache-style static serving doesn't exist on Fly.
5. **Database decision**:
- **Simplest:** provision Fly's managed MySQL (or PostgreSQL — needs settings.py `ENGINE` tweak)
- **Alternative:** external managed MySQL (PlanetScale free tier, AWS RDS)
6. **Media storage decision**:
- For test deploy: ephemeral is fine
- For anything beyond test: add `django-storages` + S3 bucket
### Test deploy verification checklist
Once deployed, verify:
- [ ] Home page loads (`/`)
- [ ] Login works with a seeded admin user
- [ ] Dashboard renders
- [ ] Create a worker via friendly UI
- [ ] Log attendance
- [ ] Process a payment (critical path)
- [ ] Download a payroll report PDF (verifies WeasyPrint + system libs)
- [ ] Generate CSV exports
- [ ] Django admin pages load
- [ ] Session cookies survive across requests
- [ ] Static files (CSS, images) load from `/static/`
- [ ] Admin can log in and out
### Rollback
Test deploy is entirely on its own branch. If it fails catastrophically, delete the Fly app and the branch. Zero impact on Flatlogic production.
---
## Part C — Recommended execution order
I don't recommend executing this as one big batch. Split into four phases, ship each, observe, then proceed:
**Phase 1 — Critical security fixes (ship to everything)**
- Remove hardcoded email credentials (#1)
- Fix SECRET_KEY default (#2)
- Fix DEBUG default (#3)
- Fix CSRF_TRUSTED_ORIGINS bug (#6)
- **Rotate exposed Gmail App Password**
- Land this on `redesign-weasyprint`, merge to `ai-dev`, let Flatlogic rebuild, verify still working.
**Phase 2 — Create the fork branch**
- Cut `test-deploy-<platform>` off `redesign-weasyprint` after Phase 1 is in.
- No code changes — just the branch exists.
**Phase 3 — Add deploy config for chosen platform**
- Dockerfile, platform config file, WhiteNoise, static-root adjustments, etc.
- Commit on `test-deploy-<platform>` only.
**Phase 4 — Deploy + verify**
- Push branch, deploy, run the verification checklist above.
- Document any platform-specific quirks in a follow-up note.
**Phase 5 (optional, only if going beyond test) — Address MEDIUM findings**
- PDF-None handling (#7)
- `price_overtime` exception leak (#8)
- Clickjacking header (#9)
- Salary validation (#10)
---
## Open questions / decisions needed
1. **What platform did you actually mean?** (SpacetimeDB blocker — see top of doc)
2. For the test deploy, is the database allowed to start empty, or do you want the production MySQL data copied in?
3. Do you want the CRITICAL fixes merged into Flatlogic production (via `ai-dev`) as part of Phase 1, or hold off until the whole plan is approved?
4. Budget sensitivity — Fly.io's free tier is ~$5/mo equivalent; Railway's is similar. Are we constrained to free-tier, or is a few dollars a month OK for test?
---
## Not in scope (explicit)
- Adding new app features (no new views, models, or migrations beyond what exists)
- Rewriting in a different language/framework (e.g., actual SpacetimeDB migration)
- Performance tuning at scale (current data size is small; defer until needed)
- Removing Flatlogic as the production platform — this is a *test* deploy to prove portability, not a migration

View File

@ -0,0 +1,663 @@
# Worker Management Expansion — Design & Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. REQUIRED SUB-SKILL: Use superpowers:test-driven-development where tests are called for.
**Goal:** Extend the Worker model with certifications + disciplinary records, add a friendly in-app worker-edit form (replacing the need to use Django admin for common edits), and build a batch worker report showing projects, teams, days worked, and first/last payslip dates.
**Architecture:**
- Two new models (`WorkerCertificate`, `WorkerWarning`) with ForeignKey to existing `Worker`.
- A worker list page + worker edit page, both admin-only, replacing most everyday admin needs.
- A batch-report page (HTML + CSV + PDF) summarising each worker's full work history.
- All new work lives inside the existing `core/` app — no new apps. Reuses existing patterns: `is_admin()` gating, `@login_required`, Bootstrap modals, `render_to_pdf()`, inline formsets.
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod), Bootstrap 5, WeasyPrint for PDFs, existing `format_tags.money` filter.
---
## Context
**Why this is being built:**
The owner (Konrad) currently manages workers via `/admin/core/worker/` — which is functional but lacks:
- Certification tracking (Skills, PDP, First Aid, Medical, Work at Height) with expiry dates and document uploads
- A disciplinary/warnings record per worker
- A friendlier edit UI than Django admin's default form
- A consolidated worker-history report (projects worked, days, payslips) for review or regulatory/auditor purposes
This plan closes all four gaps in one coherent feature.
**Outcomes when complete:**
1. Per worker, the admin can see and maintain: all 5 cert types, all warnings, in one page.
2. The admin has a worker list and worker edit page that are easier than Django admin.
3. The admin can generate a batch report of all workers' project/team/day/payslip history — viewable, CSV-exportable, PDF-exportable.
---
## Scope (explicit)
### In scope
- New models: `WorkerCertificate`, `WorkerWarning`
- Django admin registrations for both (inline on Worker)
- Worker list page (admin-only): `/workers/`
- Worker edit page (admin-only): `/workers/<id>/edit/` and `/workers/new/`
- Worker detail page (admin-only, read-only view with history): `/workers/<id>/`
- Batch worker report: `/workers/report/` (HTML), `/workers/report/csv/`, `/workers/report/pdf/`
- Nav links added to base.html
- Migrations for the two new models
- Updates to `CLAUDE.md` documenting the new pieces
### Out of scope (not in this plan — can be follow-ups)
- Cert expiry email alerts
- Worker self-service portal (only admins use this)
- Photo cropping / file optimisation
- Bulk cert upload (edit one worker at a time)
---
## Files to Create / Modify
| File | Action | Purpose |
|------|--------|---------|
| `core/models.py` | Modify | Add `WorkerCertificate` and `WorkerWarning` model classes |
| `core/migrations/0009_worker_certificates_warnings.py` | Create (auto) | Schema changes |
| `core/admin.py` | Modify | Register new models; add inlines to WorkerAdmin |
| `core/forms.py` | Modify | `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` |
| `core/views.py` | Modify | 6 new views: worker_list, worker_detail, worker_edit, worker_batch_report, worker_batch_report_csv, worker_batch_report_pdf |
| `core/urls.py` | Modify | 6 new routes |
| `core/templates/core/workers/list.html` | Create | Worker list with search + filters + "Add Worker" button |
| `core/templates/core/workers/edit.html` | Create | Section-based edit form with inline certs + warnings |
| `core/templates/core/workers/detail.html` | Create | Read-only worker profile with history tabs |
| `core/templates/core/workers/batch_report.html` | Create | HTML batch report (extends base.html) |
| `core/templates/core/pdf/workers_report_pdf.html` | Create | PDF version of batch report (uses WeasyPrint) |
| `core/templates/base.html` | Modify | Add "Workers" nav link (admin only) |
| `CLAUDE.md` | Modify | Document new models, URLs, admin patterns |
---
## Data Model
### `WorkerCertificate`
```python
class WorkerCertificate(models.Model):
"""A certification held by a worker (Skills, PDP, First Aid, etc.).
One row per (worker, cert_type) — existence of the row means the
worker currently holds this certification. Delete the row to record
that they no longer hold it. Use `valid_until` to track expiry.
"""
CERT_TYPES = [
('skills', 'Skills Certificate'),
('pdp', 'PDP (Professional Driving Permit)'),
('first_aid', 'First Aid'),
('medical', 'Medical'),
('work_at_height', 'Work at Height'),
]
worker = models.ForeignKey(
Worker, related_name='certificates', on_delete=models.CASCADE,
)
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
document = models.FileField(
upload_to='workers/certificates/', blank=True, null=True,
help_text='Scan or photo of the certificate',
)
issued_date = models.DateField(blank=True, null=True)
valid_until = models.DateField(
blank=True, null=True,
help_text='Expiry date — leave blank if the cert does not expire',
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('worker', 'cert_type')]
ordering = ['worker', 'cert_type']
def __str__(self):
return f'{self.worker.name} — {self.get_cert_type_display()}'
@property
def is_expired(self):
if not self.valid_until:
return False
return self.valid_until < timezone.now().date()
@property
def expires_soon(self):
"""True if the cert expires within the next 30 days."""
if not self.valid_until:
return False
today = timezone.now().date()
return today <= self.valid_until <= today + datetime.timedelta(days=30)
```
### `WorkerWarning`
```python
class WorkerWarning(models.Model):
"""A disciplinary warning issued to a worker."""
SEVERITY_CHOICES = [
('verbal', 'Verbal Warning'),
('written', 'Written Warning'),
('final', 'Final Warning'),
]
worker = models.ForeignKey(
Worker, related_name='warnings', on_delete=models.CASCADE,
)
date = models.DateField(default=timezone.now)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
reason = models.CharField(
max_length=200,
help_text='Short summary — e.g. "Repeated lateness"',
)
description = models.TextField(
blank=True,
help_text='Full context of what happened',
)
issued_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='warnings_issued',
)
document = models.FileField(
upload_to='workers/warnings/', blank=True, null=True,
help_text='Signed warning form (optional)',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date']
def __str__(self):
return f'{self.worker.name} — {self.get_severity_display()} ({self.date})'
```
### Migration
`python manage.py makemigrations core` → produces `0009_worker_certificates_warnings.py`.
Safe migration: two new tables, no changes to existing ones. Reversible.
---
## URL Routes (new)
Add to `core/urls.py` before the `# === EXPENSE RECEIPTS ===` section:
```python
# === WORKERS ===
# Admin-friendly worker management UI (alternative to /admin/core/worker/)
path('workers/', views.worker_list, name='worker_list'),
path('workers/new/', views.worker_edit, name='worker_new'),
path('workers/<int:worker_id>/', views.worker_detail, name='worker_detail'),
path('workers/<int:worker_id>/edit/', views.worker_edit, name='worker_edit'),
# Batch report (table of all workers with aggregated history)
path('workers/report/', views.worker_batch_report, name='worker_batch_report'),
path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'),
path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'),
```
---
## Views (new)
All admin-gated via `@login_required` + `is_admin(request.user)` → 403 if not admin.
### `worker_list(request)`
- Fetches all Workers (not just active).
- Filters: `?q=search_term`, `?status=active|inactive|all`.
- Template: `core/workers/list.html`.
- Shows table: name, ID, phone, salary, days worked, active toggle.
### `worker_detail(request, worker_id)`
- Worker profile (read-only).
- Shows: personal info, PPE, license, certs (with expiry highlights), warnings, and a "History" tab with projects/teams/days/payslips.
- Template: `core/workers/detail.html`.
### `worker_edit(request, worker_id=None)`
- GET: renders form pre-filled (or blank if `worker_id is None`).
- POST: validates + saves Worker + inline formsets for certs + warnings.
- Redirect on success: → `worker_detail`.
- Template: `core/workers/edit.html`.
### `worker_batch_report(request)`
- Builds per-worker aggregates using a shared `_build_worker_report_context()` helper (parallel to `_build_report_context`).
- Filters: `?status=`, `?project=`, `?team=`.
- Template: `core/workers/batch_report.html`.
### `worker_batch_report_csv(request)`
- Same context builder; streams a CSV with all columns.
### `worker_batch_report_pdf(request)`
- Same context builder; uses `render_to_pdf('core/pdf/workers_report_pdf.html', context)`.
### Shared helper `_build_worker_report_context(status=None, project_id=None, team_id=None)`
Returns a list of dicts, one per worker:
```python
{
'worker': worker_obj,
'projects': ['Solar Farm Alpha', 'Solar Farm Beta'], # distinct project names
'teams': ['Team Alpha'], # distinct team names
'days_worked': 47, # distinct WorkLog dates
'first_payslip_date': date(2025, 3, 14) or None,
'last_payslip_date': date(2026, 4, 5) or None,
'total_paid_lifetime': Decimal('127450.00'),
'payslip_count': 12,
'active_certs': 3,
'expiring_certs': 1, # expires within 30 days
'expired_certs': 0,
'active_warnings_count': 1, # warnings issued in last 12 months
}
```
Aggregation approach (efficient — one query per aggregate, not per worker):
```python
qs = Worker.objects.annotate(
days_worked=Count('work_logs__date', distinct=True),
first_payslip_date=Min('payroll_records__date'),
last_payslip_date=Max('payroll_records__date'),
total_paid_lifetime=Sum('payroll_records__amount_paid'),
payslip_count=Count('payroll_records', distinct=True),
)
# then a separate prefetch for projects/teams
```
---
## Forms (new)
```python
# core/forms.py
class WorkerForm(forms.ModelForm):
"""Main worker edit form — covers the flat fields on Worker."""
class Meta:
model = Worker
fields = [
'name', 'id_number', 'phone_number', 'monthly_salary',
'employment_date', 'active', 'notes',
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
'photo', 'id_document',
'has_drivers_license', 'drivers_license',
]
widgets = {
'employment_date': forms.DateInput(attrs={'type': 'date'}),
'notes': forms.Textarea(attrs={'rows': 3}),
}
WorkerCertificateFormSet = inlineformset_factory(
Worker, WorkerCertificate,
fields=['cert_type', 'document', 'issued_date', 'valid_until', 'notes'],
extra=0, # no blank rows by default
can_delete=True,
widgets={
'issued_date': forms.DateInput(attrs={'type': 'date'}),
'valid_until': forms.DateInput(attrs={'type': 'date'}),
'notes': forms.Textarea(attrs={'rows': 2}),
},
)
WorkerWarningFormSet = inlineformset_factory(
Worker, WorkerWarning,
fields=['date', 'severity', 'reason', 'description', 'document'],
extra=0,
can_delete=True,
widgets={
'date': forms.DateInput(attrs={'type': 'date'}),
'description': forms.Textarea(attrs={'rows': 3}),
},
)
```
In the edit view, `request.POST`/`request.FILES` flow through all three (form + two formsets); all must be valid before saving.
---
## Templates (new)
### `core/workers/list.html`
- Search box (name/ID) + status filter dropdown
- Table columns: Name, ID, Phone, Salary, Days Worked, Active, Actions (View / Edit / Toggle)
- Buttons: "Add Worker", "Batch Report", "Export CSV"
- Styled with existing stat-card / resource-row patterns from index.html
### `core/workers/edit.html`
- Section-based layout (no tabs — long-form scroll for easier visual review):
1. **Personal & Pay** — name, id_number, phone, salary, employment_date, active, notes
2. **PPE Sizing** — shoe, overall top, pants, t-shirt
3. **Documents** — photo, id_document
4. **Driver's License** — has_drivers_license, drivers_license file
5. **Certifications** (formset with + add button, × delete)
6. **Warnings & Disciplinary** (formset with + add button, × delete)
- Client-side JS: "Add Certification" / "Add Warning" buttons clone a hidden blank formset row and bump the TOTAL_FORMS counter (standard Django formset pattern)
- Submit button at the bottom; Cancel goes back to `worker_detail`
### `core/workers/detail.html`
- Header: worker photo, name, ID, active badge
- Tabs:
1. **Profile** — personal, PPE, license info
2. **Certifications** — list with colored badges: green (valid > 30 days), amber (expires within 30), red (expired)
3. **Warnings** — chronological list
4. **History** — projects worked, teams, days, last 10 payslips
- "Edit" button links to `worker_edit`
### `core/workers/batch_report.html`
- Report header + filter bar (status / project / team)
- Table with columns:
- Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (n/m) | Warnings
- "Export CSV" + "Download PDF" buttons at top-right
- Row click → `worker_detail`
### `core/pdf/workers_report_pdf.html`
- Print-optimized A4 layout using WeasyPrint
- Header: "FoxFitt Construction — Worker Roster Report"
- Filter summary subhead
- Table (narrower columns, landscape orientation may be needed for many fields)
- Uses the same amber accent and typography as `report_pdf.html`
---
## Navigation
`base.html` desktop topbar: add a "Workers" link after "Receipts" and before "Admin" (admin-only):
```html
{% if user.is_staff %}
<a href="{% url 'worker_list' %}" class="topbar-nav__link {% if 'worker' in request.resolver_match.url_name %}active{% endif %}">
<i class="fas fa-hard-hat"></i><span>Workers</span>
</a>
{% endif %}
```
Also add matching entries to the mobile menu (the `.mobile-menu__nav` block) and the bottom tab bar (if room).
---
## Django Admin Enhancements
Register the new models:
```python
# core/admin.py
class WorkerCertificateInline(admin.TabularInline):
model = WorkerCertificate
extra = 0
class WorkerWarningInline(admin.TabularInline):
model = WorkerWarning
extra = 0
readonly_fields = ['created_at']
@admin.register(WorkerCertificate)
class WorkerCertificateAdmin(admin.ModelAdmin):
list_display = ('worker', 'cert_type', 'valid_until', 'is_expired')
list_filter = ('cert_type',)
search_fields = ('worker__name',)
@admin.register(WorkerWarning)
class WorkerWarningAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'severity', 'reason')
list_filter = ('severity',)
search_fields = ('worker__name', 'reason')
```
Then update `WorkerAdmin` to include the inlines:
```python
class WorkerAdmin(admin.ModelAdmin):
# ...existing config...
inlines = [WorkerCertificateInline, WorkerWarningInline]
```
This means the Django admin ALSO gets the new sections — the in-app edit page is a better UX, but admin remains fully functional.
---
## Task-by-Task Execution Plan
Each task is 515 minutes. Execute in order.
### Task 1: Add the two new models + migration
**Files:**
- Modify: `core/models.py` (append `WorkerCertificate`, `WorkerWarning` classes at end of file)
- Create: `core/migrations/0009_worker_certificates_warnings.py` (auto-generated)
**Steps:**
1. Add the two model classes (code above)
2. `USE_SQLITE=true python manage.py makemigrations core`
3. `USE_SQLITE=true python manage.py migrate`
4. Verify: `python manage.py check` → no issues
5. Commit
### Task 2: Register models in Django admin
**Files:**
- Modify: `core/admin.py`
**Steps:**
1. Add `WorkerCertificateInline`, `WorkerWarningInline`, `WorkerCertificateAdmin`, `WorkerWarningAdmin` classes
2. Add `inlines = [...]` to `WorkerAdmin`
3. Verify in browser: `/admin/core/worker/<id>/change/` shows certs + warnings sections
4. Commit
### Task 3: Add WorkerForm and formsets
**Files:**
- Modify: `core/forms.py`
**Steps:**
1. Add `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet`
2. Verify: `python manage.py shell` → import forms → no errors
3. Commit
### Task 4: Add worker_list view and template
**Files:**
- Modify: `core/views.py` (+ `import` updates)
- Modify: `core/urls.py`
- Create: `core/templates/core/workers/list.html`
**Steps:**
1. Add URL route `workers/``views.worker_list`
2. Add view `worker_list(request)` with search + status filter
3. Create template with search bar, table, action buttons
4. Verify: visit `/workers/` as admin → list shows workers
5. Commit
### Task 5: Add worker_edit view + template (the big one)
**Files:**
- Modify: `core/views.py`
- Modify: `core/urls.py`
- Create: `core/templates/core/workers/edit.html`
**Steps:**
1. Add two URL routes: `workers/new/`, `workers/<id>/edit/`
2. Add view `worker_edit(request, worker_id=None)` handling both create and update
3. Create section-based template with formsets for certs + warnings
4. Add JS for "+ Add Certification" / "+ Add Warning" buttons (formset clone pattern)
5. Verify: add a worker, edit a worker, add cert, add warning → all persist
6. Commit
### Task 6: Add worker_detail view + template
**Files:**
- Modify: `core/views.py`
- Modify: `core/urls.py`
- Create: `core/templates/core/workers/detail.html`
**Steps:**
1. Add URL `workers/<id>/`
2. Add view with tab-context (profile, certs, warnings, history)
3. Template with Bootstrap tabs; cert badges styled by expiry
4. Link "Edit" button to edit view
5. Verify
6. Commit
### Task 7: Add batch report (HTML, CSV, PDF)
**Files:**
- Modify: `core/views.py` (add `_build_worker_report_context`, 3 views)
- Modify: `core/urls.py`
- Create: `core/templates/core/workers/batch_report.html`
- Create: `core/templates/core/pdf/workers_report_pdf.html`
**Steps:**
1. Write `_build_worker_report_context()` helper with the annotate/prefetch pattern
2. Add 3 URLs + 3 views
3. Create HTML template with filter bar + table
4. Create PDF template (derive from existing `report_pdf.html` structure — cover, section headings, ledger-style table)
5. Verify HTML, CSV, PDF all render correctly
6. Commit
### Task 8: Add nav links
**Files:**
- Modify: `core/templates/base.html`
**Steps:**
1. Add desktop nav link (admin-only)
2. Add mobile menu link
3. Verify on desktop and mobile layouts
4. Commit
### Task 9: Update CLAUDE.md
**Files:**
- Modify: `CLAUDE.md`
**Steps:**
1. Add `WorkerCertificate` and `WorkerWarning` to Key Models section
2. Add 7 new URL routes to URL Routes table
3. Add "Worker Management" subsection under Development Workflow
4. Commit
### Task 10: Verification pass
- Visit every new page; click every button; upload a test file to each upload field
- Try edge cases: blank forms, duplicate cert_type for same worker (should fail unique constraint), expired certs
- Regenerate PDF; open to verify layout
- Run `python manage.py check`
- Smoke-test existing features (payroll report, payment, receipt) still work
---
## Open Questions for User
The following decisions need your input before I start executing. Defaults in brackets are what I'll use if you don't answer.
### Q1: Certification types list
I'm proposing these 5 types:
- Skills Certificate
- PDP (Professional Driving Permit)
- First Aid
- Medical
- Work at Height
**Any additions, removals, or renames?** [Default: use exactly these 5]
### Q2: Warning severity levels
I'm proposing: Verbal → Written → Final. **Accept, or add a fourth (e.g. "Informal"), or use different names?** [Default: Verbal/Written/Final]
### Q3: Navigation placement
**Where does the "Workers" link go?**
- (a) Top desktop nav, admin-only, after Receipts — prominent, 1-click access
- (b) Only accessible from a button on the Dashboard — less cluttered nav
- (c) Inside the Admin dropdown / submenu
[Default: (a) — matches how Payroll is currently linked]
### Q4: Worker list — default sort?
- (a) Alphabetical by name
- (b) By employment date (newest first)
- (c) By active status, then name
[Default: (a) alphabetical]
### Q5: Coexist with Django admin?
The new worker list/edit pages would be more user-friendly, but Django admin at `/admin/core/worker/` remains fully functional.
**Keep Django admin working as a fallback for power-user edits?** [Default: yes — keep both]
### Q6: Cert expiry alerts on dashboard
Would you like the Dashboard to show a stat card like "3 certs expiring in 30 days"?
- (a) Yes, show it — helpful operationally
- (b) No, keep dashboard unchanged for now
- (c) Show, but only if count > 0 (hide the card when everything's fine)
[Default: (c) conditional display]
### Q7: File upload size limit
Certificates and warnings support file uploads. Currently no limit. Should I add a max size to prevent someone accidentally uploading a 50MB scan?
- (a) No limit
- (b) 5 MB max
- (c) 10 MB max
[Default: (b) 5 MB]
### Q8: Batch report columns
I'm proposing this column set:
Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (active/total) | Warnings
**Anything to add or remove?** Common additions could be: employment_date, phone, has_drivers_license, last-active-date.
[Default: use the list above]
### Q9: Scope bailout
If I discover during execution that any task is significantly more complex than the estimate (e.g. Task 5 turns out to need 2 hours instead of 30 minutes), should I:
- (a) Keep going, add a new task, push complete
- (b) Stop, flag the issue, let you decide
- (c) Deliver a smaller version (e.g. certs-only, no warnings) and flag warnings as follow-up
[Default: (b) stop and flag]
---
## Verification (end-to-end)
Run after all tasks complete:
```
USE_SQLITE=true python manage.py check
USE_SQLITE=true python manage.py migrate --plan # confirm no pending migrations
```
Browser smoke tests (as admin):
1. Visit `/workers/` — list renders, search works
2. Click "Add Worker" — blank form loads
3. Fill name/ID/salary, click Save → redirected to detail view
4. Click "Edit" on detail — form pre-filled
5. Click "+ Add Certification" → blank cert row appears
6. Fill cert (Medical, valid_until=next month), upload a test PDF, Save
7. Verify cert appears on detail page with green "valid" badge
8. Click "+ Add Warning", fill, Save — verify it appears on Warnings tab
9. Visit `/workers/report/` — table shows all workers with aggregates
10. Click "Export CSV" — downloads, opens in Excel/LibreOffice cleanly
11. Click "Download PDF" — renders with correct layout
12. Visit `/admin/core/worker/<id>/change/` — Django admin still works, inlines show
13. As a non-admin user, visit `/workers/` — should get 403
## Rollback
The change touches two new models with ForeignKey to Worker. If we need to undo:
1. `git reset --hard` to the last commit before this plan started
2. `python manage.py migrate core 0008` (the pre-existing migration) — drops the two new tables
3. Branches stay isolated until merged; worst case the whole thing sits on a local branch forever
---
## Estimated Timeline
- Task 1 (models + migration): 10 min
- Task 2 (admin): 5 min
- Task 3 (forms): 10 min
- Task 4 (list view): 15 min
- Task 5 (edit view + template): 40 min — biggest task; has formset JS
- Task 6 (detail view): 20 min
- Task 7 (batch report): 30 min
- Task 8 (nav): 5 min
- Task 9 (CLAUDE.md): 5 min
- Task 10 (verification): 15 min
**Total: ~2.5 hours of supervised execution**, or ~90 minutes if I can auto-execute with good test coverage.

View File

@ -0,0 +1,274 @@
# Push `redesign-weasyprint``ai-dev` — Deploy Plan
**Created:** 22 April 2026
**Status:** Draft — awaiting your approval before execution
**Target:** Deploy ~6 weeks of local work to Flatlogic production safely
---
## What's going live
### 3 commits already committed (inherited from the `redesign` branch)
- `82c1906` Redesign UI with premium orange theme, sidebar nav, bottom tab bar
- `16d0342` Fix modal z-index stacking issue
- `deef851` Fix dark mode contrast
### 40 working-directory changes from this session (must be committed)
**Security fixes** (critical):
- Remove hardcoded Gmail App Password + email defaults from `settings.py`
- Remove weak `SECRET_KEY` default (raise in prod, safe fallback in dev)
- Flip `DEBUG` default to `false`
- Fix `CSRF_TRUSTED_ORIGINS` double-scheme bug
**New database models** (3 migrations to run on production):
- `0009``WorkerCertificate` + `WorkerWarning` tables
- `0010``Worker` fields: `bank_name`, `bank_account_number`, `uif_number`, `drivers_license_code`
- `0011``Worker.tax_number` field
- Plus `0007` / `0008` (vat_type defaults) — these were made locally but never committed
**PDF engine** — xhtml2pdf → WeasyPrint (`requirements.txt` pins `weasyprint==68.1`)
**New features** (all admin-only UI):
- Worker management UI: `/workers/`, `/workers/<id>/`, `/workers/<id>/edit/`
- Team management UI: `/teams/` + detail + edit + batch report
- Project management UI: `/projects/` + detail + edit + batch report
- Worker batch report: HTML + CSV + PDF
- Team/Project batch reports: HTML + CSV
- Payroll report: Resources dropdown nav, `New Report` button, money filter
- Dashboard cert-expiry stat card (conditional)
**Infrastructure**:
- Backup + restore management commands
- `/backup-data/` + `/restore-data/` browser endpoints
- Bootstrap tooltips (global init + theme-aware CSS)
- Django admin template override (taller M2M pickers)
- `TEMPLATES.DIRS` change so admin overrides work
**Documentation**:
- Massive `CLAUDE.md` expansion: users/roles/permissions, backup/restore, admin overrides
---
## ⚠️ The backup problem
**Our `/backup-data/` feature is NOT yet on Flatlogic** — it's on this local branch, not yet pushed. So we can't use it to back up production right now.
This matters because once we push, migrations will run (or need to be run via `/run-migrate/`), and the new code goes live. If something breaks badly, we'd have no restore path.
**Three backup options, in order of my preference**:
### Option A (RECOMMENDED): Split the push into two phases
**Phase 1** — A tiny push that just adds the backup/restore feature + security fixes + the 2 uncommitted "vat_type" migrations (which are tiny and safe).
- Risk: very low. No new models, no PDF engine change, no UI.
- Gets `/backup-data/` live on production.
- You download a backup via that URL.
- Backup is on your laptop — you now have a real safety net.
**Phase 2** — The big push with everything else.
- Risk: significant, but now recoverable.
- If anything breaks, restore from Phase 1 backup.
### Option B: Ask Gemini to take a manual backup first
Ask Gemini in the Flatlogic chat:
> Before a planned code push, please run `python manage.py dumpdata --natural-foreign --natural-primary --exclude=contenttypes --exclude=auth.permission --output=/tmp/pre_deploy_backup_20260422.json` and then make that file downloadable to me somehow (e.g., expose it at a temporary URL like we did with the env-setup page).
Pros: one push instead of two.
Cons: relies on Gemini being able to do this cleanly; format may differ from our backup tool; another round of "build temp page, use it, delete it" like last time.
### Option C: Push all at once, no backup
Pros: fastest.
Cons: if anything goes wrong, you're relying on Flatlogic's internal backups (which exist but aren't something you've tested).
---
## Recommended: Option A — two-phase push
### Phase 1 — Safety scaffolding (~10 minutes)
**Scope**: only these files
- `config/settings.py` — security fixes
- `core/management/commands/backup_data.py` (new)
- `core/management/commands/restore_data.py` (new)
- `core/views.py` — just the `backup_data` + `restore_data` view functions (not the other 8 new views)
- `core/urls.py` — just the 2 new routes
- `core/migrations/0007_vat_type_default.py` (new)
- `core/migrations/0008_vat_type_default_none.py` (new)
- `CLAUDE.md` — just the Backup & Restore section + the updated Authentication/Users section
**NOT in Phase 1**: new models, WeasyPrint, new UIs, the 3 new Worker migrations, new templates, tooltips, etc.
**Sequence**:
1. On local `redesign-weasyprint` branch, make a sub-branch `phase-1-safety`:
```
git checkout -b phase-1-safety
```
2. Stage and commit only the Phase 1 files:
```
git add config/settings.py core/management/commands/backup_data.py core/management/commands/restore_data.py core/migrations/0007_vat_type_default.py core/migrations/0008_vat_type_default_none.py
git add -p core/views.py core/urls.py # interactively pick just the backup/restore additions
git add CLAUDE.md
git commit -m "Security fixes + backup/restore feature + vat_type migrations"
```
3. Switch to `ai-dev`, merge `phase-1-safety`:
```
git checkout ai-dev
git pull origin ai-dev # important — Flatlogic may have auto-committed since
git merge phase-1-safety
```
4. Push:
```
git push origin ai-dev
```
5. Flatlogic auto-detects; click **Pull Latest** in the dashboard; wait ~5 min for rebuild.
6. Visit `/run-migrate/` to apply migrations 0007 + 0008.
7. Visit `/backup-data/` — download the JSON to your laptop. **Keep this file safe.**
8. Basic verification: dashboard loads, payroll dashboard loads, an existing receipt can be viewed. Nothing should behave differently from before.
**If Phase 1 fails at any step**: revert the merge (`git revert -m 1 <merge-commit>`), push, Flatlogic rebuilds. Low risk because nothing user-facing changed.
### Phase 2 — The big feature release (~3060 minutes)
**Scope**: everything else from this session — WeasyPrint, 3 new Worker migrations, worker/team/project UIs, tooltips, CSS tweaks, admin template override.
**Sequence**:
1. Back on `redesign-weasyprint` (or a new branch off it — doesn't matter since Phase 1 is already in ai-dev):
```
git checkout redesign-weasyprint
git rebase ai-dev # pulls in Phase 1
```
2. Commit the rest. I'll propose 57 logical commits rather than one giant one, so rollback can be surgical:
- `feat: migrate PDF engine to WeasyPrint``requirements.txt`, `utils.py`, updated PDF templates
- `feat: Worker certifications and warnings + new model fields``models.py`, `admin.py`, migrations 0009-0011, WorkerForm changes
- `feat: Worker management UI` — worker list/detail/edit/batch_report templates + views
- `feat: Team + Project management UI + Resources dropdown nav` — team/project templates + views + base.html nav
- `feat: Dashboard tweaks + tooltip infrastructure + admin template override` — index.html changes, tooltip CSS, base_site.html, settings.TEMPLATES.DIRS
- `docs: expand CLAUDE.md with users/permissions + backup/restore + admin sections`
3. Merge into `ai-dev`, push.
4. Flatlogic rebuilds. Run `/run-migrate/` to apply 0009, 0010, 0011.
5. Verification checklist below.
**If Phase 2 fails**:
- Identify which commit broke things (git bisect if needed)
- `git revert <bad-commit>` on ai-dev, push, Flatlogic rebuilds
- Worst case: revert the whole merge, restore from Phase 1 backup
---
## Pre-push cleanup (required regardless of Option A vs B)
Before **any** commit, these files must be excluded:
**Add to `.gitignore`** (if not already):
```
.claude/
test_*.pdf
*.sqlite3-journal
nul
```
**Delete from working directory** (they're test artifacts):
```
rm -f nul test_report.pdf test_workers_report.pdf test_report_weasyprint.pdf test_payslip_weasyprint.pdf test_receipt_weasyprint.pdf "test_report modified manually.pdf"
```
**Never commit**:
- `.env` file (lives on server only, not in git)
- Any password / SECRET_KEY (all those live in `.env` now)
- `test_backup.json` (if any dev backup exists locally)
---
## Verification checklist (post-Phase-2)
Run these in order. Each should pass before moving to the next.
### System checks
- [ ] `/` loads — dashboard renders with all stat cards
- [ ] `/admin/` loads and you can log in
- [ ] `/payroll/` loads — pending payments table visible
- [ ] `/report/` — generate a payroll report with this month's dates, HTML renders
### PDF engine (WeasyPrint) — critical
- [ ] Download the payroll report PDF via `/report/pdf/` — opens in a viewer, has content
- [ ] Create a small test expense receipt, trigger the email, verify the PDF arrives in Spark Receipt (not just the email — the attached PDF)
- [ ] Process a test payment for one worker, verify payslip PDF generates and emails correctly
### New features
- [ ] `/workers/` list renders with ~14 workers
- [ ] Click into a worker, see the new tabs (Profile / Certifications / Warnings / History)
- [ ] `/teams/` and `/projects/` list pages load
- [ ] Resources dropdown in topbar works on desktop
- [ ] Bootstrap tooltips work (hover over the ⓘ icons on worker edit page)
### Data integrity
- [ ] Worker salary, daily_rate, employment_date all display correctly (no fields lost in migration)
- [ ] An existing work log from months ago still shows workers correctly
- [ ] A historical payslip (`/payroll/payslip/<pk>/`) still renders
### Admin
- [ ] `/admin/core/worker/<id>/change/` shows the new inlines (certs + warnings)
- [ ] `/admin/auth/group/<id>/change/` shows taller M2M pickers (30em tall)
### Security (quick)
- [ ] Try loading `/secret-env-setup/` — should be 404 (Gemini cleaned it up)
- [ ] Settings log check (Flatlogic logs): no warning about missing email vars
---
## Rollback procedures
### Rollback Phase 1 (low risk)
```
# On ai-dev branch locally
git revert -m 1 <phase-1-merge-commit-hash>
git push origin ai-dev
# Flatlogic rebuilds, removes the changes
```
### Rollback Phase 2 (after deploying)
**Option 1 — Revert the code**:
```
git revert -m 1 <phase-2-merge-commit-hash>
git push origin ai-dev
```
This reverts the code. BUT — if migrations 0009/0010/0011 have been applied, the new columns/tables exist in MySQL. That's safe (they're just unused), but you'd also want to roll back those migrations:
```
# Visit /run-migrate/ won't help here; we'd need:
python manage.py migrate core 0008
```
Which requires SSH or Gemini intervention.
**Option 2 — Restore from Phase 1 backup** (nuclear option):
1. Visit `/restore-data/` on production
2. Upload the backup JSON from Phase 1
3. Tick "Yes, I understand", click Restore
4. All data returns to Phase 1 snapshot
Option 2 is the last resort. It only works if Phase 2 somehow corrupted data, not just broke the UI.
---
## What I need from you before we start
1. **Confirm Option A** (split into two phases) vs **Option B** (Gemini manual backup + single push) vs **Option C** (yolo single push, no backup).
2. **Have you revoked the old Gmail App Password yet?** (previous plan step — want to confirm before we push, so the leaked password is fully dead before code deploys)
3. **Timing** — when do you want to do this? It's ~10 minutes for Phase 1 + ~5 min Flatlogic rebuild + backup download, then Phase 2 is ~30 min of sequential commits + push + verification. Total ~1 hour if nothing goes wrong. Ideally a time when no one else is actively using the app.
4. **Commit authoring** — do you want me to write the commit messages and make the commits for you (I can stage and commit from bash), or do you want to drive git yourself and I guide?
Once I have these three answers I'll proceed with execution.
---
## Junk we should NOT deploy
For the record, these files exist locally but must NOT make it into the commits:
- `nul` — accidental Windows shell artifact
- `test_report.pdf`, `test_workers_report.pdf`, `test_report_weasyprint.pdf`, `test_payslip_weasyprint.pdf`, `test_receipt_weasyprint.pdf`, `test_report modified manually.pdf` — dev/test output
- `.claude/settings.local.json` — IDE config
- `run_dev.bat` — could go either way (Windows-specific dev convenience); I'd keep it out
- `test_backup.json` (if still present from backup-command testing)

View File

@ -2,4 +2,4 @@ Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
pillow==12.1.1
xhtml2pdf==0.2.16
weasyprint==68.1

3
run_dev.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
set USE_SQLITE=true
python manage.py runserver 0.0.0.0:8000

View File

@ -1,7 +1,7 @@
/* ===================================================================
FoxFitt LabourPay v5 Premium Orange Theme
Dark-first design system with warm amber/orange accents.
Sidebar navigation on desktop, bottom tab bar on mobile.
Top bar navigation on desktop, bottom tab bar on mobile.
All colours are CSS variables the theme toggle switches them.
=================================================================== */
@ -30,17 +30,17 @@
--border-strong: rgba(255, 255, 255, 0.15);
--border-accent: rgba(232, 133, 26, 0.3);
/* Text */
--text-primary: #f0f0f0;
/* Text — softened white (~85% brightness) for easier reading on dark backgrounds */
--text-primary: #d8d8d8;
--text-secondary: #9ca3af;
--text-tertiary: #6b7280;
--text-on-accent: #ffffff;
--text-on-nav: #f0f0f0;
--text-on-nav: #d8d8d8;
--text-on-nav-muted: #6b7280;
--text-link: #e8851a;
/* Override Bootstrap */
--bs-body-color: #f0f0f0;
--bs-body-color: #d8d8d8;
--bs-body-bg: #0c0e14;
--bs-border-color: rgba(255, 255, 255, 0.08);
@ -75,8 +75,7 @@
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar dimensions */
--sidebar-width: 240px;
/* Layout dimensions */
--bottom-nav-height: 64px;
}
@ -167,138 +166,136 @@ a:hover {
}
/* ===================================================================
APP LAYOUT sidebar + main content area
APP LAYOUT top bar + main content area
=================================================================== */
/* Wrapper for the whole app (sidebar + content) */
/* Wrapper for the whole app (topbar + content stacked vertically) */
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === SIDEBAR (desktop only) === */
.app-sidebar {
width: var(--sidebar-width);
/* === TOP BAR (always visible — horizontal nav on desktop, brand-only on mobile) === */
.app-topbar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-default);
position: fixed;
border-bottom: 1px solid var(--border-default);
position: sticky;
top: 0;
left: 0;
bottom: 0;
z-index: 1040;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
transition: background-color var(--transition-normal);
z-index: 1030;
padding: 0 1rem;
}
/* Sidebar brand/logo area */
.sidebar-brand {
padding: 1.5rem 1.25rem;
border-bottom: 1px solid var(--border-default);
/* Inner flexbox container for topbar items */
.topbar-inner {
display: flex;
align-items: center;
height: 52px;
gap: 0.75rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
/* Brand logo + text */
.topbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
flex-shrink: 0;
}
.topbar-brand:hover {
text-decoration: none;
}
/* Bolt icon box (also reused on login page) */
.sidebar-brand__icon {
width: 36px;
height: 36px;
width: 30px;
height: 30px;
background: linear-gradient(135deg, #e8851a 0%, #f59e0b 100%);
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1rem;
font-size: 0.85rem;
flex-shrink: 0;
}
.sidebar-brand__text {
.topbar-brand__text {
font-family: 'Poppins', sans-serif;
font-weight: 700;
font-size: 1.2rem;
font-size: 1.1rem;
color: var(--text-on-nav);
text-decoration: none;
letter-spacing: -0.02em;
}
.sidebar-brand__text span {
.topbar-brand__text span {
color: var(--accent);
}
/* Sidebar navigation links */
.sidebar-nav {
padding: 1rem 0.75rem;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.sidebar-nav__link {
/* Horizontal nav links in topbar — centred between brand and actions */
.topbar-nav {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
justify-content: center;
gap: 0.15rem;
flex: 1;
}
.topbar-nav__link {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
border-radius: var(--radius-sm);
color: var(--text-on-nav-muted);
font-size: 0.875rem;
font-size: 0.8rem;
font-weight: 500;
text-decoration: none;
transition: all var(--transition-fast);
position: relative;
white-space: nowrap;
}
.sidebar-nav__link:hover {
.topbar-nav__link:hover {
color: var(--text-on-nav);
background: rgba(255, 255, 255, 0.06);
text-decoration: none;
}
.sidebar-nav__link.active {
.topbar-nav__link.active {
color: var(--accent);
background: rgba(232, 133, 26, 0.1);
font-weight: 600;
}
.sidebar-nav__link.active::before {
content: '';
position: absolute;
left: 0;
top: 0.5rem;
bottom: 0.5rem;
width: 3px;
background: var(--accent);
border-radius: 0 3px 3px 0;
}
.sidebar-nav__link i {
width: 1.25rem;
.topbar-nav__link i {
font-size: 0.8rem;
width: 1rem;
text-align: center;
font-size: 1rem;
flex-shrink: 0;
}
/* Sidebar footer (theme toggle + user info) */
.sidebar-footer {
padding: 1rem 0.75rem;
border-top: 1px solid var(--border-default);
margin-top: auto;
}
.sidebar-user {
/* Right side of topbar: theme toggle + user avatar + logout */
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: var(--radius-md);
gap: 0.5rem;
flex-shrink: 0;
margin-left: auto;
}
.sidebar-user__avatar {
width: 32px;
height: 32px;
/* User avatar + name in topbar */
.topbar-user {
display: flex;
align-items: center;
gap: 0.4rem;
}
.topbar-user__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
display: flex;
@ -306,27 +303,20 @@ a:hover {
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.8rem;
font-size: 0.7rem;
flex-shrink: 0;
}
.sidebar-user__name {
.topbar-user__name {
color: var(--text-on-nav);
font-size: 0.8rem;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.2;
}
.sidebar-user__role {
color: var(--text-on-nav-muted);
font-size: 0.7rem;
}
/* === MAIN CONTENT AREA === */
.app-main {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
/* NO position/z-index here — avoids trapping Bootstrap modals in a stacking context */
@ -355,24 +345,13 @@ a:hover {
content: '';
position: absolute;
bottom: -300px;
left: var(--sidebar-width);
left: 0;
width: 500px;
height: 500px;
background: radial-gradient(ellipse, rgba(232, 133, 26, 0.06) 0%, transparent 70%);
}
/* === TOP BAR (mobile) === */
.app-topbar {
display: none; /* hidden on desktop */
background: var(--bg-nav);
padding: 0.75rem 1rem;
position: sticky;
top: 0;
z-index: 1030;
border-bottom: 1px solid var(--border-default);
}
/* === BOTTOM TAB BAR (mobile) === */
/* === BOTTOM TAB BAR (mobile only) === */
.app-bottom-nav {
display: none; /* hidden on desktop */
position: fixed;
@ -436,30 +415,123 @@ a:hover {
color: var(--text-tertiary);
}
/* === HAMBURGER BUTTON (mobile only — hidden on desktop via d-lg-none) === */
.hamburger-btn {
background: none;
border: none;
color: var(--text-on-nav);
font-size: 1.2rem;
padding: 0.35rem 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.hamburger-btn:hover {
color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
/* === MOBILE MENU (slides down from topbar when hamburger is tapped) === */
/* Fixed below the topbar so it stays visible regardless of scroll position */
.mobile-menu {
position: fixed;
top: 52px; /* matches topbar-inner height */
left: 0;
right: 0;
z-index: 1029; /* just below topbar (1030) */
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border-default);
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease, opacity 200ms ease;
opacity: 0;
box-shadow: var(--shadow-lg);
}
/* Open state — toggled by JS */
.mobile-menu.open {
max-height: calc(100vh - 52px); /* never taller than remaining screen */
opacity: 1;
overflow-y: auto;
}
.mobile-menu__nav {
display: flex;
flex-direction: column;
padding: 0.5rem 1rem;
gap: 0.15rem;
}
.mobile-menu__link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--text-on-nav-muted);
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
transition: all var(--transition-fast);
}
.mobile-menu__link:hover {
color: var(--text-on-nav);
background: rgba(255, 255, 255, 0.06);
text-decoration: none;
}
.mobile-menu__link.active {
color: var(--accent);
background: rgba(232, 133, 26, 0.1);
font-weight: 600;
}
.mobile-menu__link i {
width: 1.25rem;
text-align: center;
font-size: 0.9rem;
}
.mobile-menu__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-default);
margin-top: 0.25rem;
}
/* Semi-transparent backdrop behind the menu — tapping it closes the menu */
.mobile-menu-backdrop {
display: none;
position: fixed;
inset: 0;
top: 52px;
background: rgba(0, 0, 0, 0.5);
z-index: 1028; /* below menu (1029) and topbar (1030) */
}
.mobile-menu-backdrop.open {
display: block;
}
/* === RESPONSIVE: Mobile layout === */
@media (max-width: 991.98px) {
.app-sidebar {
display: none; /* sidebar hidden on mobile */
/* Hide desktop nav links — hamburger menu handles navigation on mobile */
.topbar-nav {
display: none;
}
.app-topbar {
display: flex;
align-items: center;
justify-content: space-between;
/* Hide user name on mobile — just show avatar */
.topbar-user__name {
display: none;
}
/* Bottom tab bar hidden — replaced by hamburger menu */
.app-bottom-nav {
display: block;
}
.app-main {
margin-left: 0;
padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
}
/* Decorative gradients on mobile are positioned differently */
.app-glow::after {
left: -100px;
display: none;
}
}
@ -596,14 +668,39 @@ a:hover {
box-shadow: 0 1px 4px rgba(232, 133, 26, 0.3);
}
/* btn-primary — dark slate in dark mode, darker slate in light mode */
.btn-primary {
background-color: var(--text-primary);
border-color: var(--text-primary);
background-color: #2a2d3a;
border-color: #3a3d4a;
color: #d8d8d8;
}
.btn-primary:hover {
background-color: var(--text-secondary);
border-color: var(--text-secondary);
.btn-primary:hover,
.btn-primary:focus {
background-color: #353849;
border-color: #4a4d5a;
color: #ffffff;
}
.btn-primary:active,
.btn-primary.active {
background-color: #1e2130;
border-color: #3a3d4a;
color: #ffffff;
}
/* Light mode btn-primary — dark navy for good contrast */
[data-theme="light"] .btn-primary {
background-color: #1e293b;
border-color: #1e293b;
color: #ffffff;
}
[data-theme="light"] .btn-primary:hover,
[data-theme="light"] .btn-primary:focus {
background-color: #334155;
border-color: #334155;
color: #ffffff;
}
/* Dark mode outline button fixes */
@ -646,13 +743,14 @@ a:hover {
}
/* ===================================================================
TABLES
TABLES compact text for data-dense views
=================================================================== */
.table {
color: var(--text-primary);
--bs-table-bg: transparent;
--bs-table-color: var(--text-primary);
font-size: 0.78rem;
}
.table > thead {
@ -672,11 +770,13 @@ a:hover {
.table-hover > tbody > tr:hover {
background-color: var(--bg-card-hover);
--bs-table-hover-bg: var(--bg-card-hover);
--bs-table-hover-color: var(--text-primary);
color: var(--text-primary);
}
.table th {
font-weight: 600;
font-size: 0.75rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
@ -696,6 +796,13 @@ a:hover {
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
/* Placeholder text — visible but subtle */
.form-control::placeholder,
.form-select::placeholder {
color: var(--text-tertiary);
opacity: 1;
}
.form-control:focus,
.form-select:focus {
background-color: var(--bg-input);
@ -711,17 +818,79 @@ a:hover {
margin-bottom: 0.375rem;
}
/* === NATIVE DATE/MONTH PICKER ICONS (Chromium) ===
The browser paints a small calendar icon on the right of
<input type="date"> and <input type="month"> via the pseudo-element
::-webkit-calendar-picker-indicator. On dark backgrounds the default
black icon is nearly invisible. CSS can't set its fill directly, so
we use a filter chain to tint it toward our amber accent (#e8851a).
Firefox doesn't render this indicator so the rule is a no-op there. */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.9;
filter: invert(58%) sepia(89%) saturate(862%) hue-rotate(357deg) brightness(93%) contrast(92%);
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="month"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
.form-check-input:checked {
background-color: var(--accent);
border-color: var(--accent);
}
/* === FORMSET ROW MARKED FOR DELETION ===
When a user clicks the trash button on a certification/warning row,
JS adds `.row-marked-delete` to that row. These styles fade the row
and strike through its inputs so it's visually obvious the row will
be removed on save. The "Undo" link restores everything. */
.formset-row.row-marked-delete {
opacity: 0.55;
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.3) !important;
}
.formset-row.row-marked-delete .form-control,
.formset-row.row-marked-delete .form-select,
.formset-row.row-marked-delete textarea {
text-decoration: line-through;
pointer-events: none; /* can't edit a row you're removing */
background: var(--bg-inset, #f0f0f5);
}
.formset-row.row-marked-delete .form-label {
text-decoration: line-through;
}
.input-group-text {
background-color: var(--bg-inset);
border-color: var(--border-default);
color: var(--text-secondary);
}
/* === BOOTSTRAP TOOLTIPS themed for dark/light modes ===
Bootstrap 5.3's default tooltip uses `--bs-body-color` as background
and `--bs-body-bg` as text colour. In dark mode that produces a light
tooltip with dark text, which clashes with the rest of the UI and can
be unreadable when the body/bg values are very close.
Override the tooltip CSS variables to use our elevated-surface colours
(same palette as cards on hover) readable on both dark and light. */
.tooltip {
--bs-tooltip-bg: var(--bg-card-hover);
--bs-tooltip-color: var(--text-primary);
--bs-tooltip-opacity: 1;
}
.tooltip .tooltip-inner {
border: 1px solid var(--border-default);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
font-size: 0.8rem;
max-width: 280px;
padding: 6px 10px;
font-weight: 500;
}
/* The arrow inherits its color from --bs-tooltip-bg automatically, but
we give it a matching border so it stays connected visually. */
/* ===================================================================
MODALS
=================================================================== */
@ -799,7 +968,7 @@ a:hover {
}
/* ===================================================================
THEME TOGGLE BUTTON (in sidebar footer)
THEME TOGGLE BUTTON (in topbar)
=================================================================== */
.theme-toggle {
@ -807,15 +976,15 @@ a:hover {
border: 1px solid var(--border-default);
color: var(--text-on-nav-muted);
cursor: pointer;
padding: 0.5rem;
padding: 0.4rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-size: 0.85rem;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: 32px;
height: 32px;
}
.theme-toggle:hover {
@ -882,6 +1051,30 @@ a:hover {
margin-bottom: 0;
}
/* === PAYROLL ACTION BUTTONS — 2x2 grid on mobile, row on desktop === */
@media (max-width: 767.98px) {
.payroll-actions {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.payroll-actions .btn {
font-size: 0.75rem;
padding: 0.45rem 0.6rem;
white-space: nowrap;
}
}
/* On desktop, restore normal button size (undo btn-sm) */
@media (min-width: 768px) {
.btn-md-normal {
font-size: 0.875rem !important;
padding: 0.375rem 0.75rem !important;
}
}
.text-muted {
color: var(--text-secondary) !important;
}
@ -1101,7 +1294,7 @@ a:hover {
color: var(--text-primary) !important;
}
/* Loan badge: yellow bg + white text for contrast (not text-dark) */
/* Loan badge: yellow bg + dark text for contrast */
.badge.bg-warning {
color: #000 !important;
}
@ -1181,15 +1374,98 @@ a:hover {
}
/* ===================================================================
PRINT STYLES
PRINT STYLES ensure payslips print as black text on white page
=================================================================== */
@media print {
body { background: white !important; color: black !important; }
.app-sidebar, .app-topbar, .app-bottom-nav,
.app-footer, .d-print-none { display: none !important; }
.app-main { margin-left: 0 !important; }
.card { border: 1px solid #ddd !important; box-shadow: none !important; backdrop-filter: none !important; }
/* Override ALL CSS variables to light/print-friendly values */
:root, [data-theme="dark"], [data-theme="light"] {
--bg-body: #ffffff !important;
--bg-card: #ffffff !important;
--bg-card-hover: #ffffff !important;
--bg-elevated: #ffffff !important;
--bg-inset: #f5f5f5 !important;
--bg-input: #ffffff !important;
--text-primary: #000000 !important;
--text-secondary: #333333 !important;
--text-tertiary: #666666 !important;
--text-on-accent: #000000 !important;
--text-link: #000000 !important;
--border-default: #cccccc !important;
--border-subtle: #dddddd !important;
--border-strong: #999999 !important;
--accent: #d97706 !important;
--color-success: #16a34a !important;
--color-danger: #dc2626 !important;
--color-warning: #d97706 !important;
--color-info: #2563eb !important;
--color-success-bg: #ecfdf5 !important;
--color-danger-bg: #fef2f2 !important;
--color-warning-bg: #fffbeb !important;
--color-info-bg: #eff6ff !important;
--bs-body-color: #000000 !important;
--bs-body-bg: #ffffff !important;
}
body {
background: white !important;
color: black !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Hide navigation and non-print elements */
.app-topbar, .app-bottom-nav,
.app-footer, .app-glow, .d-print-none { display: none !important; }
/* Cards: clean white with thin border, no blur */
.card {
background: white !important;
border: 1px solid #ddd !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Stat cards: remove glass effect */
.stat-card {
background: white !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}
/* Tables: black text on white */
.table, .table th, .table td {
color: #000 !important;
background: white !important;
border-color: #ccc !important;
font-size: 11pt !important;
}
.table > thead, .table-light {
background-color: #f0f0f0 !important;
}
/* Headings and text: all black */
h1, h2, h3, h4, h5, h6, p, span, div, td, th, a {
color: #000 !important;
}
/* Badges: print-friendly */
.badge {
border: 1px solid #999 !important;
background: #f5f5f5 !important;
color: #000 !important;
}
/* Ensure the stat-label text prints as dark grey */
.stat-label {
color: #555 !important;
}
/* Links: no colour, no underline */
a { color: #000 !important; text-decoration: none !important; }
}
/* ===================================================================
@ -1199,48 +1475,9 @@ a:hover {
body, .card, .modal-content, .form-control, .form-select,
.table, .btn, .alert, .badge,
.input-group-text, .stat-card, .cal-day,
.app-sidebar, .app-topbar, .app-bottom-nav {
.app-topbar, .app-bottom-nav {
transition: background-color var(--transition-normal),
color var(--transition-normal),
border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
/* ===================================================================
NAVBAR kept for backward compatibility (legacy top navbar)
Sidebar replaces this on desktop. Hidden by default.
=================================================================== */
.navbar {
background-color: var(--bg-nav) !important;
border-bottom: 1px solid var(--border-default);
padding: 0.6rem 0;
display: none !important; /* hidden — sidebar replaces it */
}
.navbar-brand {
font-family: 'Poppins', sans-serif;
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.02em;
}
.navbar .nav-link {
color: var(--text-on-nav-muted) !important;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 0.85rem !important;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.navbar .nav-link:hover,
.navbar .nav-link.active {
color: var(--text-on-nav) !important;
background-color: rgba(255, 255, 255, 0.08);
}
.navbar .nav-link i {
width: 1.25rem;
text-align: center;
}