Compare commits

..

2 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

410
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)
- 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
- xhtml2pdf for PDF generation (payslips, receipts)
- Gmail SMTP for automated document delivery
- Hosted on Flatlogic/AppWizzy platform (Apache on Debian, e2-micro VM)
@ -25,24 +25,12 @@ 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 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)
utils.py — render_to_pdf() helper (lazy xhtml2pdf import)
views.py — All 28 functions (~2635 lines, includes helpers)
management/commands/ — setup_groups, setup_test_data, import_production_data
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)
templates/ — base.html + 7 page templates + 2 email + 2 PDF + login
ai/ — Flatlogic AI proxy client (not used in app logic)
static/css/ — custom.css (CSS variables, component styles, tooltip overrides)
static/css/ — custom.css (CSS variables, component styles)
staticfiles/ — Collected static assets (Bootstrap, admin)
```
@ -56,8 +44,6 @@ 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
@ -123,10 +109,6 @@ 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 |
@ -136,25 +118,6 @@ 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) |
@ -171,335 +134,64 @@ python manage.py check # System check
| `/payroll/batch-pay/preview/` | `batch_pay_preview` | Admin: AJAX JSON batch pay preview (`?mode=schedule\|all`) |
| `/payroll/batch-pay/` | `batch_pay` | Admin: POST process batch payments for multiple workers |
| `/run-migrate/` | `run_migrate` | Setup: run pending DB migrations from browser |
| `/teams/` | `team_list` | Admin: list teams with filters & search |
| `/teams/new/` | `team_edit` | Admin: create a new team |
| `/teams/<id>/` | `team_detail` | Admin: team detail (Profile · Pay Schedule · Workers · History) |
| `/teams/<id>/edit/` | `team_edit` | Admin: edit team (shared view with `team_new`) |
| `/teams/report/` | `team_batch_report` | Admin: batch report across all teams (HTML) |
| `/teams/report/csv/` | `team_batch_report_csv` | Admin: batch report CSV download |
| `/projects/` | `project_list` | Admin: list projects with filters & search |
| `/projects/new/` | `project_edit` | Admin: create a new project |
| `/projects/<id>/` | `project_detail` | Admin: project detail (Profile · Supervisors · Teams · Workers · History) |
| `/projects/<id>/edit/` | `project_edit` | Admin: edit project (shared view with `project_new`) |
| `/projects/report/` | `project_batch_report` | Admin: batch report across all projects (HTML) |
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: batch report CSV download |
## Team & Project Management Pages
Added as a friendly alternative to Django admin for managing Teams and Projects
outside of `/admin/`. Pattern mirrors (or anticipates) the Workers management UI.
- **Access**: Admins only — every view checks `is_admin(user)` and returns 403 for others
- **Entry points**:
- `Resources` dropdown in the top nav (admin-only) — links to Teams and Projects
- `Manage All Teams` / `Manage All Projects` buttons on the Dashboard's Manage Resources card tabs
- **Forms**: `TeamForm` and `ProjectForm` in `core/forms.py` — plain `ModelForm` classes,
no inline formsets. `_supervisor_user_queryset()` returns admins + Work Logger group members
so both forms pick supervisors from the same pool
- **Helpers in views.py**:
- `_build_team_report_context(request)` — shared between HTML and CSV batch-report views
- `_build_project_report_context(request)` — same pattern for projects
- Reuses existing `get_pay_period(team)` for the Team detail "Pay Schedule" tab
- **Templates**: `core/templates/core/teams/{list,detail,edit,batch_report}.html`
and `core/templates/core/projects/{list,detail,edit,batch_report}.html`
- **No model changes / no migrations** — these pages are purely additive
- **PDF export deferred** — HTML + CSV only for now; PDF can be added with a new
view + template per model if needed later
- **Django admin still works** for all of these models — the new pages are an
alternative UI, not a replacement
## Frontend Design Conventions
- **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)`:
- `--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`
- `--primary-dark: #0f172a` (navbar), `--primary: #1e293b` (headers), `--accent: #10b981` (brand green)
- `--text-main: #334155`, `--text-secondary: #64748b`, `--background: #f1f5f9`
- **Icons**: Font Awesome 6 only (`fas fa-*`). Do NOT use Bootstrap Icons (`bi bi-*`)
- **CTA buttons**: `btn-accent` (orange) for primary actions. `btn-primary` (dark slate) for modal Save/Submit
- **Page titles**: `{% block title %}Page Name | FoxFitt{% endblock %}`
- **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 %}`
- **Fonts**: Inter (body) + Poppins (headings) loaded in base.html via Google Fonts CDN
- **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).
- **Cards**: Borderless with `box-shadow: 0 4px 6px rgba(0,0,0,0.1)`. Stat cards use `backdrop-filter: blur`
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
## 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.
## 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
## 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()` and `run_migrate()`
- All views use `@login_required` except `import_data()`
- 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
```
@ -529,5 +221,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 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()`)
- `render_to_pdf()` uses lazy import of xhtml2pdf to prevent app crash if library missing
- Django admin is available at `/admin/` with full model registration and search/filter

View File

@ -42,7 +42,7 @@ if not SECRET_KEY:
else:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY environment variable is not set. "
"Set it in the deploy platform's environment variables. "
"Set it in the deploy platform's environment variables (or .env file). "
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
"to generate a new one."
)
@ -69,11 +69,7 @@ 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.
"""
"""Ensure `host` has an http:// or https:// scheme; default to https."""
host = host.strip()
if host.startswith(("http://", "https://")):
return host
@ -121,11 +117,7 @@ ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# 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'],
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -207,23 +199,20 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# === EMAIL CONFIGURATION ===
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
# The App Password is a 16-character code from Google Account settings —
# it lets the app send email through Gmail without your actual password.
# === 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.
# NO FALLBACKS for credentials — they MUST come from environment variables
# (the Flatlogic .env file at `../.env`). Previous versions had the Gmail
# App Password committed in source as a fallback default, which is a
# critical security leak via git history. In local dev (USE_SQLITE=true)
# empty credentials are fine; email sends will just fail with an auth
# error — which is what you want locally.
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
)
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set on deploy platform
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set on deploy platform
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set via .env
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set via .env
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "")
@ -234,11 +223,10 @@ CONTACT_EMAIL_TO = [
]
# 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.
# This is a routing address, not a secret, so a default is acceptable.
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# Fail loudly at startup in production if credentials are missing — catches the
# Fail loudly in production if critical email vars are missing — catches the
# "I forgot to set env vars on the new deploy platform" mistake before a user
# triggers a payroll payment and the email silently fails.
if not DEBUG and not _IS_DEV:
@ -250,8 +238,6 @@ if not DEBUG and not _IS_DEV:
] 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. "

View File

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

View File

@ -36,9 +36,17 @@ from core.models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
)
# WorkerCertificate and WorkerWarning were added in a later migration.
# Import them optionally so this backup command works during a multi-phase
# deploy where the backup tool ships before those models do.
try:
from core.models import WorkerCertificate, WorkerWarning
_HAS_WORKER_CERTS_WARNINGS = True
except ImportError:
_HAS_WORKER_CERTS_WARNINGS = False
# === BACKUP SCOPE ===
# The exact list of models we back up. Order matters for restore —
@ -63,9 +71,10 @@ MODELS_TO_BACKUP = [
PayrollAdjustment,
ExpenseReceipt,
ExpenseLineItem,
WorkerCertificate,
WorkerWarning,
]
# Append the cert/warning models only if they're available in this deploy
if _HAS_WORKER_CERTS_WARNINGS:
MODELS_TO_BACKUP.extend([WorkerCertificate, WorkerWarning])
def build_backup_payload():

View File

@ -1,51 +0,0 @@
# 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

@ -1,33 +0,0 @@
# 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

@ -1,18 +0,0 @@
# 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,4 +1,3 @@
import datetime
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
@ -39,33 +38,6 @@ 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)
@ -83,11 +55,6 @@ 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):
@ -243,115 +210,3 @@ 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

@ -1,45 +0,0 @@
{% 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

@ -1,21 +1,10 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FoxFitt{% endblock %}</title>
<!-- === THEME: Apply saved preference BEFORE first paint (prevents flash) === -->
<script>
(function() {
var saved = localStorage.getItem('foxfitt-theme');
if (saved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<!-- Bootstrap 5.3 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Google Fonts -->
@ -24,373 +13,136 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Poppins:wght@500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Custom CSS (cache-busted with deployment timestamp) -->
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ request.timestamp|default:'1.0' }}">
{% block extra_css %}{% endblock %}
<style>
/* Layout helpers — keep body full-height so footer sticks to bottom */
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex-grow: 1; }
/* Branding — Fox in green, Fitt in white */
.navbar-brand-fox { color: #10b981; font-weight: 700; }
.navbar-brand-fitt { color: #ffffff; font-weight: 700; }
.nav-link { font-weight: 500; }
.dropdown-menu { border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
</style>
</head>
<body>
{% if user.is_authenticated %}
<!-- ===================================================================
APP LAYOUT — top bar (desktop + mobile) + bottom tab bar (mobile)
=================================================================== -->
<div class="app-layout">
<!-- === 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 -->
<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>
<nav class="navbar navbar-expand-lg navbar-dark sticky-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="{% url 'home' %}">
<span class="navbar-brand-fox">Fox</span>
<span class="navbar-brand-fitt">Fitt</span>
</a>
<!-- 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>
<!-- 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>
<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>
</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 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>
</div>
<!-- === MAIN CONTENT AREA === -->
<div class="app-main">
<!-- Decorative gradient glows (separate from app-main to avoid stacking context trapping modals) -->
<div class="app-glow d-print-none"></div>
<!-- === Flash messages (Django messages framework) === -->
{% if messages %}
<div class="container-fluid px-3 px-lg-4 mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{% if message.tags == 'success' %}<i class="fas fa-check-circle me-2"></i>
{% elif message.tags == 'error' or message.tags == 'danger' %}<i class="fas fa-exclamation-circle me-2"></i>
{% elif message.tags == 'warning' %}<i class="fas fa-exclamation-triangle me-2"></i>
{% elif message.tags == 'info' %}<i class="fas fa-info-circle me-2"></i>
{% if user.is_authenticated %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}" href="{% url 'home' %}">
<i class="fas fa-home me-1"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}" href="{% url 'attendance_log' %}">
<i class="fas fa-clipboard-list me-1"></i> Log Work
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}" href="{% url 'work_history' %}">
<i class="fas fa-clock me-1"></i> Work History
</a>
</li>
{% if user.is_staff %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}" href="{% url 'payroll_dashboard' %}">
<i class="fas fa-wallet me-1"></i> Payroll
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}" href="{% url 'create_receipt' %}">
<i class="fas fa-receipt me-1"></i> Receipts
</a>
</li>
{# === RESOURCES DROPDOWN (admin only) ===
Friendly management pages for Teams and Projects — an
alternative to Django admin. Workers will be added here
when the Workers management UI ships in a later release. #}
{% if user.is_staff %}
<li class="nav-item dropdown">
{% with url_name=request.resolver_match.url_name %}
<a class="nav-link dropdown-toggle {% if url_name == 'team_list' or url_name == 'team_detail' or url_name == 'team_edit' or url_name == 'team_new' or url_name == 'team_batch_report' or url_name == 'project_list' or url_name == 'project_detail' or url_name == 'project_edit' or url_name == 'project_new' or url_name == 'project_batch_report' %}active{% endif %}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-folder-tree me-1"></i> Resources
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if url_name == 'team_list' or url_name == 'team_detail' or url_name == 'team_edit' or url_name == 'team_new' or url_name == 'team_batch_report' %}active{% endif %}" href="{% url 'team_list' %}">
<i class="fas fa-users me-1"></i> Teams
</a>
</li>
<li>
<a class="dropdown-item {% if url_name == 'project_list' or url_name == 'project_detail' or url_name == 'project_edit' or url_name == 'project_new' or url_name == 'project_batch_report' %}active{% endif %}" href="{% url 'project_list' %}">
<i class="fas fa-folder-open me-1"></i> Projects
</a>
</li>
</ul>
{% endwith %}
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">
<i class="fas fa-cog me-1"></i> Admin
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item d-flex align-items-center">
<span class="nav-link text-light pe-2">
<i class="fas fa-user-circle me-1"></i> {{ user.username }}
</span>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-sign-out-alt me-1"></i> Logout
</button>
</form>
</li>
</ul>
</div>
{% endif %}
</div>
</nav>
<div class="container mt-4">
<!-- Messages Block -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show shadow-sm" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- === Page Content === -->
<div class="app-content">
{% endif %}
{% block content %}
{% endblock %}
{% if user.is_authenticated %}
</div>
<!-- === Footer (inside main area) === -->
<footer class="app-footer d-print-none">
<div class="container">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-center">
<p class="mb-1 mb-sm-0">
<span style="color: var(--accent); font-weight: 600;">Fox</span><span style="font-weight: 600;">Fitt</span>
<span class="ms-1">Construction</span>
</p>
<p class="mb-0">&copy; {% now "Y" %} All rights reserved.</p>
</div>
</div>
</footer>
<!-- === BOTTOM TAB BAR (mobile only, hidden on desktop via CSS) === -->
<nav class="app-bottom-nav d-print-none">
<div class="bottom-nav-inner">
<a href="{% url 'home' %}" class="bottom-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="bottom-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="bottom-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="bottom-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>History</span>
</a>
<a href="{% url 'create_receipt' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i>
<span>Receipts</span>
</a>
</div>
</nav>
</div>
</div>
{% endif %}
<!-- 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>
<!-- Main Content -->
<main>
{% block content %}
{% endblock %}
</main>
<!-- === 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() {
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>
<!-- Footer -->
<footer class="py-4 mt-auto border-top border-secondary">
<div class="container text-center">
<p class="mb-0 small">&copy; {% now "Y" %} FoxFitt Construction. All rights reserved.</p>
</div>
</footer>
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
<script>
(function() {
var btn = document.getElementById('themeToggle');
var icon = document.getElementById('themeIcon');
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>
{% block extra_js %}{% endblock %}
<!-- Bootstrap 5.3 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
</html>

View File

@ -1,134 +0,0 @@
{% 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

@ -1,29 +1,27 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Log Work | FoxFitt{% endblock %}
{% block title %}Log Work | Fox Fitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === Page Header === -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="page-title"><i class="fas fa-clipboard-list me-2" style="color: var(--accent);"></i>Log Daily Attendance</h1>
</div>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Log Daily Attendance</h1>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
<div class="row">
<!-- === Main Form Column === -->
<!-- Main Form Column -->
<div class="{% if is_admin %}col-lg-8{% else %}col-lg-8 mx-auto{% endif %}">
<div class="card">
<div class="card shadow-sm border-0" style="border-radius: 12px;">
<div class="card-body p-4 p-md-5">
{# --- Conflict Warning --- #}
{# If we found workers already logged on selected dates, show this warning #}
{% if conflicts %}
<div class="alert alert-warning mb-4" role="alert">
<div class="alert alert-warning border-0 shadow-sm mb-4" role="alert">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>Conflicts Found
</h6>
@ -36,11 +34,15 @@
<div class="d-flex gap-2">
<form method="POST" class="d-inline">
{% csrf_token %}
{# Re-submit all form data with a conflict_action flag #}
{# Non-multi-value fields from form.data #}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' and key != 'workers' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
{# Workers is a multi-value field — use the explicit list #}
{# passed from the view (QueryDict.getlist) to avoid losing values #}
{% for wid in selected_worker_ids %}
<input type="hidden" name="workers" value="{{ wid }}">
{% endfor %}
@ -70,7 +72,7 @@
{# --- Form Errors --- #}
{% if form.errors %}
<div class="alert alert-danger mb-4">
<div class="alert alert-danger border-0 shadow-sm mb-4">
<strong><i class="fas fa-exclamation-circle me-1"></i> Please fix the following:</strong>
<ul class="mb-0 mt-2">
{% for field, errors in form.errors.items %}
@ -85,7 +87,7 @@
<form method="POST" id="attendanceForm">
{% csrf_token %}
{# --- Date Range --- #}
{# --- Date Range Section --- #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Start Date</label>
@ -93,10 +95,10 @@
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
End Date <span style="color: var(--text-tertiary); font-weight: 400;">(optional)</span>
End Date <span class="text-muted fw-normal">(optional)</span>
</label>
{{ form.end_date }}
<small style="color: var(--text-tertiary);">Leave blank to log a single day</small>
<small class="text-muted">Leave blank to log a single day</small>
</div>
</div>
@ -124,7 +126,7 @@
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Team <span style="color: var(--text-tertiary); font-weight: 400;">(optional — selects all team workers)</span>
Team <span class="text-muted fw-normal">(optional — selects all team workers)</span>
</label>
{{ form.team }}
</div>
@ -133,7 +135,7 @@
{# --- Worker Checkboxes --- #}
<div class="mb-4">
<label class="form-label d-block mb-3 fw-semibold">Workers Present</label>
<div class="worker-selection border rounded p-3" style="max-height: 300px; overflow-y: auto; background-color: var(--bg-inset); border-color: var(--border-default) !important;">
<div class="worker-selection border rounded p-3" style="max-height: 300px; overflow-y: auto; background-color: #f8fafc; border-color: #e2e8f0 !important;">
<div class="row">
{% for worker in form.workers %}
<div class="col-md-6 mb-2">
@ -163,9 +165,9 @@
{{ form.notes }}
</div>
{# --- Submit --- #}
{# --- Submit Button --- #}
<div class="d-grid mt-5">
<button type="submit" class="btn btn-lg btn-accent">
<button type="submit" class="btn btn-lg btn-accent shadow-sm" style="border-radius: 8px;">
<i class="fas fa-save me-2"></i>Log Work
</button>
</div>
@ -177,22 +179,22 @@
{# --- Estimated Cost Card (Admin Only) --- #}
{% if is_admin %}
<div class="col-lg-4 mt-4 mt-lg-0">
<div class="card sticky-top" style="top: 80px;">
<div class="card shadow-sm border-0 sticky-top" style="border-radius: 12px; top: 80px;">
<div class="card-body p-4">
<h6 class="fw-bold mb-3">
<i class="fas fa-calculator me-2" style="color: var(--accent);"></i>Estimated Cost
<i class="fas fa-calculator me-2 text-success"></i>Estimated Cost
</h6>
<div class="text-center py-3">
<div id="estimatedCost" style="font-size: 2rem; font-weight: 700; font-family: 'Poppins', sans-serif; color: var(--accent);">
<div class="display-6 fw-bold" id="estimatedCost" style="color: var(--accent-color, #10b981);">
R 0.00
</div>
<small style="color: var(--text-secondary);">
<small class="text-muted">
<span id="selectedWorkerCount">0</span> worker(s) &times;
<span id="selectedDayCount">1</span> day(s)
</small>
</div>
<hr style="border-color: var(--border-default);">
<small style="color: var(--text-tertiary);">
<hr>
<small class="text-muted">
This estimate is based on each worker's daily rate multiplied by the
number of working days selected. Overtime is not included.
</small>
@ -203,33 +205,50 @@
</div>
</div>
<!-- === JavaScript: Team auto-select + Cost estimator === -->
{# --- JavaScript for dynamic features --- #}
<script>
document.addEventListener('DOMContentLoaded', function() {
// === TEAM AUTO-SELECT ===
// When a team is chosen from the dropdown, automatically check all workers
// that belong to that team. Uses team_workers_json passed from the view.
var teamWorkersMap = JSON.parse('{{ team_workers_json|escapejs }}');
var teamSelect = document.querySelector('[name="team"]');
if (teamSelect) {
teamSelect.addEventListener('change', function() {
var teamId = this.value;
var allBoxes = document.querySelectorAll('input[name="workers"]');
allBoxes.forEach(function(cb) { cb.checked = false; });
// First, uncheck ALL worker checkboxes
var allBoxes = document.querySelectorAll('input[name="workers"]');
allBoxes.forEach(function(cb) {
cb.checked = false;
});
// Then check workers that belong to the selected team
if (teamId && teamWorkersMap[teamId]) {
teamWorkersMap[teamId].forEach(function(id) {
var workerIds = teamWorkersMap[teamId];
workerIds.forEach(function(id) {
var checkbox = document.querySelector('input[name="workers"][value="' + id + '"]');
if (checkbox) checkbox.checked = true;
if (checkbox) {
checkbox.checked = true;
}
});
}
if (typeof updateEstimatedCost === 'function') updateEstimatedCost();
// Recalculate estimated cost if the admin cost calculator exists
if (typeof updateEstimatedCost === 'function') {
updateEstimatedCost();
}
});
}
{% if is_admin %}
// === ESTIMATED COST CALCULATOR (Admin Only) ===
// Updates the cost card in real-time as workers and dates are selected.
// Worker daily rates passed from the view
const workerRates = {{ worker_rates_json|safe }};
const startDateInput = document.querySelector('[name="date"]');
const endDateInput = document.querySelector('[name="end_date"]');
const satCheckbox = document.querySelector('[name="include_saturday"]');
@ -240,18 +259,26 @@ document.addEventListener('DOMContentLoaded', function() {
const dayCountDisplay = document.getElementById('selectedDayCount');
function countWorkingDays() {
// Count how many working days are in the selected date range
const startDate = startDateInput ? new Date(startDateInput.value) : null;
const endDateVal = endDateInput ? endDateInput.value : '';
const endDate = endDateVal ? new Date(endDateVal) : startDate;
if (!startDate || isNaN(startDate)) return 1;
if (!endDate || isNaN(endDate)) return 1;
let count = 0;
let current = new Date(startDate);
while (current <= endDate) {
const day = current.getDay();
if (day === 6 && !(satCheckbox && satCheckbox.checked)) { current.setDate(current.getDate() + 1); continue; }
if (day === 0 && !(sunCheckbox && sunCheckbox.checked)) { current.setDate(current.getDate() + 1); continue; }
const day = current.getDay(); // 0=Sun, 6=Sat
if (day === 6 && !(satCheckbox && satCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
if (day === 0 && !(sunCheckbox && sunCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
count++;
current.setDate(current.getDate() + 1);
}
@ -259,27 +286,39 @@ document.addEventListener('DOMContentLoaded', function() {
}
function updateEstimatedCost() {
// Add up daily rates of all checked workers, multiply by number of days
let totalDailyRate = 0;
let selectedCount = 0;
workerCheckboxes.forEach(function(cb) {
if (cb.checked) {
const workerId = cb.value;
if (workerRates[workerId]) totalDailyRate += parseFloat(workerRates[workerId]);
if (workerRates[workerId]) {
totalDailyRate += parseFloat(workerRates[workerId]);
}
selectedCount++;
}
});
const days = countWorkingDays();
const totalCost = totalDailyRate * days;
// Update the display
if (costDisplay) costDisplay.textContent = 'R ' + totalCost.toLocaleString('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (workerCountDisplay) workerCountDisplay.textContent = selectedCount;
if (dayCountDisplay) dayCountDisplay.textContent = days;
}
workerCheckboxes.forEach(function(cb) { cb.addEventListener('change', updateEstimatedCost); });
// Listen for changes on all relevant inputs
workerCheckboxes.forEach(function(cb) {
cb.addEventListener('change', updateEstimatedCost);
});
if (startDateInput) startDateInput.addEventListener('change', updateEstimatedCost);
if (endDateInput) endDateInput.addEventListener('change', updateEstimatedCost);
if (satCheckbox) satCheckbox.addEventListener('change', updateEstimatedCost);
if (sunCheckbox) sunCheckbox.addEventListener('change', updateEstimatedCost);
// Run once on page load in case of pre-selected values
updateEstimatedCost();
{% endif %}
});

View File

@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block title %}Create Receipt | FoxFitt{% endblock %}
{% block title %}Create Receipt | Fox Fitt{% endblock %}
{% block content %}
<!-- === CREATE EXPENSE RECEIPT ===
@ -9,164 +9,182 @@
- Live VAT calculation (Included / Excluded / None)
- On submit: saves to database + emails HTML + PDF to Spark Receipt -->
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-10 col-xl-8">
<div class="container py-5">
<div class="card border-0 shadow-sm">
<!-- Page header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="page-title"><i class="fas fa-receipt me-2" style="color: var(--accent);"></i>Create Expense Receipt</h1>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
<!-- Card header -->
<div class="card-header py-3" style="background-color: var(--primary-color);">
<h4 class="mb-0 text-white fw-bold">
<i class="fas fa-file-invoice-dollar me-2"></i> Create Expense Receipt
</h4>
</div>
<div class="card">
<div class="card-body p-4">
<form method="post" id="receipt-form">
{% csrf_token %}
<div class="card-body p-4">
<form method="post" id="receipt-form">
{% csrf_token %}
<!-- === RECEIPT HEADER FIELDS === -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Date</label>
{{ form.date }}
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Vendor Name</label>
{{ form.vendor_name }}
<small style="color: var(--text-tertiary);">
<i class="fas fa-info-circle"></i> Will appear as the main title on the receipt.
</small>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Payment Method</label>
{{ form.payment_method }}
</div>
<div class="col-12">
<label class="form-label fw-semibold">Description</label>
{{ form.description }}
<!-- === RECEIPT HEADER FIELDS === -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Date</label>
{{ form.date }}
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Vendor Name</label>
{{ form.vendor_name }}
<div class="form-text text-muted small">
<i class="fas fa-info-circle"></i> Will appear as the main title on the receipt.
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Payment Method</label>
{{ form.payment_method }}
</div>
<div class="col-12">
<label class="form-label fw-bold text-secondary">Description</label>
{{ form.description }}
</div>
</div>
<hr class="my-4">
<!-- === LINE ITEMS SECTION ===
Each row is a product name + amount.
The "Add Line" button adds new rows via JavaScript.
The X button hides the row and checks a hidden DELETE checkbox. -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold text-dark m-0">Items</h5>
<button type="button" id="add-item" class="btn btn-sm btn-outline-secondary rounded-pill">
<i class="fas fa-plus me-1"></i> Add Line
</button>
</div>
<!-- Django formset management form — tracks how many item forms exist -->
{{ items.management_form }}
<div id="items-container">
{% for item_form in items %}
<div class="item-row row g-2 align-items-center mb-2">
<!-- Hidden ID field (used by Django to track existing items) -->
{{ item_form.id }}
<!-- Product name (takes most of the row) -->
<div class="col-12 col-md-7">
{{ item_form.product_name }}
</div>
<!-- Amount with "R" prefix -->
<div class="col-10 col-md-4">
<div class="input-group">
<span class="input-group-text bg-light border-end-0">R</span>
{{ item_form.amount }}
</div>
</div>
<hr style="border-color: var(--border-default);">
<!-- === LINE ITEMS === -->
<div class="d-flex justify-content-between align-items-center mb-3 mt-4">
<h5 class="fw-bold m-0">Items</h5>
<button type="button" id="add-item" class="btn btn-sm btn-outline-secondary rounded-pill">
<i class="fas fa-plus me-1"></i> Add Line
</button>
<!-- Delete button — hides the row and checks the DELETE checkbox -->
<div class="col-2 col-md-1 text-center">
{% if items.can_delete %}
<div class="form-check d-none">
{{ item_form.DELETE }}
</div>
<button type="button" class="btn btn-outline-danger btn-sm delete-row rounded-circle" title="Remove">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{{ items.management_form }}
<hr class="my-4">
<div id="items-container">
{% for item_form in items %}
<div class="item-row row g-2 align-items-center mb-2">
{{ item_form.id }}
<div class="col-12 col-md-7">
{{ item_form.product_name }}
</div>
<div class="col-10 col-md-4">
<div class="input-group">
<span class="input-group-text">R</span>
{{ item_form.amount }}
</div>
</div>
<div class="col-2 col-md-1 text-center">
{% if items.can_delete %}
<div class="form-check d-none">
{{ item_form.DELETE }}
</div>
<button type="button" class="btn btn-outline-danger btn-sm delete-row rounded-circle" title="Remove">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
<!-- === VAT CONFIGURATION + LIVE TOTALS === -->
<div class="row">
<!-- Left: VAT type radio buttons -->
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label d-block fw-bold text-secondary mb-2">VAT Configuration (15%)</label>
<div class="card bg-light border-0 p-3">
{% for radio in form.vat_type %}
<div class="form-check mb-2">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<hr style="border-color: var(--border-default);">
<!-- === VAT + TOTALS === -->
<div class="row mt-4">
<!-- VAT radio buttons -->
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label d-block fw-semibold mb-2">VAT Configuration (15%)</label>
<div class="p-3 rounded" style="background: var(--bg-inset); border: 1px solid var(--border-default);">
{% for radio in form.vat_type %}
<div class="form-check mb-2">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
<!-- Right: Live-updating totals panel -->
<div class="col-md-6">
<label class="form-label d-block fw-bold text-secondary mb-2">Receipt Totals</label>
<div class="p-3 rounded" style="background-color: #f8fafc; border: 1px solid #e2e8f0;">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Subtotal (Excl. VAT):</span>
<span class="fw-bold">R <span id="display-subtotal">0.00</span></span>
</div>
<!-- Live totals -->
<div class="col-md-6">
<label class="form-label d-block fw-semibold mb-2">Receipt Totals</label>
<div class="p-3 rounded" style="background: var(--bg-inset); border: 1px solid var(--border-default);">
<div class="d-flex justify-content-between mb-2">
<span style="color: var(--text-secondary);">Subtotal (Excl. VAT):</span>
<span class="fw-bold">R <span id="display-subtotal">0.00</span></span>
</div>
<div class="d-flex justify-content-between mb-2">
<span style="color: var(--text-secondary);">VAT (15%):</span>
<span class="fw-bold">R <span id="display-vat">0.00</span></span>
</div>
<div class="d-flex justify-content-between pt-2 mt-2" style="border-top: 1px solid var(--border-default);">
<span class="h5 mb-0 fw-bold">Total:</span>
<span class="h5 mb-0" style="color: var(--accent); font-family: 'Poppins', sans-serif;">
R <span id="display-total">0.00</span>
</span>
</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">VAT (15%):</span>
<span class="fw-bold">R <span id="display-vat">0.00</span></span>
</div>
<div class="d-flex justify-content-between border-top pt-2 mt-2">
<span class="h5 mb-0 fw-bold">Total:</span>
<span class="h5 mb-0" style="color: var(--accent-color);">
R <span id="display-total">0.00</span>
</span>
</div>
</div>
<!-- Submit -->
<div class="text-end mt-4">
<button type="submit" class="btn btn-accent btn-lg">
<i class="fas fa-paper-plane me-2"></i> Create & Send Receipt
</button>
</div>
</form>
</div>
</div>
</div>
<!-- === SUBMIT BUTTON === -->
<div class="text-end mt-4">
<button type="submit" class="btn btn-accent btn-lg">
<i class="fas fa-paper-plane me-2"></i> Create & Send Receipt
</button>
</div>
</form>
</div>
</div>
</div>
<!-- === JavaScript: Dynamic line items + live VAT calculation === -->
<!-- ==========================================================================
JAVASCRIPT — Dynamic line items + live VAT calculation
========================================================================== -->
<script>
(function() {
'use strict';
// --- DOM REFERENCES ---
var itemsContainer = document.getElementById('items-container');
var addItemBtn = document.getElementById('add-item');
var totalForms = document.querySelector('#id_line_items-TOTAL_FORMS');
var displaySubtotal = document.getElementById('display-subtotal');
var displayVat = document.getElementById('display-vat');
var displayTotal = document.getElementById('display-total');
// All VAT radio buttons — we listen for changes on these
var vatRadios = document.querySelectorAll('input[name="vat_type"]');
// === ADD NEW LINE ITEM ===
// === ADD NEW LINE ITEM ROW ===
// When "Add Line" is clicked, build a new blank row using DOM methods.
// We increment TOTAL_FORMS so Django knows there's an extra form.
addItemBtn.addEventListener('click', function() {
var formIdx = parseInt(totalForms.value);
// Create the row container
var row = document.createElement('div');
row.className = 'item-row row g-2 align-items-center mb-2';
// Hidden ID input (required by Django formset)
var hiddenId = document.createElement('input');
hiddenId.type = 'hidden';
hiddenId.name = 'line_items-' + formIdx + '-id';
hiddenId.id = 'id_line_items-' + formIdx + '-id';
row.appendChild(hiddenId);
// Product name column
var prodCol = document.createElement('div');
prodCol.className = 'col-12 col-md-7';
var prodInput = document.createElement('input');
@ -178,12 +196,13 @@
prodCol.appendChild(prodInput);
row.appendChild(prodCol);
// Amount column with "R" prefix
var amtCol = document.createElement('div');
amtCol.className = 'col-10 col-md-4';
var inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
var prefix = document.createElement('span');
prefix.className = 'input-group-text';
prefix.className = 'input-group-text bg-light border-end-0';
prefix.textContent = 'R';
var amtInput = document.createElement('input');
amtInput.type = 'number';
@ -197,6 +216,7 @@
amtCol.appendChild(inputGroup);
row.appendChild(amtCol);
// Delete button column
var delCol = document.createElement('div');
delCol.className = 'col-2 col-md-1 text-center';
var delBtn = document.createElement('button');
@ -209,60 +229,98 @@
delCol.appendChild(delBtn);
row.appendChild(delCol);
// Add to DOM and update form count
itemsContainer.appendChild(row);
totalForms.value = formIdx + 1;
// Recalculate totals
updateCalculations();
});
// === DELETE LINE ITEM ===
// === DELETE LINE ITEM ROW ===
// Uses event delegation — listens on the container for any delete button click.
// If the row has a DELETE checkbox (existing saved item), checks it and hides the row.
// If the row is brand new (no DELETE checkbox), just removes it from the DOM.
itemsContainer.addEventListener('click', function(e) {
var deleteBtn = e.target.closest('.delete-row');
if (!deleteBtn) return;
var row = deleteBtn.closest('.item-row');
var deleteCheckbox = row.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox) {
// Existing item — check DELETE and hide (Django will delete on save)
deleteCheckbox.checked = true;
row.classList.add('d-none', 'deleted');
} else {
// New item — just remove from DOM
row.remove();
}
updateCalculations();
});
// === LIVE AMOUNT CHANGES ===
// === LIVE AMOUNT INPUT CHANGES ===
// Recalculate whenever an amount field changes
itemsContainer.addEventListener('input', function(e) {
if (e.target.classList.contains('item-amount')) updateCalculations();
if (e.target.classList.contains('item-amount')) {
updateCalculations();
}
});
// === VAT TYPE RADIO CHANGES ===
vatRadios.forEach(function(radio) {
radio.addEventListener('change', updateCalculations);
});
// === VAT CALCULATION ===
// === VAT CALCULATION LOGIC ===
// Mirrors the backend Python calculation exactly.
// Three modes: Included (reverse 15%), Excluded (add 15%), None (no VAT).
function updateCalculations() {
// Sum all visible (non-deleted) item amounts
var sum = 0;
document.querySelectorAll('.item-row:not(.deleted) .item-amount').forEach(function(input) {
sum += parseFloat(input.value) || 0;
var amounts = document.querySelectorAll('.item-row:not(.deleted) .item-amount');
amounts.forEach(function(input) {
var val = parseFloat(input.value) || 0;
sum += val;
});
// Find which VAT radio is selected
var vatType = 'None';
vatRadios.forEach(function(r) { if (r.checked) vatType = r.value; });
vatRadios.forEach(function(r) {
if (r.checked) vatType = r.value;
});
var subtotal = 0;
var vat = 0;
var total = 0;
var subtotal = 0, vat = 0, total = 0;
if (vatType === 'Included') {
total = sum; subtotal = total / 1.15; vat = total - subtotal;
// Entered amounts include VAT — reverse it out
total = sum;
subtotal = total / 1.15;
vat = total - subtotal;
} else if (vatType === 'Excluded') {
subtotal = sum; vat = subtotal * 0.15; total = subtotal + vat;
// Entered amounts are pre-VAT — add 15% on top
subtotal = sum;
vat = subtotal * 0.15;
total = subtotal + vat;
} else {
subtotal = sum; total = sum;
// No VAT
subtotal = sum;
vat = 0;
total = sum;
}
// Update the display using textContent (safe, no HTML injection)
displaySubtotal.textContent = subtotal.toFixed(2);
displayVat.textContent = vat.toFixed(2);
displayTotal.textContent = total.toFixed(2);
}
// Run once on page load (in case form has pre-filled values)
updateCalculations();
})();
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -1,91 +1,36 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Payroll Dashboard | FoxFitt{% endblock %}
{% block title %}Payroll Dashboard | Fox Fitt{% endblock %}
{% block content %}
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
// === CHART.JS THEME DEFAULTS ===
// Read CSS variable colours so chart axes/grid lines adapt to dark mode
(function() {
var style = getComputedStyle(document.documentElement);
var textColor = style.getPropertyValue('--text-secondary').trim() || '#64748b';
var borderColor = style.getPropertyValue('--border-default').trim() || '#e2e8f0';
if (typeof Chart !== 'undefined') {
Chart.defaults.color = textColor;
Chart.defaults.borderColor = borderColor;
}
})();
</script>
<div class="container py-4">
{# === PAGE HEADER === #}
{# 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">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-info shadow-sm" 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 btn-sm btn-md-normal" 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" 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-outline-success shadow-sm btn-sm btn-md-normal fw-bold" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<button type="button" class="btn btn-accent shadow-sm" 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 btn-sm btn-md-normal" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<button type="button" class="btn btn-outline-warning shadow-sm" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<i class="fas fa-clock fa-sm me-1"></i> Price Overtime
</button>
</div>
</div>
{# === 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 --- #}
{# === ANALYTICS CARDS === #}
{# Left side: 3 single-value stat cards (2 on top + 1 below) #}
{# Right side: Project breakdown card spanning full height — no scroll #}
<div class="row g-3 mb-4">
{# --- Left column: stat cards --- #}
@ -93,37 +38,39 @@
<div class="row g-3 h-100">
{# Outstanding Total — with breakdown of wages vs adjustments #}
<div class="col-sm-6">
<div class="stat-card stat-card--danger h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Outstanding Payments</div>
<div class="stat-value">R {{ outstanding_total|floatformat:2 }}</div>
{% if pending_adj_add_total or pending_adj_sub_total %}
<div class="mt-2 pt-2" style="border-top: 1px solid var(--border-subtle); font-size: 0.75rem; color: var(--text-secondary);">
<div class="d-flex justify-content-between">
<span>Unpaid wages</span>
<span>R {{ unpaid_wages_total|floatformat:2 }}</span>
</div>
{% if pending_adj_add_total %}
<div class="d-flex justify-content-between">
<span>+ Additions</span>
<span style="color: var(--color-success);">R {{ pending_adj_add_total|floatformat:2 }}</span>
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #ef4444;">
Outstanding Payments</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ outstanding_total|floatformat:2 }}</div>
{# === BREAKDOWN — only shown when there are pending adjustments === #}
{% if pending_adj_add_total or pending_adj_sub_total %}
<div class="mt-2 pt-2 border-top" style="font-size: 0.75rem; color: #64748b;">
<div class="d-flex justify-content-between">
<span>Unpaid wages</span>
<span>R {{ unpaid_wages_total|floatformat:2 }}</span>
</div>
{% if pending_adj_add_total %}
<div class="d-flex justify-content-between">
<span>+ Additions</span>
<span class="text-success">R {{ pending_adj_add_total|floatformat:2 }}</span>
</div>
{% endif %}
{% if pending_adj_sub_total %}
<div class="d-flex justify-content-between">
<span>- Deductions</span>
<span class="text-danger">-R {{ pending_adj_sub_total|floatformat:2 }}</span>
</div>
{% endif %}
</div>
{% endif %}
{% if pending_adj_sub_total %}
<div class="d-flex justify-content-between">
<span>- Deductions</span>
<span style="color: var(--color-danger);">-R {{ pending_adj_sub_total|floatformat:2 }}</span>
<div class="mt-1" style="font-size: 0.65rem; color: #94a3b8;">
<i class="fas fa-info-circle"></i> Loan repayments deducted at payment time
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-1" style="font-size: 0.65rem; color: var(--text-tertiary);">
<i class="fas fa-info-circle"></i> Loan repayments deducted at payment time
</div>
</div>
<div class="stat-icon stat-icon--danger">
<i class="fas fa-exclamation-circle"></i>
<i class="fas fa-exclamation-circle fa-2x text-danger opacity-25"></i>
</div>
</div>
</div>
@ -131,14 +78,15 @@
{# Recent Payments #}
<div class="col-sm-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Paid (Last 60 Days)</div>
<div class="stat-value">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
<div class="stat-icon stat-icon--success">
<i class="fas fa-check-circle"></i>
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
Paid (Last 60 Days)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
<i class="fas fa-check-circle fa-2x text-success opacity-25"></i>
</div>
</div>
</div>
@ -146,14 +94,15 @@
{# Active Loans — spans full width below the first two #}
<div class="col-12">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Active Loans & Advances ({{ active_loans_count }})</div>
<div class="stat-value">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<div class="stat-icon stat-icon--warning">
<i class="fas fa-hand-holding-usd"></i>
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
Active Loans & Advances ({{ active_loans_count }})</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-25"></i>
</div>
</div>
</div>
@ -163,26 +112,25 @@
{# --- Right column: project breakdown (grows to fit all projects) --- #}
<div class="col-xl-5 d-flex">
<div class="stat-card stat-card--info p-3 w-100">
<div class="d-flex flex-column h-100">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="stat-label">Outstanding by Project</div>
<div class="stat-icon stat-icon--info">
<i class="fas fa-chart-pie"></i>
</div>
<div class="card stat-card py-2 w-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="text-xs font-weight-bold text-uppercase" style="color: #3b82f6;">
Outstanding by Project</div>
<i class="fas fa-chart-pie fa-2x text-primary opacity-25"></i>
</div>
{% if outstanding_project_costs %}
<div class="flex-grow-1">
{% for pc in outstanding_project_costs %}
<div class="d-flex justify-content-between align-items-center {% if not forloop.last %}mb-2 pb-2{% endif %}" {% if not forloop.last %}style="border-bottom: 1px solid var(--border-subtle);"{% endif %}>
<span class="fw-semibold">{{ pc.name }}</span>
<span class="fw-bold" style="color: var(--color-info);">R {{ pc.cost|floatformat:2 }}</span>
<div class="d-flex justify-content-between align-items-center {% if not forloop.last %}mb-2 pb-2 border-bottom{% endif %}">
<span class="fw-semibold text-gray-800">{{ pc.name }}</span>
<span class="fw-bold" style="color: #3b82f6;">R {{ pc.cost|floatformat:2 }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex-grow-1 d-flex align-items-center justify-content-center">
<span style="color: var(--text-tertiary);"><i class="fas fa-check-circle me-1"></i> No outstanding amounts</span>
<span class="text-muted"><i class="fas fa-check-circle me-1"></i> No outstanding amounts</span>
</div>
{% endif %}
</div>
@ -191,14 +139,16 @@
</div>
{# --- Charts row --- #}
{# === CHARTS === #}
<div class="row mb-4">
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card h-100">
<div class="card-header py-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
{# === CHART TOGGLE: Overall vs By Worker === #}
{# Two small buttons to switch between the total line chart #}
{# and a per-worker stacked bar chart breakdown. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold">Monthly Payroll</h6>
<h6 class="m-0 font-weight-bold" style="color: var(--primary-dark);">Monthly Payroll</h6>
<div class="btn-group btn-group-sm" role="group" aria-label="Chart view toggle">
<button type="button" class="btn btn-sm btn-accent" id="btnOverall">
<i class="fas fa-chart-line fa-sm me-1"></i>Overall
@ -235,9 +185,9 @@
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold">Cost by Project (Monthly)</h6>
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Cost by Project (Monthly)</h6>
</div>
<div class="card-body">
<canvas id="projectChart" height="200"></canvas>
@ -246,8 +196,6 @@
</div>
</div>
</div>{# /analyticsDetail #}
{# === TAB NAVIGATION === #}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
@ -298,21 +246,18 @@
</div>
</div>
<div class="card">
<div class="card shadow-sm border-0">
<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" 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">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="fw-bold">Total</th>
<th scope="col" class="pe-4 text-end">Actions</th>
</tr>
@ -323,32 +268,22 @@
data-overdue="{{ wd.is_overdue|yesno:'true,false' }}"
data-has-loan="{{ wd.has_loan|yesno:'true,false' }}">
<td class="ps-4 align-middle">
<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>
<a href="#" class="worker-lookup-link text-decoration-none text-dark 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 text-dark ms-1" title="Has active loan or advance">Loan</span>
{% endif %}
</td>
<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">
<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">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
{# 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"
<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"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
@ -366,7 +301,7 @@
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="align-middle d-none d-md-table-cell {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
<td class="align-middle {% 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>
@ -388,7 +323,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><span class="d-none d-sm-inline"> Pay</span>
<i class="fas fa-money-bill-wave me-1"></i> Pay
</button>
</form>
</div>
@ -413,35 +348,33 @@
{# === PAYMENT HISTORY TAB === #}
{# =============================================== #}
{% if active_tab == 'paid' %}
<div class="card">
<div class="card shadow-sm border-0">
<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 d-none d-md-table-cell">Date</th>
<th scope="col" class="ps-4 ps-md-0">Worker</th>
<th scope="col" class="ps-4">Date</th>
<th scope="col">Worker</th>
<th scope="col">Amount Paid</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">Work Logs</th>
<th scope="col">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 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"
<td class="ps-4 align-middle">{{ record.date }}</td>
<td class="align-middle"><a href="#" class="worker-lookup-link text-decoration-none text-dark 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 d-none d-md-table-cell">
<td class="align-middle">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td>
<td class="align-middle d-none d-lg-table-cell">
<td class="align-middle">
{% for adj in record.adjustments.all %}
<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">
<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">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
@ -450,7 +383,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><span class="d-none d-sm-inline"> View</span>
<i class="fas fa-file-alt me-1"></i> View
</a>
</td>
</tr>
@ -483,27 +416,25 @@
History
</a>
</div>
<div class="card">
<div class="card shadow-sm border-0">
<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" 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>
<th scope="col">Date</th>
<th scope="col">Reason</th>
<th scope="col" class="pe-4">Status</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 align-middle"><a href="#" class="worker-lookup-link fw-bold"
<td class="ps-4 align-middle"><a href="#" class="worker-lookup-link text-decoration-none text-dark fw-bold"
data-worker-id="{{ loan.worker.id }}">{{ loan.worker.name }}</a></td>
<td class="align-middle">
{% if loan.loan_type == 'advance' %}
@ -514,9 +445,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 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">
<td class="align-middle">{{ loan.date }}</td>
<td class="align-middle">{{ loan.reason|default:"-" }}</td>
<td class="pe-4 align-middle">
{% if loan.active %}
<span class="badge bg-warning text-dark">Active</span>
{% else %}
@ -603,7 +534,7 @@
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none text-nowrap">Select All</a>
<a href="#" id="adjDeselectAll" class="small text-muted text-decoration-none text-nowrap">Clear</a>
</div>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: 8px; background: var(--bg-inset);">
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px; padding: 8px;">
{% for w in all_workers %}
<div class="form-check">
<input class="form-check-input add-adj-worker" type="checkbox"
@ -1737,9 +1668,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.pay_period && data.pay_period.has_schedule) {
var periodInfo = document.createElement('div');
periodInfo.className = 'alert alert-info py-2 px-3 small mb-2';
periodInfo.style.backgroundColor = 'var(--color-info-bg)';
periodInfo.style.borderColor = 'var(--color-info)';
periodInfo.style.color = 'var(--color-info)';
periodInfo.style.backgroundColor = '#e0f2fe';
periodInfo.style.borderColor = '#7dd3fc';
periodInfo.style.color = '#0c4a6e';
var infoIcon = document.createElement('i');
infoIcon.className = 'fas fa-calendar-alt me-2';
@ -2069,7 +2000,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Card container for each loan/advance
var card = document.createElement('div');
card.className = 'border rounded p-2 mb-2';
card.style.backgroundColor = 'var(--bg-inset)';
card.style.backgroundColor = '#f8f9fa';
// Row 1: Type badge + Balance
var topRow = document.createElement('div');
@ -2859,21 +2790,23 @@ document.addEventListener('DOMContentLoaded', function() {
var statsRow = el('div', 'row g-2 mb-4');
var stats = [
{ label: 'Amount Payable', value: data.amount_payable, color: 'var(--text-primary)' },
{ label: 'Outstanding Loans', value: data.outstanding_loans, color: 'var(--color-warning)' },
{ label: 'Paid This Month', value: data.paid_this_month, color: 'var(--color-success)' },
{ label: 'Loans This Year', value: data.loans_this_year, color: 'var(--color-danger)' },
{ label: 'Amount Payable', value: data.amount_payable, color: '#0f172a' },
{ label: 'Outstanding Loans', value: data.outstanding_loans, color: '#f59e0b' },
{ label: 'Paid This Month', value: data.paid_this_month, color: '#10b981' },
{ label: 'Loans This Year', value: data.loans_this_year, color: '#ef4444' },
];
stats.forEach(function(stat) {
var col = el('div', 'col-6 col-md-3');
var card = el('div', 'stat-card h-100');
card.style.padding = '0.75rem';
var label = el('div', 'stat-label');
var card = el('div', 'card border-0 shadow-sm h-100');
var body = el('div', 'card-body text-center py-2 px-2');
var label = el('div', 'text-uppercase small fw-bold mb-1');
label.style.color = stat.color;
label.style.fontSize = '0.65rem';
label.textContent = stat.label;
card.appendChild(label);
card.appendChild(el('div', 'fw-bold', formatRand(stat.value)));
body.appendChild(label);
body.appendChild(el('div', 'fw-bold', formatRand(stat.value)));
card.appendChild(body);
col.appendChild(card);
statsRow.appendChild(col);
});
@ -2893,11 +2826,9 @@ document.addEventListener('DOMContentLoaded', function() {
];
activities.forEach(function(act) {
var row = el('div', 'd-flex justify-content-between align-items-center py-1 border-bottom');
row.style.fontSize = '0.78rem';
var row = el('div', 'd-flex justify-content-between align-items-center py-2 border-bottom');
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);
@ -2905,14 +2836,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', formatDate(act.data.date)));
right.appendChild(el('span', 'text-muted small', formatDate(act.data.date)));
if (act.data.reason) {
right.appendChild(document.createTextNode(' '));
right.appendChild(el('span', 'text-muted fst-italic', '(' + act.data.reason + ')'));
right.appendChild(el('span', 'text-muted small fst-italic', '(' + act.data.reason + ')'));
}
row.appendChild(right);
} else {
row.appendChild(el('span', 'text-muted', 'None'));
row.appendChild(el('span', 'text-muted small', 'None'));
}
actSection.appendChild(row);
@ -2954,7 +2885,7 @@ document.addEventListener('DOMContentLoaded', function() {
// --- PAID THIS YEAR ---
var yearSection = el('div', 'mb-4 p-3 rounded');
yearSection.style.backgroundColor = 'var(--bg-inset)';
yearSection.style.backgroundColor = '#f1f5f9';
var yearLabel = el('span', 'text-muted small text-uppercase', 'Paid This Year: ');
var yearValue = el('span', 'fw-bold', formatRand(data.paid_this_year));
yearSection.appendChild(yearLabel);
@ -2988,7 +2919,7 @@ document.addEventListener('DOMContentLoaded', function() {
var notesLabel = el('div', 'small text-muted mt-2', 'Notes:');
infoSection.appendChild(notesLabel);
var notesText = el('div', 'small p-2 rounded');
notesText.style.backgroundColor = 'var(--bg-inset)';
notesText.style.backgroundColor = '#f8f9fa';
notesText.textContent = data.notes;
infoSection.appendChild(notesText);
}
@ -3033,27 +2964,6 @@ 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

@ -1,15 +1,16 @@
{% extends 'base.html' %}
{% block title %}Payslip #{{ record.id }} | FoxFitt{% endblock %}
{% block title %}Payslip #{{ record.id }} | Fox Fitt{% endblock %}
{% block content %}
<!-- === PAYSLIP DETAIL PAGE ===
Shows a completed payment with work logs, adjustments, and totals.
Print-friendly layout. -->
Reached from the Payment History tab on the payroll dashboard.
Has a Print button that uses the browser's native print dialog. -->
<div class="container py-4">
<div class="container py-5">
<!-- Action buttons (hidden when printing) -->
<div class="d-print-none mb-4 d-flex gap-2 flex-wrap">
<div class="d-print-none mb-4 d-grid gap-2 d-md-flex">
<a href="{% url 'payroll_dashboard' %}?status=paid" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back to Payment History
</a>
@ -19,41 +20,35 @@
</div>
<!-- Payslip card -->
<div class="card" id="payslip-card">
<div class="card-body p-4 p-md-5">
<div class="card border-0 shadow-sm" id="payslip-card">
<div class="card-body p-5">
<!-- === HEADER === -->
<div class="row mb-5 pb-4 align-items-center" style="border-bottom: 2px solid var(--border-default);">
<!-- === HEADER — worker name is the dominant element === -->
<div class="row mb-5 border-bottom pb-4 align-items-center">
<div class="col-md-6">
<div class="stat-label mb-1">Payment To Beneficiary:</div>
<h2 class="fw-bold mb-0 text-uppercase">{{ record.worker.name }}</h2>
<p class="mb-0" style="color: var(--text-tertiary); font-size: 0.85rem;">
{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}
</p>
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
<h2 class="fw-bold text-dark mb-0 text-uppercase">{{ record.worker.name }}</h2>
<p class="text-muted small mb-0">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}</p>
</div>
<div class="col-md-6 text-md-end mt-3 mt-md-0">
<h3 class="fw-bold text-uppercase" style="color: var(--text-secondary);">
{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip
</h3>
<h3 class="fw-bold text-uppercase text-secondary mb-1">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip</h3>
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
<div style="color: var(--text-tertiary); font-size: 0.85rem;">Payer: Fox Fitt</div>
<div class="text-muted small">Payer: Fox Fitt</div>
</div>
</div>
<!-- === WORKER DETAILS + NET PAY === -->
<div class="row mb-5">
<div class="col-md-6">
<div class="stat-label mb-2">Beneficiary Details:</div>
<h6 class="text-uppercase text-muted fw-bold small mb-3">Beneficiary Details:</h6>
<h4 class="fw-bold">{{ record.worker.name }}</h4>
<p class="mb-0">ID Number: <strong>{{ record.worker.id_number }}</strong></p>
<p class="mb-0" style="color: var(--text-secondary);">Phone: {{ record.worker.phone_number|default:"—" }}</p>
<p class="mb-0">Phone: {{ record.worker.phone_number|default:"—" }}</p>
</div>
<div class="col-md-6 text-md-end mt-4 mt-md-0">
<div class="stat-label mb-2">Net Payable Amount:</div>
<div style="font-size: 2.5rem; font-weight: 700; font-family: 'Poppins', sans-serif;">
R {{ record.amount_paid|floatformat:2 }}
</div>
<p class="fw-bold mt-2" style="color: var(--color-success); font-size: 0.85rem;">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Net Payable Amount:</h6>
<div class="display-6 fw-bold text-dark">R {{ record.amount_paid|floatformat:2 }}</div>
<p class="text-success small fw-bold mt-2">
<i class="fas fa-check-circle me-1"></i> PAID
</p>
</div>
@ -61,10 +56,10 @@
{% if is_advance %}
<!-- === ADVANCE PAYMENT DETAIL === -->
<div class="stat-label mb-3">Advance Details</div>
<h6 class="text-uppercase text-muted fw-bold small mb-3">Advance Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead>
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
@ -75,17 +70,19 @@
<tbody>
<tr>
<td>{{ advance_adj.date|date:"M d, Y" }}</td>
<td><span class="badge" style="background: var(--color-info-bg); color: var(--color-info);">ADVANCE PAYMENT</span></td>
<td><span class="badge bg-info text-dark text-uppercase">Advance Payment</span></td>
<td>{{ advance_adj.description|default:"Salary advance" }}</td>
<td class="text-end fw-bold" style="color: var(--color-success);">R {{ advance_adj.amount|floatformat:2 }}</td>
<td class="text-end text-success fw-bold">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === ADVANCE TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr style="border-top: 2px solid var(--text-primary);">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Amount Advanced:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
@ -95,10 +92,10 @@
{% elif is_loan %}
<!-- === LOAN PAYMENT DETAIL === -->
<div class="stat-label mb-3">Loan Details</div>
<h6 class="text-uppercase text-muted fw-bold small mb-3">Loan Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead>
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
@ -109,17 +106,19 @@
<tbody>
<tr>
<td>{{ loan_adj.date|date:"M d, Y" }}</td>
<td><span class="badge" style="background: var(--color-warning-bg); color: var(--color-warning);">LOAN PAYMENT</span></td>
<td><span class="badge bg-warning text-dark text-uppercase">Loan Payment</span></td>
<td>{{ loan_adj.description|default:"Worker loan" }}</td>
<td class="text-end fw-bold" style="color: var(--color-success);">R {{ loan_adj.amount|floatformat:2 }}</td>
<td class="text-end text-success fw-bold">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === LOAN TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr style="border-top: 2px solid var(--text-primary);">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Loan Amount:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
@ -128,11 +127,11 @@
</div>
{% else %}
<!-- === WORK LOG TABLE === -->
<div class="stat-label mb-3">Work Log Details (Attendance)</div>
<!-- === WORK LOG TABLE — each day worked === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead>
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
@ -145,19 +144,19 @@
<tr>
<td>{{ log.date|date:"M d, Y" }}</td>
<td>{{ log.project.name }}</td>
<td style="color: var(--text-secondary);">{{ log.notes|default:"—"|truncatechars:50 }}</td>
<td>{{ log.notes|default:"—"|truncatechars:50 }}</td>
<td class="text-end">R {{ record.worker.daily_rate|floatformat:2 }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center py-3" style="color: var(--text-tertiary);">
<td colspan="4" class="text-center text-muted">
<i class="fas fa-info-circle me-1"></i> No work logs in this period.
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="background: var(--bg-inset);">
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
<td class="text-end fw-bold">R {{ base_pay|floatformat:2 }}</td>
</tr>
@ -165,12 +164,12 @@
</table>
</div>
<!-- === ADJUSTMENTS TABLE === -->
<!-- === ADJUSTMENTS TABLE — bonuses, deductions, overtime, loan repayments === -->
{% if adjustments %}
<div class="stat-label mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</div>
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead>
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
@ -183,13 +182,15 @@
<tr>
<td>{{ adj.date|date:"M d, Y" }}</td>
<td>
<span class="badge" style="background: var(--bg-inset); color: var(--text-secondary); border: 1px solid var(--border-default);">
{{ adj.get_type_display|upper }}
</span>
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
</td>
<td>{{ adj.description }}</td>
<td class="text-end fw-semibold {% if adj.type in deductive_types %}{% else %}{% endif %}" style="color: {% if adj.type in deductive_types %}var(--color-danger){% else %}var(--color-success){% endif %};">
{% if adj.type in deductive_types %}- R {{ adj.amount|floatformat:2 }}{% else %}+ R {{ adj.amount|floatformat:2 }}{% endif %}
<td class="text-end {% if adj.type in deductive_types %}text-danger{% else %}text-success{% endif %}">
{% if adj.type in deductive_types %}
- R {{ adj.amount|floatformat:2 }}
{% else %}
+ R {{ adj.amount|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endfor %}
@ -198,23 +199,27 @@
</div>
{% endif %}
<!-- === GRAND TOTAL === -->
<!-- === GRAND TOTAL SUMMARY === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr>
<td class="text-end border-0" style="color: var(--text-secondary);">Base Pay:</td>
<td class="text-end border-0 text-muted">Base Pay:</td>
<td class="text-end border-0" width="140">R {{ base_pay|floatformat:2 }}</td>
</tr>
{% if adjustments %}
<tr>
<td class="text-end border-0" style="color: var(--text-secondary);">Adjustments Net:</td>
<td class="text-end border-0 text-muted">Adjustments Net:</td>
<td class="text-end border-0">
{% if adjustments_net >= 0 %}+ R {{ adjustments_net|floatformat:2 }}{% else %}- R {{ adjustments_net_abs|floatformat:2 }}{% endif %}
{% if adjustments_net >= 0 %}
+ R {{ adjustments_net|floatformat:2 }}
{% else %}
- R {{ adjustments_net_abs|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endif %}
<tr style="border-top: 2px solid var(--text-primary);">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount_paid|floatformat:2 }}</td>
</tr>
@ -224,9 +229,9 @@
{% endif %}
<!-- === FOOTER === -->
<div class="text-center mt-5 pt-4" style="border-top: 1px solid var(--border-default); color: var(--text-tertiary); font-size: 0.85rem;">
<div class="text-center text-muted small mt-5 pt-4 border-top">
<p>This is a computer-generated document and does not require a signature.</p>
<p>Payer: Fox Fitt &copy; {% now "Y" %}</p>
<p>Payer: Fox Fitt &copy; 2026</p>
</div>
</div>
</div>

View File

@ -1,623 +0,0 @@
{% 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

@ -1,188 +0,0 @@
{% 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

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

View File

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

View File

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

View File

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

@ -1,329 +0,0 @@
{% 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

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

View File

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

View File

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

View File

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

@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Work History | FoxFitt{% endblock %}
{% block title %}Work History | Fox Fitt{% endblock %}
{% block content %}
<!-- === WORK HISTORY PAGE ===
@ -13,9 +13,9 @@
<div class="container py-4">
{# === PAGE HEADER with view toggle and export === #}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h1 class="page-title"><i class="fas fa-clock me-2" style="color: var(--accent);"></i>Work History</h1>
<div class="d-flex gap-2 flex-wrap">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Work History</h1>
<div class="d-flex gap-2">
{# View toggle — List vs Calendar #}
<div class="btn-group" role="group" aria-label="View mode">
<a href="?view=list{{ filter_params }}"
@ -27,48 +27,59 @@
<i class="fas fa-calendar-alt me-1"></i> Calendar
</a>
</div>
{# CSV Export button — keeps the current filters in the export URL #}
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&status={{ selected_status }}"
class="btn btn-outline-secondary btn-sm">
class="btn btn-outline-success btn-sm shadow-sm">
<i class="fas fa-file-csv me-1"></i> Export CSV
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm">
<a href="{% url 'home' %}" class="btn btn-outline-secondary btn-sm shadow-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
</div>
{# === FILTER BAR === #}
<div class="card mb-4{% if has_active_filters %} border-start border-3{% endif %}" {% if has_active_filters %}style="border-left-color: var(--accent) !important;"{% endif %}>
<div class="card shadow-sm border-0 mb-4{% if has_active_filters %} border-start border-3{% endif %}" {% if has_active_filters %}style="border-left-color: var(--accent, #10b981) !important;"{% endif %}>
<div class="card-body py-3">
<form method="GET" action="{% url 'work_history' %}" class="row g-2 align-items-end">
{# Preserve current view mode when filtering #}
<input type="hidden" name="view" value="{{ view_mode }}">
{% if view_mode == 'calendar' %}
{# Preserve current calendar month when filtering #}
<input type="hidden" name="year" value="{{ curr_year }}">
<input type="hidden" name="month" value="{{ curr_month }}">
{% endif %}
{# Filter by Worker #}
<div class="col-md-3">
<label class="form-label">Worker</label>
<label class="form-label small text-muted mb-1">Worker</label>
<select name="worker" class="form-select form-select-sm">
<option value="">All Workers</option>
{% for w in filter_workers %}
<option value="{{ w.id }}" {% if selected_worker == w.id|stringformat:"d" %}selected{% endif %}>{{ w.name }}</option>
<option value="{{ w.id }}" {% if selected_worker == w.id|stringformat:"d" %}selected{% endif %}>
{{ w.name }}
</option>
{% endfor %}
</select>
</div>
{# Filter by Project #}
<div class="col-md-3">
<label class="form-label">Project</label>
<label class="form-label small text-muted mb-1">Project</label>
<select name="project" class="form-select form-select-sm">
<option value="">All Projects</option>
{% for p in filter_projects %}
<option value="{{ p.id }}" {% if selected_project == p.id|stringformat:"d" %}selected{% endif %}>{{ p.name }}</option>
<option value="{{ p.id }}" {% if selected_project == p.id|stringformat:"d" %}selected{% endif %}>
{{ p.name }}
</option>
{% endfor %}
</select>
</div>
{# Filter by Payment Status #}
<div class="col-md-3">
<label class="form-label">Payment Status</label>
<label class="form-label small text-muted mb-1">Payment Status</label>
<select name="status" class="form-select form-select-sm">
<option value="" {% if not selected_status %}selected{% endif %}>All</option>
<option value="paid" {% if selected_status == 'paid' %}selected{% endif %}>Paid</option>
@ -76,6 +87,7 @@
</select>
</div>
{# Filter + Clear Buttons #}
<div class="col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i> Filter
@ -88,28 +100,31 @@
</div>
</form>
{# Active filter feedback #}
{# === Active Filter Feedback === #}
{# Shows a results counter when filters are active so the user can see the filter is working #}
{% if has_active_filters %}
<div class="mt-2 d-flex align-items-center flex-wrap gap-2">
<small style="color: var(--text-secondary);">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Showing <strong>{{ filtered_log_count }}</strong> of {{ total_log_count }} work log{{ total_log_count|pluralize }}
</small>
{# Show which filters are active as small badges #}
{% if selected_worker %}
<span class="badge" style="background: var(--color-info-bg); color: var(--color-info); border: 1px solid var(--color-info);">
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25">
<i class="fas fa-user fa-xs me-1"></i>
{% for w in filter_workers %}{% if w.id|stringformat:"d" == selected_worker %}{{ w.name }}{% endif %}{% endfor %}
</span>
{% endif %}
{% if selected_project %}
<span class="badge" style="background: var(--color-success-bg); color: var(--color-success); border: 1px solid var(--color-success);">
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="fas fa-project-diagram fa-xs me-1"></i>
{% for p in filter_projects %}{% if p.id|stringformat:"d" == selected_project %}{{ p.name }}{% endif %}{% endfor %}
</span>
{% endif %}
{% if selected_status %}
<span class="badge" style="background: var(--color-warning-bg); color: var(--color-warning); border: 1px solid var(--color-warning);">
<i class="fas fa-tag fa-xs me-1"></i>{{ selected_status|capfirst }}
<span class="badge bg-warning bg-opacity-10 text-dark border border-warning border-opacity-25">
<i class="fas fa-tag fa-xs me-1"></i>
{{ selected_status|capfirst }}
</span>
{% endif %}
</div>
@ -123,15 +138,17 @@
{# === CALENDAR VIEW === #}
{# =============================================================== #}
{# Month navigation #}
<div class="card mb-3">
{# Month navigation header #}
<div class="card shadow-sm border-0 mb-3">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<a href="?view=calendar&year={{ prev_year }}&month={{ prev_month }}{{ filter_params }}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-chevron-left"></i>
</a>
<h5 class="mb-0 fw-bold" style="font-family: 'Poppins', sans-serif;">{{ month_name }}</h5>
<h5 class="mb-0 fw-bold" style="font-family: 'Poppins', sans-serif;">
{{ month_name }}
</h5>
<a href="?view=calendar&year={{ next_year }}&month={{ next_month }}{{ filter_params }}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-chevron-right"></i>
@ -141,10 +158,10 @@
</div>
{# Calendar grid #}
<div class="card mb-3">
<div class="card shadow-sm border-0 mb-3">
<div class="card-body p-0 p-md-3">
{# Day-of-week header #}
<div class="row g-0 d-none d-md-flex text-center fw-bold border-bottom pb-2 mb-2" style="font-size: 0.85rem; color: var(--text-secondary);">
{# Day-of-week header row #}
<div class="row g-0 d-none d-md-flex text-center fw-bold text-secondary border-bottom pb-2 mb-2" style="font-size: 0.85rem;">
<div class="col">Mon</div>
<div class="col">Tue</div>
<div class="col">Wed</div>
@ -154,32 +171,36 @@
<div class="col">Sun</div>
</div>
{# Calendar weeks — each row is 7 day cells #}
{% for week in calendar_weeks %}
<div class="row g-0 g-md-1 mb-0 mb-md-1">
{% for day in week %}
<div class="col cal-day {% if not day.is_current_month %}cal-day--other{% endif %}{% if day.is_today %} cal-day--today{% endif %}{% if day.count > 0 %} cal-day--has-logs{% endif %}"
{% if day.count > 0 %}data-date="{{ day.date|date:'Y-m-d' }}"{% endif %}>
{# Day number + badge count #}
<div class="d-flex justify-content-between align-items-start">
<span class="cal-day__number">{{ day.day }}</span>
<span class="cal-day__number {% if day.is_today %}fw-bold{% endif %}">{{ day.day }}</span>
{% if day.count > 0 %}
<span class="badge rounded-pill" style="font-size: 0.65rem; background: var(--accent);">{{ day.count }}</span>
<span class="badge bg-primary rounded-pill" style="font-size: 0.65rem;">{{ day.count }}</span>
{% endif %}
</div>
{# Mini log indicators (show first 3 entries) #}
{% for log in day.records|slice:":3" %}
<div class="cal-entry text-truncate" title="{{ log.project.name }}">
<small>
{% if log.payroll_records.exists %}
<i class="fas fa-check-circle" style="font-size: 0.55rem; color: var(--color-success);"></i>
<i class="fas fa-check-circle text-success" style="font-size: 0.55rem;"></i>
{% else %}
<i class="fas fa-clock" style="font-size: 0.55rem; color: var(--color-warning);"></i>
<i class="fas fa-clock text-warning" style="font-size: 0.55rem;"></i>
{% endif %}
{{ log.project.name }}
</small>
</div>
{% endfor %}
{# "and X more" indicator #}
{% if day.count > 3 %}
<div class="cal-entry">
<small style="color: var(--text-tertiary);">+{{ day.count|add:"-3" }} more</small>
<small class="text-muted">+{{ day.count|add:"-3" }} more</small>
</div>
{% endif %}
</div>
@ -190,49 +211,62 @@
</div>
{# === Day Detail Panel === #}
<div class="card d-none" id="dayDetailPanel">
<div class="card-header py-2">
{# Hidden by default. Click day cells to select them — shows combined details with totals. #}
<div class="card shadow-sm border-0 d-none" id="dayDetailPanel">
<div class="card-header py-2 bg-white">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold" id="dayDetailTitle">
<i class="fas fa-calendar-day me-2"></i>Details
</h6>
<div class="d-flex gap-2 align-items-center">
<span class="badge rounded-pill d-none" style="background: var(--accent);" id="daySelectionCount"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clearDaySelection" title="Clear selection">
<span class="badge bg-primary rounded-pill d-none" id="daySelectionCount"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clearDaySelection"
title="Clear selection">
<i class="fas fa-times-circle me-1"></i> Clear
</button>
</div>
</div>
<small class="d-block mt-1" style="color: var(--text-tertiary);" id="multiSelectHint">
{# Hint text for multi-select #}
<small class="text-muted d-block mt-1" id="multiSelectHint">
<i class="fas fa-info-circle me-1"></i>Click more days to add them to the selection
</small>
</div>
<div class="card-body p-0" id="dayDetailBody"></div>
<div class="card-body p-0" id="dayDetailBody">
{# Content built by JavaScript #}
</div>
{# === Totals Footer (admin only, shown when days are selected) === #}
{% if is_admin %}
<div class="card-footer border-top d-none" id="dayDetailFooter">
<div class="card-footer bg-white border-top d-none" id="dayDetailFooter">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Total:</strong>
<span class="ms-2" style="color: var(--text-secondary);" id="totalDays">0 days</span>
<span class="mx-1" style="color: var(--text-tertiary);">&middot;</span>
<span style="color: var(--text-secondary);" id="totalLogs">0 logs</span>
<span class="mx-1" style="color: var(--text-tertiary);">&middot;</span>
<span style="color: var(--text-secondary);" id="totalWorkers">0 unique workers</span>
<span class="text-muted ms-2" id="totalDays">0 days</span>
<span class="text-muted mx-1">·</span>
<span class="text-muted" id="totalLogs">0 logs</span>
<span class="text-muted mx-1">·</span>
<span class="text-muted" id="totalWorkers">0 unique workers</span>
</div>
<div>
<strong class="fs-5" style="color: var(--accent);" id="totalAmount">R 0.00</strong>
<strong class="fs-5" style="color: var(--accent-color, #10b981);" id="totalAmount">R 0.00</strong>
</div>
</div>
</div>
{% endif %}
</div>
{# Pass calendar detail data to JavaScript safely using json_script #}
{{ calendar_detail|json_script:"calDetailJson" }}
<script>
(function() {
'use strict';
// === CALENDAR MULTI-DAY SELECTION ===
// Click a day to add it to the selection. Click again to deselect.
// The detail panel shows combined data from ALL selected days.
// Admin users see a total amount across all selected days.
// Parse calendar detail data (keyed by date string, e.g. "2026-02-22")
var calDetail = JSON.parse(document.getElementById('calDetailJson').textContent);
var detailPanel = document.getElementById('dayDetailPanel');
var detailTitle = document.getElementById('dayDetailTitle');
@ -242,49 +276,80 @@
var multiSelectHint = document.getElementById('multiSelectHint');
var isAdmin = {{ is_admin|yesno:"true,false" }};
var detailFooter = document.getElementById('dayDetailFooter');
// Track which dates are currently selected (array of date strings)
var selectedDates = [];
// Short month names for formatting dates
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
// === Format a date string (YYYY-MM-DD) for display (e.g. "22 Feb") ===
function formatDateShort(dateStr) {
var parts = dateStr.split('-');
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1];
var day = parseInt(parts[2], 10);
var monthIdx = parseInt(parts[1], 10) - 1;
return day + ' ' + months[monthIdx];
}
// === Format a date string for longer display (e.g. "22 Feb 2026") ===
function formatDateLong(dateStr) {
var parts = dateStr.split('-');
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
var day = parseInt(parts[2], 10);
var monthIdx = parseInt(parts[1], 10) - 1;
return day + ' ' + months[monthIdx] + ' ' + parts[0];
}
// === Update the detail panel with data from all selected dates ===
function updateDetailPanel() {
if (selectedDates.length === 0) {
// Nothing selected — hide the panel
detailPanel.classList.add('d-none');
return;
}
// Sort selected dates chronologically
selectedDates.sort();
// Collect all entries from all selected dates
var allEntries = [];
var totalAmount = 0;
var uniqueWorkers = {};
selectedDates.forEach(function(dateStr) {
(calDetail[dateStr] || []).forEach(function(entry) {
var entries = calDetail[dateStr] || [];
entries.forEach(function(entry) {
// Tag each entry with its date for display
allEntries.push({ date: dateStr, entry: entry });
entry.workers.forEach(function(w) { uniqueWorkers[w] = true; });
if (isAdmin && entry.amount !== undefined) totalAmount += entry.amount;
// Track unique workers
entry.workers.forEach(function(w) {
uniqueWorkers[w] = true;
});
// Sum amounts (admin only)
if (isAdmin && entry.amount !== undefined) {
totalAmount += entry.amount;
}
});
});
// Title
// === Update panel title ===
detailTitle.textContent = '';
var icon = document.createElement('i');
icon.className = 'fas fa-calendar-day me-2';
detailTitle.appendChild(icon);
if (selectedDates.length === 1) {
detailTitle.appendChild(document.createTextNode(formatDateLong(selectedDates[0]) + ' \u2014 ' + allEntries.length + ' log(s)'));
// Single day: show full date
detailTitle.appendChild(document.createTextNode(
formatDateLong(selectedDates[0]) + ' — ' + allEntries.length + ' log(s)'
));
} else {
detailTitle.appendChild(document.createTextNode(selectedDates.length + ' days selected \u2014 ' + allEntries.length + ' log(s)'));
// Multiple days: show date range or count
detailTitle.appendChild(document.createTextNode(
selectedDates.length + ' days selected — ' + allEntries.length + ' log(s)'
));
}
// Update selection count badge
if (selectedDates.length > 1) {
selCountBadge.textContent = selectedDates.length + ' days';
selCountBadge.classList.remove('d-none');
@ -294,13 +359,17 @@
multiSelectHint.classList.remove('d-none');
}
// Build table
// === Clear previous content ===
while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild);
// === Build detail table ===
var table = document.createElement('table');
table.className = 'table table-sm table-hover mb-0';
var thead = document.createElement('thead');
thead.className = 'table-light';
var headRow = document.createElement('tr');
// Show Date column when multiple days are selected
var headers = selectedDates.length > 1
? ['Date', 'Project', 'Workers', 'Supervisor', 'OT', 'Status']
: ['Project', 'Workers', 'Supervisor', 'OT', 'Status'];
@ -319,6 +388,7 @@
var entry = item.entry;
var tr = document.createElement('tr');
// Date column (only for multi-day selection)
if (selectedDates.length > 1) {
var tdDate = document.createElement('td');
tdDate.className = 'ps-3';
@ -326,6 +396,7 @@
tr.appendChild(tdDate);
}
// Project
var tdProj = document.createElement('td');
tdProj.className = selectedDates.length === 1 ? 'ps-3' : '';
var strong = document.createElement('strong');
@ -333,43 +404,53 @@
tdProj.appendChild(strong);
tr.appendChild(tdProj);
// Workers — each name gets a small pill badge for readability
var tdWork = document.createElement('td');
entry.workers.forEach(function(name) {
var pill = document.createElement('span');
pill.className = 'badge rounded-pill fw-normal me-1 mb-1';
pill.style.cssText = 'background: var(--bg-inset); color: var(--text-primary); border: 1px solid var(--border-default);';
pill.className = 'badge rounded-pill bg-light text-dark fw-normal border me-1 mb-1';
pill.textContent = name;
tdWork.appendChild(pill);
});
tr.appendChild(tdWork);
// Supervisor
var tdSup = document.createElement('td');
tdSup.textContent = entry.supervisor;
tr.appendChild(tdSup);
// Overtime
var tdOt = document.createElement('td');
if (entry.overtime) {
var otBadge = document.createElement('span');
otBadge.className = 'badge';
otBadge.style.cssText = 'background: var(--color-warning-bg); color: var(--color-warning);';
otBadge.className = 'badge bg-warning text-dark';
otBadge.textContent = entry.overtime;
tdOt.appendChild(otBadge);
} else {
tdOt.textContent = '-';
tdOt.style.color = 'var(--text-tertiary)';
tdOt.className = 'text-muted';
}
tr.appendChild(tdOt);
// Status
var tdStatus = document.createElement('td');
var statusBadge = document.createElement('span');
statusBadge.className = 'badge ' + (entry.is_paid ? 'bg-success' : 'bg-danger bg-opacity-75');
statusBadge.textContent = entry.is_paid ? 'Paid' : 'Unpaid';
if (entry.is_paid) {
statusBadge.className = 'badge bg-success';
statusBadge.textContent = 'Paid';
} else {
statusBadge.className = 'badge bg-danger bg-opacity-75';
statusBadge.textContent = 'Unpaid';
}
tdStatus.appendChild(statusBadge);
tr.appendChild(tdStatus);
// Amount (admin only)
if (isAdmin) {
var tdAmt = document.createElement('td');
tdAmt.textContent = entry.amount !== undefined ? 'R ' + entry.amount.toFixed(2) : '-';
tdAmt.textContent = entry.amount !== undefined
? 'R ' + entry.amount.toFixed(2)
: '-';
tr.appendChild(tdAmt);
}
@ -378,55 +459,137 @@
table.appendChild(tbody);
detailBody.appendChild(table);
// Totals footer
// === Update totals footer (admin only) ===
if (isAdmin && detailFooter) {
var totalDaysEl = document.getElementById('totalDays');
var totalLogsEl = document.getElementById('totalLogs');
var totalWorkersEl = document.getElementById('totalWorkers');
var totalAmountEl = document.getElementById('totalAmount');
var uniqueCount = Object.keys(uniqueWorkers).length;
document.getElementById('totalDays').textContent = selectedDates.length + ' day' + (selectedDates.length !== 1 ? 's' : '');
document.getElementById('totalLogs').textContent = allEntries.length + ' log' + (allEntries.length !== 1 ? 's' : '');
document.getElementById('totalWorkers').textContent = uniqueCount + ' unique worker' + (uniqueCount !== 1 ? 's' : '');
document.getElementById('totalAmount').textContent = 'R ' + totalAmount.toFixed(2);
totalDaysEl.textContent = selectedDates.length + ' day' + (selectedDates.length !== 1 ? 's' : '');
totalLogsEl.textContent = allEntries.length + ' log' + (allEntries.length !== 1 ? 's' : '');
totalWorkersEl.textContent = uniqueCount + ' unique worker' + (uniqueCount !== 1 ? 's' : '');
totalAmountEl.textContent = 'R ' + totalAmount.toFixed(2);
detailFooter.classList.remove('d-none');
}
// Show the panel and scroll to it
detailPanel.classList.remove('d-none');
detailPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// === Click handler for day cells with logs ===
// Toggle selection: click to add, click again to remove
document.querySelectorAll('.cal-day--has-logs').forEach(function(cell) {
cell.addEventListener('click', function() {
var dateStr = this.dataset.date;
if (!(calDetail[dateStr] || []).length) return;
var entries = calDetail[dateStr] || [];
if (entries.length === 0) return;
// Toggle this date in the selection
var idx = selectedDates.indexOf(dateStr);
if (idx !== -1) {
// Already selected — remove it
selectedDates.splice(idx, 1);
this.classList.remove('cal-day--selected');
} else {
// Not selected — add it
selectedDates.push(dateStr);
this.classList.add('cal-day--selected');
}
// Refresh the detail panel with the updated selection
updateDetailPanel();
});
});
// === Clear all selections ===
clearBtn.addEventListener('click', function() {
selectedDates = [];
document.querySelectorAll('.cal-day--selected').forEach(function(c) { c.classList.remove('cal-day--selected'); });
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
c.classList.remove('cal-day--selected');
});
detailPanel.classList.add('d-none');
if (detailFooter) detailFooter.classList.add('d-none');
});
})();
</script>
{# Calendar-specific CSS #}
<style>
/* === CALENDAR GRID STYLES === */
.cal-day {
min-height: 90px;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
transition: background-color 0.15s, box-shadow 0.15s;
}
.cal-day__number {
font-size: 0.85rem;
color: var(--text-main, #334155);
}
/* Days from previous/next month — faded */
.cal-day--other {
background-color: #f8fafc;
opacity: 0.5;
}
/* Today's date — accent border */
.cal-day--today {
border-color: var(--accent-color, #10b981);
border-width: 2px;
}
.cal-day--today .cal-day__number {
color: var(--accent-color, #10b981);
}
/* Days with logs — clickable */
.cal-day--has-logs {
cursor: pointer;
}
.cal-day--has-logs:hover {
background-color: #f0fdfa;
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
}
/* Selected day */
.cal-day--selected {
background-color: #ecfdf5 !important;
border-color: var(--accent-color, #10b981) !important;
border-width: 2px;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
}
/* Mini log entry indicators */
.cal-entry {
line-height: 1.3;
font-size: 0.72rem;
}
/* Mobile: compact cells */
@media (max-width: 767.98px) {
.cal-day {
min-height: 55px;
padding: 4px 5px;
font-size: 0.75rem;
}
.cal-entry {
display: none; /* Hide text indicators on mobile, just show badges */
}
}
</style>
{% else %}
{# =============================================================== #}
{# === LIST VIEW (TABLE) === #}
{# =============================================================== #}
<div class="card">
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Date</th>
<th scope="col">Project</th>
@ -443,23 +606,25 @@
<td class="ps-4 align-middle">{{ log.date }}</td>
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
<td class="align-middle">
{# When filtering by a specific worker, show only that worker. Otherwise show all workers. #}
{% if filtered_worker_obj %}
<span class="badge rounded-pill fw-normal" style="background: var(--bg-inset); color: var(--text-primary); border: 1px solid var(--border-default);">{{ filtered_worker_obj.name }}</span>
<span class="badge rounded-pill bg-light text-dark fw-normal border">{{ filtered_worker_obj.name }}</span>
{% else %}
{% for w in log.workers.all %}
<span class="badge rounded-pill fw-normal me-1 mb-1" style="background: var(--bg-inset); color: var(--text-primary); border: 1px solid var(--border-default);">{{ w.name }}</span>
<span class="badge rounded-pill bg-light text-dark fw-normal border me-1 mb-1">{{ w.name }}</span>
{% endfor %}
<span class="badge rounded-pill" style="background: var(--text-secondary); color: var(--text-on-accent);">{{ log.workers.count }}</span>
<span class="badge rounded-pill bg-secondary">{{ log.workers.count }}</span>
{% endif %}
</td>
<td class="align-middle">
{% if log.overtime_amount > 0 %}
<span class="badge" style="background: var(--color-warning-bg); color: var(--color-warning);">{{ log.get_overtime_amount_display }}</span>
<span class="badge bg-warning text-dark">{{ log.get_overtime_amount_display }}</span>
{% else %}
<span style="color: var(--text-tertiary);">-</span>
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="align-middle">
{# Payment status — a WorkLog is "paid" if it has at least one PayrollRecord #}
{% if log.payroll_records.exists %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Paid</span>
{% else %}
@ -468,10 +633,11 @@
</td>
{% if is_admin %}
<td class="align-middle">
{# Daily cost — worker's rate when filtered, otherwise total for all workers #}
{% if filtered_worker_obj %}
<span class="fw-semibold" style="color: var(--color-success);">R {{ filtered_worker_obj.daily_rate }}</span>
<span class="text-success fw-semibold">R {{ filtered_worker_obj.daily_rate }}</span>
{% else %}
<span class="fw-semibold" style="color: var(--color-success);">R {{ log.display_amount }}</span>
<span class="text-success fw-semibold">R {{ log.display_amount }}</span>
{% endif %}
</td>
{% endif %}
@ -479,14 +645,14 @@
{% if log.supervisor %}
{{ log.supervisor.get_full_name|default:log.supervisor.username }}
{% else %}
<span style="color: var(--text-tertiary);">-</span>
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="{% if is_admin %}7{% else %}6{% endif %}" class="text-center py-5" style="color: var(--text-tertiary);">
<i class="fas fa-inbox fa-2x mb-3 d-block"></i>
<td colspan="{% if is_admin %}7{% else %}6{% endif %}" class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
No work history found.
{% if selected_worker or selected_project or selected_status %}
<br><small>Try adjusting your filters.</small>

View File

@ -1,130 +0,0 @@
{% 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

@ -1,333 +0,0 @@
{% 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

@ -1,490 +0,0 @@
{% 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

@ -1,117 +0,0 @@
{% 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

@ -1,92 +1,32 @@
{% extends "base.html" %}
{% block title %}Login | FoxFitt{% endblock %}
{% block content %}
<!-- === LOGIN PAGE — full-screen centred with premium orange theme === -->
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-body); position: relative; overflow: hidden;">
<div class="container d-flex justify-content-center align-items-center min-vh-100">
<div class="card shadow-sm" style="width: 100%; max-width: 400px; border-radius: 12px; border: none;">
<div class="card-body p-5">
<h2 class="text-center mb-4" style="font-family: 'Poppins', sans-serif; font-weight: 700;">
<span style="color: #10b981;">Fox</span>Fitt
</h2>
<!-- Decorative orange glow -->
<div style="position: absolute; top: -200px; right: -100px; width: 500px; height: 500px; background: radial-gradient(circle, var(--accent-glow) 0%, transparent 65%); pointer-events: none;"></div>
<div style="position: absolute; bottom: -200px; left: -100px; width: 400px; height: 400px; background: radial-gradient(circle, rgba(232, 133, 26, 0.08) 0%, transparent 70%); pointer-events: none;"></div>
<div class="card" style="width: 100%; max-width: 420px; position: relative; z-index: 1;">
<div class="card-body p-4 p-md-5">
<!-- Brand icon + name -->
<div class="text-center mb-4">
<div class="sidebar-brand__icon mx-auto mb-3" style="width: 48px; height: 48px; font-size: 1.25rem;">
<i class="fas fa-bolt"></i>
</div>
<h1 class="mb-1" style="font-size: 2rem;">
<span style="color: var(--accent); font-weight: 700;">Fox</span><span style="font-weight: 700;">Fitt</span>
</h1>
<p class="text-muted mb-0" style="font-size: 0.85rem;">Payroll Management System</p>
</div>
<!-- Error message -->
{% if form.errors %}
<div class="alert alert-danger d-flex align-items-center" role="alert" style="font-size: 0.875rem;">
<i class="fas fa-exclamation-circle me-2"></i>
<div class="alert alert-danger" role="alert">
Your username and password didn't match. Please try again.
</div>
{% endif %}
<!-- Login form -->
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user" style="width: 1rem; text-align: center;"></i></span>
<input type="text" name="username" class="form-control form-control-lg" id="id_username" placeholder="Enter username" required autofocus>
</div>
<label for="id_username" class="form-label" style="font-family: 'Inter', sans-serif; font-weight: 500;">Username</label>
<input type="text" name="username" class="form-control form-control-lg" id="id_username" required autofocus style="border-radius: 8px;">
</div>
<div class="mb-4">
<label for="id_password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock" style="width: 1rem; text-align: center;"></i></span>
<input type="password" name="password" class="form-control form-control-lg" id="id_password" placeholder="Enter password" required>
</div>
<label for="id_password" class="form-label" style="font-family: 'Inter', sans-serif; font-weight: 500;">Password</label>
<input type="password" name="password" class="form-control form-control-lg" id="id_password" required style="border-radius: 8px;">
</div>
<button type="submit" class="btn btn-accent btn-lg w-100">
<i class="fas fa-sign-in-alt me-2"></i>Login
</button>
<button type="submit" class="btn btn-lg w-100 text-white" style="background-color: #10b981; border: none; border-radius: 8px; font-weight: 600;">Login</button>
</form>
<!-- Theme toggle (since sidebar isn't visible on login page) -->
<div class="text-center mt-4 pt-3" style="border-top: 1px solid var(--border-subtle);">
<button type="button" class="theme-toggle" id="loginThemeToggle" style="border-color: var(--border-default); color: var(--text-secondary); margin: 0 auto;">
<i class="fas fa-moon" id="loginThemeIcon"></i>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Theme toggle for login page (sidebar toggle is hidden when not authenticated) -->
<script>
(function() {
var toggle = document.getElementById('loginThemeToggle');
var icon = document.getElementById('loginThemeIcon');
if (!toggle || !icon) return;
function updateLoginToggle() {
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
}
updateLoginToggle();
toggle.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);
updateLoginToggle();
});
})();
</script>
{% endblock %}

View File

@ -1,27 +0,0 @@
# === 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

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

View File

@ -1,94 +1,26 @@
# === PDF GENERATION ===
# Converts a Django HTML template into a PDF file using WeasyPrint.
# Used for payslip, receipt, and payroll report PDFs (both email and
# browser download).
# Converts a Django HTML template into a PDF file using xhtml2pdf.
# Used for payslip and receipt PDF attachments sent via email.
#
# 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.
# 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.
import logging
import os
import sys
from django.conf import settings
from io import BytesIO
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 using WeasyPrint.
Render a Django template to PDF bytes.
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.
@ -96,37 +28,25 @@ def render_to_pdf(template_src, context_dict=None):
if context_dict is None:
context_dict = {}
# 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.
# --- Lazy import: only load xhtml2pdf when actually generating a PDF ---
# This prevents the entire app from crashing if xhtml2pdf isn't installed.
try:
from weasyprint import HTML
except (ImportError, OSError) as e:
from xhtml2pdf import pisa
except ImportError:
logger.error(
"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,
"xhtml2pdf is not installed — cannot generate PDF. "
"Install it with: pip install xhtml2pdf"
)
return None
# Render the Django template to HTML first
# Load and render the HTML template
template = get_template(template_src)
html = template.render(context_dict)
# 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
# Convert HTML to PDF
result = BytesIO()
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
if not pdf.err:
return result.getvalue()
return None

File diff suppressed because it is too large Load Diff

View File

@ -1,294 +0,0 @@
# 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

@ -1,663 +0,0 @@
# 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

@ -1,274 +0,0 @@
# 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
weasyprint==68.1
xhtml2pdf==0.2.16

View File

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

File diff suppressed because it is too large Load Diff