WIP: 2026-04-22 session checkpoint
Complete working state of the session. Will be split into two deploy phases (safety scaffolding then feature release) before merging to ai-dev. Includes: - Security fixes (email creds / SECRET_KEY / DEBUG / CSRF) - Backup + restore management commands and browser endpoints - WeasyPrint migration (replaces xhtml2pdf) - New Worker fields + WorkerCertificate + WorkerWarning models - Worker / Team / Project friendly management UIs - Dashboard cert-expiry card + Manage All buttons - Bootstrap tooltips (global init + theme-aware CSS) - Django admin template override (taller M2M pickers) - Money filter for ZAR currency formatting - Resources dropdown nav - Massive CLAUDE.md expansion + deploy plan docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
deef851e52
commit
3c28387dd3
17
.gitignore
vendored
17
.gitignore
vendored
@ -6,8 +6,23 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
.DS_Store
|
||||
media/
|
||||
.venv/
|
||||
.venv/
|
||||
|
||||
# Claude Code / IDE
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Dev artifacts — test PDFs, backup files, accidental shell artifacts
|
||||
test_*.pdf
|
||||
test_*.json
|
||||
nul
|
||||
|
||||
# Local backup downloads — these should never be in git
|
||||
backups/
|
||||
|
||||
375
CLAUDE.md
375
CLAUDE.md
@ -14,7 +14,7 @@ This is v5 — a fresh export from Flatlogic/AppWizzy, rebuilt from the v2 codeb
|
||||
## Tech Stack
|
||||
- Django 5.2.7, Python 3.13, MySQL (production on Flatlogic Cloud Run) / SQLite (local dev)
|
||||
- Bootstrap 5.3.3 (CDN), Font Awesome 6.5.1 (CDN), Google Fonts (Inter + Poppins)
|
||||
- xhtml2pdf for PDF generation (payslips, receipts)
|
||||
- WeasyPrint for PDF generation (payroll report, payslips, receipts) — migrated from xhtml2pdf; browser-grade HTML/CSS rendering with flexbox, grid, @font-face, shadows, and proper CSS cascade
|
||||
- Gmail SMTP for automated document delivery
|
||||
- Hosted on Flatlogic/AppWizzy platform (Apache on Debian, e2-micro VM)
|
||||
|
||||
@ -25,12 +25,24 @@ core/ — Single main app: ALL business logic, models, views, forms,
|
||||
context_processors.py — Injects deployment_timestamp (cache-busting), Flatlogic branding vars
|
||||
forms.py — AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm + formset
|
||||
models.py — All 10 database models
|
||||
utils.py — render_to_pdf() helper (lazy xhtml2pdf import)
|
||||
views.py — All 28 functions (~2635 lines, includes helpers)
|
||||
utils.py — render_to_pdf() helper (lazy WeasyPrint import + Windows GTK3 DLL registration)
|
||||
views.py — All view functions (~52 functions, ~3,800 lines) — dashboard, attendance, payroll, reports, worker/team/project CRUD
|
||||
forms.py — All form classes + validators (WorkerForm, TeamForm, ProjectForm, AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, WorkerCertificate/WarningFormSet, 5MB file validator)
|
||||
admin.py — Django admin registrations for all core models + WorkerCertificate/Warning inlines on Worker
|
||||
templatetags/ — format_tags.py (money filter for ZAR formatting)
|
||||
management/commands/ — setup_groups, setup_test_data, import_production_data
|
||||
templates/ — base.html + 7 page templates + 2 email + 2 PDF + login
|
||||
templates/
|
||||
base.html — App shell (topbar + mobile menu + bottom tab bar)
|
||||
core/ — Page templates: index, attendance_log, work_history, payroll_dashboard,
|
||||
report, create_receipt, payslip, login, _report_config_modal (partial)
|
||||
core/workers/ — 4 templates: list, detail, edit, batch_report
|
||||
core/teams/ — 4 templates: list, detail, edit, batch_report
|
||||
core/projects/— 4 templates: list, detail, edit, batch_report
|
||||
core/pdf/ — 4 PDF templates: report_pdf, payslip_pdf, receipt_pdf, workers_report_pdf
|
||||
core/email/ — 2 HTML email templates
|
||||
admin/ — base_site.html override (adds admin CSS tweaks, e.g. taller M2M pickers)
|
||||
ai/ — Flatlogic AI proxy client (not used in app logic)
|
||||
static/css/ — custom.css (CSS variables, component styles)
|
||||
static/css/ — custom.css (CSS variables, component styles, tooltip overrides)
|
||||
staticfiles/ — Collected static assets (Bootstrap, admin)
|
||||
```
|
||||
|
||||
@ -44,6 +56,8 @@ staticfiles/ — Collected static assets (Bootstrap, admin)
|
||||
- **PayrollAdjustment** — bonuses, deductions, overtime, loans; linked to project (FK, optional), worker, and optionally to a Loan or WorkLog
|
||||
- **Loan** — worker loans AND advances with principal, remaining_balance, `loan_type` ('loan' or 'advance')
|
||||
- **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling
|
||||
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
|
||||
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
|
||||
|
||||
## Key Business Rules
|
||||
- All business logic lives in the `core/` app — do not create additional Django apps
|
||||
@ -109,6 +123,10 @@ python manage.py check # System check
|
||||
- Pending Payments Table: Shows overdue badges (red) for workers with unpaid work from completed pay periods, and loan badges (yellow) for workers with active loans/advances. Filter bar has: team dropdown, "Overdue only" checkbox, and loan dropdown (All Workers / With loans only / Without loans). Overdue detection uses `get_pay_period()` cutoff logic.
|
||||
- Quick Adjust Button: Each pending payments row has an "Adjust" button (slider icon) that opens the Add Adjustment modal with that worker pre-checked and their most recent project pre-selected. The header "Add Adjustment" button resets the modal to a clean state. Uses `_quickAdjustOpen` flag to distinguish between the two open paths.
|
||||
- Worker Lookup Modal: Clicking any worker name on the payroll dashboard (or using the "Worker Lookup" button) opens a modal with a comprehensive report card — amount payable, outstanding loans, paid this month/year, loans this year, recent activity (last payslip, loan, repayment, advance), active loans table, current project + days on project, PPE sizing, drivers license, and notes. Uses `worker_lookup_ajax` AJAX endpoint. Worker dropdown in modal allows switching workers without closing.
|
||||
- Team & Project Management UIs: Friendlier alternatives to `/admin/core/team/` and `/admin/core/project/`. Reachable via the "Resources" dropdown in the topbar (admin only). **Team pages**: `/teams/` (list + search/filter), `/teams/<id>/` (detail with Profile/Pay Schedule/Workers/History tabs — Pay Schedule tab uses the existing `get_pay_period()` helper to show current + next 2 periods), `/teams/<id>/edit/` (single-page form for name, supervisor, pay schedule, and workers M2M). **Project pages**: `/projects/`, `/projects/<id>/` (tabs: Profile/Supervisors/Teams/Workers/History), `/projects/<id>/edit/` (form for name, description, dates, supervisors M2M). Uses `TeamForm` and `ProjectForm` from `core/forms.py` (both simple ModelForms, no inline formsets). Batch reports at `/teams/report/` and `/projects/report/` with CSV exports; PDF exports deferred as a follow-up. Dashboard "Manage Resources" card now has "Manage All Workers/Projects/Teams" footer links on each tab. Django admin remains fully functional as a fallback.
|
||||
- Worker Management UI: A friendlier alternative to `/admin/core/worker/`. Reachable via the "Resources" topbar dropdown → Workers (admin-only). Pages: `/workers/` (list with search + status filter), `/workers/<id>/` (detail with Profile/Certifications/Warnings/History tabs), `/workers/<id>/edit/` or `/workers/new/` (single-page form with sections for Personal & Pay, PPE, Documents, Driver's License, plus inline formsets for certifications and warnings). Uses `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` from `core/forms.py`. The "+ Add Certification" / "+ Add Warning" buttons clone a `<template>` element via `content.cloneNode()` (DOM-safe, no innerHTML) and rewrite `__PREFIX__` in input names to the next formset index. File uploads validated at 5 MB max via `validate_max_5mb()` in `forms.py`. Django admin (`/admin/core/worker/`) remains fully functional as a fallback — both UIs coexist.
|
||||
- Worker Batch Report: `/workers/report/` shows every worker with aggregated lifetime history — days worked, projects worked on, teams, first/last payslip dates, total paid, cert status (active/total + expired/expiring counts), warning count. Filter by status, project, team. CSV export via `/workers/report/csv/`, PDF via `/workers/report/pdf/` (landscape A4, same amber-accent typography as the payroll report). Built on the reusable `_build_worker_report_context()` helper which uses `annotate(Min/Max/Count/Sum)` + prefetch for efficient aggregation.
|
||||
- Dashboard cert-expiry card: The admin dashboard shows a "Certifications Need Attention" stat card with count of expired + expiring-within-30-days certs (active workers only). Card is CONDITIONAL — renders only when count > 0, so it disappears when everything is in good standing. Clicking it goes to the worker batch report. Counts come from `index()` view adding `certs_expired_count`, `certs_expiring_count`, `certs_alert_total` to context.
|
||||
|
||||
## URL Routes
|
||||
| Path | View | Purpose |
|
||||
@ -118,6 +136,25 @@ python manage.py check # System check
|
||||
| `/history/` | `work_history` | Work logs table with filters |
|
||||
| `/history/export/` | `export_work_log_csv` | Download filtered logs as CSV |
|
||||
| `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV |
|
||||
| `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter |
|
||||
| `/workers/new/` | `worker_edit` | Admin: blank worker-create form |
|
||||
| `/workers/<id>/` | `worker_detail` | Admin: worker profile with profile/certs/warnings/history tabs |
|
||||
| `/workers/<id>/edit/` | `worker_edit` | Admin: edit worker + inline cert/warning formsets |
|
||||
| `/workers/report/` | `worker_batch_report` | Admin: aggregated roster report (days, projects, payslips, certs) |
|
||||
| `/workers/report/csv/` | `worker_batch_report_csv` | Admin: batch report as CSV download |
|
||||
| `/workers/report/pdf/` | `worker_batch_report_pdf` | Admin: batch report as PDF download |
|
||||
| `/teams/` | `team_list` | Admin: friendly team list with search + status filter |
|
||||
| `/teams/new/` | `team_edit` | Admin: blank team-create form |
|
||||
| `/teams/<id>/` | `team_detail` | Admin: team profile with profile/pay schedule/workers/history tabs |
|
||||
| `/teams/<id>/edit/` | `team_edit` | Admin: edit team (name, supervisor, pay schedule, workers M2M) |
|
||||
| `/teams/report/` | `team_batch_report` | Admin: aggregated team report (HTML) |
|
||||
| `/teams/report/csv/` | `team_batch_report_csv` | Admin: team batch report as CSV download |
|
||||
| `/projects/` | `project_list` | Admin: friendly project list with search + status filter |
|
||||
| `/projects/new/` | `project_edit` | Admin: blank project-create form |
|
||||
| `/projects/<id>/` | `project_detail` | Admin: project profile with profile/supervisors/teams/workers/history tabs |
|
||||
| `/projects/<id>/edit/` | `project_edit` | Admin: edit project (name, description, dates, supervisors M2M) |
|
||||
| `/projects/report/` | `project_batch_report` | Admin: aggregated project report (HTML) |
|
||||
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: project batch report as CSV download |
|
||||
| `/toggle/<model>/<id>/` | `toggle_active` | Admin: AJAX toggle active status |
|
||||
| `/payroll/` | `payroll_dashboard` | Admin: pending payments, loans, charts |
|
||||
| `/payroll/pay/<worker_id>/` | `process_payment` | Admin: process payment (atomic) |
|
||||
@ -136,27 +173,333 @@ python manage.py check # System check
|
||||
| `/run-migrate/` | `run_migrate` | Setup: run pending DB migrations from browser |
|
||||
|
||||
## Frontend Design Conventions
|
||||
- **Dual-theme** (dark + light) driven by a single CSS variable set in `static/css/custom.css`.
|
||||
The theme is dark-first; the light theme is a set of var overrides inside a `:root.light` block.
|
||||
A sun/moon toggle in the topbar flips a class on `<html>` and persists the choice to localStorage.
|
||||
- **CSS variables** in `static/css/custom.css` `:root` — always use `var(--name)`:
|
||||
- `--primary-dark: #0f172a` (navbar), `--primary: #1e293b` (headers), `--accent: #10b981` (brand green)
|
||||
- `--text-main: #334155`, `--text-secondary: #64748b`, `--background: #f1f5f9`
|
||||
- `--accent: #e8851a` (warm orange/amber, brand), `--accent-hover: #f59e0b`
|
||||
- `--primary-dark: #0f172a`, `--primary: #1e293b`
|
||||
- `--bg-card: #161921`, `--bg-card-hover: #1c2029` (elevated surfaces)
|
||||
- `--text-primary: #d8d8d8` (dark theme), `--text-secondary`, `--text-tertiary`
|
||||
- Light-theme overrides flip backgrounds to white/grey and accent to `#d97706`
|
||||
- **Icons**: Font Awesome 6 only (`fas fa-*`). Do NOT use Bootstrap Icons (`bi bi-*`)
|
||||
- **CTA buttons**: `btn-accent` (green) for primary actions. `btn-primary` (dark slate) for modal Save/Submit
|
||||
- **Page titles**: `{% block title %}Page Name | Fox Fitt{% endblock %}`
|
||||
- **CTA buttons**: `btn-accent` (orange) for primary actions. `btn-primary` (dark slate) for modal Save/Submit
|
||||
- **Page titles**: `{% block title %}Page Name | FoxFitt{% endblock %}`
|
||||
- **Fonts**: Inter (body) + Poppins (headings) loaded in base.html via Google Fonts CDN
|
||||
- **Cards**: Borderless with `box-shadow: 0 4px 6px rgba(0,0,0,0.1)`. Stat cards use `backdrop-filter: blur`
|
||||
- **Cards**: Borderless with subtle shadow. Stat cards have coloured accent bars on the left.
|
||||
- **Bootstrap tooltips**: Global init in `base.html` — any element with
|
||||
`data-bs-toggle="tooltip" title="..."` gets a tooltip automatically. Tooltips are themed
|
||||
via custom `--bs-tooltip-bg`/`--bs-tooltip-color` overrides in custom.css so they're
|
||||
readable in both light and dark modes (otherwise Bootstrap's default picks the wrong
|
||||
pair of body vars for dark mode).
|
||||
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
|
||||
|
||||
## Permission Groups
|
||||
Created by `setup_groups` management command:
|
||||
- **Admin** — full CRUD on all core models
|
||||
- **Work Logger** — add/change/view WorkLog; view-only on Project/Worker/Team
|
||||
## PDF Generation (WeasyPrint)
|
||||
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, `@font-face`, shadows, `border-radius`, proper cascade) that xhtml2pdf could not handle.
|
||||
|
||||
### Files
|
||||
- `core/utils.py` — `render_to_pdf(template_src, context_dict)` is the single entry point; lazy-imports WeasyPrint, returns PDF bytes or `None` on failure
|
||||
- `core/templates/core/pdf/report_pdf.html` — payroll report (complex layout, 600+ lines)
|
||||
- `core/templates/core/pdf/payslip_pdf.html` — payslip (emailed to Spark Receipt after each payment)
|
||||
- `core/templates/core/pdf/receipt_pdf.html` — expense receipt (emailed to Spark Receipt after each expense entry)
|
||||
- `core/templatetags/format_tags.py::money` — South African space-separated currency formatting (`R 64 939.00`); use this instead of `floatformat:2`
|
||||
|
||||
### Callers
|
||||
- `generate_report_pdf()` — downloads the report PDF to the browser
|
||||
- `_send_payslip_email()` — attaches payslip PDF to Gmail SMTP email (called by `process_payment`, `add_adjustment` advance path, `batch_pay`)
|
||||
- `create_receipt()` — attaches receipt PDF to Gmail SMTP email
|
||||
- All three use the same `EmailMultiAlternatives.attach(filename, pdf_bytes, "application/pdf")` pattern — engine-agnostic
|
||||
|
||||
### Dependencies
|
||||
- **Python package**: `weasyprint==68.1` (pinned in `requirements.txt`)
|
||||
- **System libraries** (Pango, Cairo, GDK-PixBuf, FFI, shared-mime-info):
|
||||
- **Flatlogic/production (Debian)**: already installed on the platform image — confirmed via Flatlogic's Gemini
|
||||
- **Windows local dev**: install the GTK3 runtime via `winget install -e --id tschoonj.GTKForWindows --accept-package-agreements --accept-source-agreements --silent` (installs `C:\Program Files\GTK3-Runtime Win64\`)
|
||||
- **macOS local dev**: `brew install pango` (not currently used but documented for completeness)
|
||||
|
||||
### Windows DLL resolution quirk
|
||||
Since Python 3.8, native DLLs are *not* loaded from PATH automatically — an explicit `os.add_dll_directory()` call is required. The `_ensure_gtk_on_windows()` helper in `core/utils.py` handles this automatically: it checks common GTK3 install paths on module load and registers the first one found. No-op on Linux/macOS.
|
||||
|
||||
If the report page returns "PDF generation failed", check the Django log for the underlying error:
|
||||
- `cannot load library 'gobject-2.0-0'` → GTK3 runtime not installed (run the winget command above)
|
||||
- `'super' object has no attribute 'transform'` → `weasyprint`/`pydyf` version mismatch; reinstall with `pip install --upgrade weasyprint==68.1`
|
||||
|
||||
### Template conventions
|
||||
- **Modern CSS is fine** — flexbox (`display: flex`), grid (`display: grid; gap: 20pt`), `@font-face`, `box-shadow`, `border-radius` all render correctly
|
||||
- **Fonts**: WeasyPrint can load web fonts. If we ever add `@font-face` blocks pointing to `static/fonts/Inter-*.ttf` and `Poppins-*.ttf`, the PDFs can use the same typography as the web app (currently the PDFs use Helvetica by default — upgrading to Inter/Poppins is optional follow-up work)
|
||||
- **Page setup**: `@page { size: a4 portrait; margin: 2cm 1.8cm 1.6cm 1.8cm; }` — standard A4 with generous margins
|
||||
- **`base_url`**: `render_to_pdf()` passes `settings.STATIC_ROOT or "."` as `base_url` so relative paths in `<img src="...">` and `@font-face src: url(...)` resolve against the collected static dir
|
||||
|
||||
### Known lint footguns (legacy from xhtml2pdf era)
|
||||
- `report_pdf.html` still uses invisible `<table class="cols">` elements for two-column layout — these work fine under WeasyPrint but could be simplified to `display: grid; grid-template-columns: 1fr 1fr; gap: 20pt` as a future cleanup pass
|
||||
- The `period-detail td { padding-top: 3pt; padding-bottom: 3pt; }` split-padding workaround (from a shorthand-collision bug with xhtml2pdf) is no longer needed; safe to replace with the `padding` shorthand when cleaning up the template
|
||||
|
||||
## Users, Roles & Permissions
|
||||
|
||||
Understanding who-can-do-what in this app requires grasping **three separate layers**
|
||||
of Django auth that stack on top of each other:
|
||||
|
||||
### Layer 1 — Django's three built-in user flags
|
||||
|
||||
These live on the `auth.User` model and are the foundation. Every user has exactly one
|
||||
combination of these three flags:
|
||||
|
||||
| Flag | What it means | Who should have it |
|
||||
|---|---|---|
|
||||
| `is_superuser=True` | **Bypasses every permission check.** Full access to everything everywhere, including Django admin. Created by `createsuperuser`. | Konrad (the owner), and one emergency-access account. That's it. |
|
||||
| `is_staff=True` | Can log into `/admin/` (the built-in Django admin interface) and sees it. Does NOT grant any model permissions by itself — those come from groups or per-user permissions. | Konrad, and any "office admin" people who need full access to edit data via Django admin. Usually combined with `is_superuser` in this app. |
|
||||
| (neither) | Regular user. Can log into the friendly app at `/` but cannot enter Django admin. Sees only what they've been explicitly given access to via group membership or supervisor assignments. | Site supervisors (Work Loggers), and any future non-admin roles. |
|
||||
|
||||
Key mental model: **`is_superuser` beats everything**. A superuser's permission groups
|
||||
and assignments don't matter — they always see everything. Use superuser sparingly
|
||||
so that regular permission paths get exercised and tested.
|
||||
|
||||
### Layer 2 — The two app-specific permission groups
|
||||
|
||||
Created by `python manage.py setup_groups` (a one-time command, safe to re-run; it
|
||||
updates existing groups rather than duplicating them). Assignment happens in
|
||||
`/admin/auth/group/` → add users to groups:
|
||||
|
||||
**Admin group** — grants every Django model permission (add/change/delete/view) on
|
||||
all 10 core models (Worker, Project, Team, WorkLog, PayrollRecord, Loan,
|
||||
PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, and the new WorkerCertificate/
|
||||
WorkerWarning via separate admin registration).
|
||||
|
||||
- **Practical effect**: only matters for `is_staff=True` users who are NOT
|
||||
superusers. For them, the Admin group is what lets them actually use Django
|
||||
admin (without it, they can log into /admin/ but see empty lists).
|
||||
- For superusers, the Admin group is redundant (they bypass permissions anyway).
|
||||
- For non-staff users, the Admin group is pointless (they can't reach Django
|
||||
admin at all).
|
||||
|
||||
**Work Logger group** — grants: add/change/view `WorkLog`; view-only on
|
||||
`Project`, `Worker`, `Team`. Notably does NOT grant any Payroll permissions.
|
||||
|
||||
- **Practical effect**: this group is the app's signal for "this user is a site
|
||||
supervisor". The `is_supervisor()` helper in `views.py` explicitly checks for
|
||||
membership in this group — so adding someone here marks them as a supervisor,
|
||||
even if they don't own any teams or projects yet.
|
||||
- Work Loggers typically have `is_staff=False` (no Django admin access). They
|
||||
use the friendly app UI at `/attendance/log/`, `/history/`, and the dashboard.
|
||||
|
||||
### Layer 3 — Implicit supervisor roles via model relationships
|
||||
|
||||
Two model fields independently grant "supervisor-ness" even without group membership:
|
||||
|
||||
- `Team.supervisor` (ForeignKey → User) — whoever this points to is a supervisor
|
||||
of that team. Set on the Team edit page or `/admin/core/team/`.
|
||||
- `Project.supervisors` (ManyToManyField → User) — every user in this M2M is a
|
||||
supervisor of the project. Set on the Project edit page or
|
||||
`/admin/core/project/`.
|
||||
|
||||
The `is_supervisor()` helper treats any ONE of these as sufficient:
|
||||
|
||||
```python
|
||||
def is_supervisor(user):
|
||||
return (
|
||||
user.supervised_teams.exists() # Team.supervisor FK reverse
|
||||
or user.assigned_projects.exists() # Project.supervisors M2M reverse
|
||||
or user.groups.filter(name='Work Logger').exists()
|
||||
)
|
||||
```
|
||||
|
||||
So a user can become a supervisor via **any** of: Work Logger group, assigned to
|
||||
a Team as supervisor, or added to a Project's supervisors M2M.
|
||||
|
||||
### The three permission-check helpers in `core/views.py`
|
||||
|
||||
All three are defined near the top of `views.py` (around line 47–67):
|
||||
|
||||
| Helper | Returns True if… | Used by |
|
||||
|---|---|---|
|
||||
| `is_admin(user)` | `is_staff=True` OR `is_superuser=True` | Every admin-only view (payroll, reports, worker/team/project management, CSV exports) |
|
||||
| `is_supervisor(user)` | Supervises a team OR has assigned projects OR is in Work Logger group | Attendance logging, history page filtering |
|
||||
| `is_staff_or_supervisor(user)` | `is_admin` OR `is_supervisor` | Views accessible to both tiers (dashboard shows different content per tier) |
|
||||
|
||||
**Critical**: `is_admin()` does NOT check for the "Admin" group. It checks the
|
||||
Django `is_staff`/`is_superuser` flags. A user can be in the "Admin" permission
|
||||
group but NOT be an admin as far as the app is concerned, and vice versa. The
|
||||
group controls Django-admin model permissions; the flags control everything else.
|
||||
|
||||
### How views enforce permissions
|
||||
|
||||
1. **`@login_required`** is on every view except `import_data()` and
|
||||
`run_migrate()` (temporary setup endpoints).
|
||||
2. **Admin-only views** call `is_admin(request.user)` at the top and return
|
||||
`HttpResponseForbidden("Admin access required.")` if false. Examples:
|
||||
everything under `/payroll/`, `/workers/*`, `/teams/*`, `/projects/*`,
|
||||
`/report/*`, `/workers/export/`.
|
||||
3. **Supervisor-scoped data** uses `is_supervisor()` to gate access, then filters
|
||||
querysets by the user's `supervised_teams` / `assigned_projects`:
|
||||
- `work_history` — supervisors see only logs for their teams/projects
|
||||
- `AttendanceLogForm` — pre-filters `project` and `team` dropdowns by what
|
||||
the user can see; `workers` field is filtered by team membership
|
||||
4. **Permission cascading** — a supervisor of a Team automatically "supervises"
|
||||
every worker in that team, and every project that team has worked on.
|
||||
This is implicit — there's no per-worker permission.
|
||||
|
||||
### The "Resources" dropdown supervisor picker
|
||||
|
||||
When editing a Team or Project via the friendly UI (`/teams/<id>/edit/` or
|
||||
`/projects/<id>/edit/`), the Supervisor/Supervisors picker uses
|
||||
`_supervisor_user_queryset()` in `core/forms.py`:
|
||||
|
||||
```python
|
||||
User.objects.filter(is_active=True).filter(
|
||||
Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger')
|
||||
).distinct()
|
||||
```
|
||||
|
||||
So anyone who's either an admin OR a Work Logger shows up as an eligible
|
||||
supervisor. Deactivated accounts (`is_active=False`) are hidden.
|
||||
|
||||
### Typical user setups
|
||||
|
||||
| User | is_superuser | is_staff | Groups | Supervised Teams/Projects | What they can do |
|
||||
|---|---|---|---|---|---|
|
||||
| **admin** (Konrad) | ✓ | ✓ | — | — | Everything. Bypasses all checks. |
|
||||
| **testadmin** (Flatlogic) | ✓ | ✓ | — | — | Same as above. |
|
||||
| **eendman** | — | — | Work Logger | *(usually also supervises a team)* | Log work for assigned teams/projects, see their history. Cannot enter Django admin. Cannot view payroll or worker salary data via app UI. |
|
||||
| **Office data-entry staff** (future) | — | ✓ | Admin | — | Can enter Django admin and CRUD all core models there. Does NOT see the payroll dashboard or worker salary UI (because `is_admin()` helper still returns True via `is_staff`, so actually yes they CAN see payroll — see note below). |
|
||||
| **Inactive terminated employee** | — | — | — | — | Cannot log in if `is_active=False`. |
|
||||
|
||||
**Note on is_staff + payroll access**: `is_admin()` returns True for any
|
||||
`is_staff` user. That means if you create an office-admin user with
|
||||
`is_staff=True` but no superuser flag, they WILL see payroll and salary data
|
||||
via the friendly UI. If you need a "Django admin only, no payroll UI" role,
|
||||
we'd have to add a separate flag or group check — not currently supported.
|
||||
|
||||
### How to add a new supervisor (step by step)
|
||||
|
||||
1. Go to `/admin/auth/user/add/` and create the user with a username and
|
||||
password. **Uncheck "Staff status"** on the initial form (they don't need
|
||||
Django admin access).
|
||||
2. On the user's change page, add them to the **Work Logger** group.
|
||||
3. (Optional) Assign them as the supervisor of one or more teams via
|
||||
`/teams/<id>/edit/` (Supervisor dropdown — they'll appear in the list
|
||||
because of their Work Logger group membership).
|
||||
4. (Optional) Add them to one or more projects via `/projects/<id>/edit/`
|
||||
(Supervisors M2M checklist).
|
||||
5. They can now log in at `/accounts/login/` and will land on the Dashboard
|
||||
with a supervisor view — their teams + projects only.
|
||||
|
||||
### Common misconceptions (read these)
|
||||
|
||||
- **"Admin" group ≠ Django admin access.** Django admin access requires
|
||||
`is_staff=True`. The "Admin" group is just a permission bundle.
|
||||
- **Work Logger doesn't need to supervise a team to be "a supervisor".**
|
||||
Group membership alone satisfies `is_supervisor()`.
|
||||
- **Superuser bypasses the "Admin" group.** They don't need it.
|
||||
- **Deactivating a user** (`is_active=False`) blocks login entirely; their
|
||||
team/project assignments remain in the DB for audit purposes but stop
|
||||
having any effect.
|
||||
- **The `supervisor` field on WorkLog is historical, not authoritative.** It
|
||||
records who logged the work that day — not who currently supervises the
|
||||
workers.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Django's built-in auth (`django.contrib.auth`)
|
||||
- Login: `/accounts/login/` → redirects to `/` (home)
|
||||
- Logout: POST to `/accounts/logout/` → redirects to login
|
||||
- All views use `@login_required` except `import_data()`
|
||||
- All views use `@login_required` except `import_data()` and `run_migrate()`
|
||||
- No PIN auth in v5 (simplified from v2)
|
||||
- Passwords: Django's default PBKDF2 hashing, no custom password policy
|
||||
- Sessions: cookie-based, server-side session store (default Django)
|
||||
|
||||
## Django Admin Customisation
|
||||
|
||||
The `/admin/` interface is Django's built-in admin with two targeted customisations:
|
||||
|
||||
### Model registrations (`core/admin.py`)
|
||||
- Every core model is registered with `list_display`, `list_filter`, `search_fields`, and
|
||||
(where relevant) `filter_horizontal` / `filter_vertical` for M2M pickers.
|
||||
- `WorkerAdmin` has `WorkerCertificateInline` + `WorkerWarningInline` so you can edit a
|
||||
worker's certs and warnings inline on the worker change page.
|
||||
- `WorkerCertificateAdmin` + `WorkerWarningAdmin` are also standalone (useful for
|
||||
"show me all expiring certs across all workers" type queries via list_filter).
|
||||
|
||||
### Template override — `core/templates/admin/base_site.html`
|
||||
Extends `admin/base.html` and injects a small `<style>` block into every admin page.
|
||||
Currently used for:
|
||||
- Taller `FilteredSelectMultiple` widgets (the "Choose" / "Available" boxes used by
|
||||
`filter_horizontal` on Groups, WorkLogs etc.) — default Django height is ~16em which
|
||||
is too short for long permission lists; we set 30em (40em on tall screens).
|
||||
|
||||
Add more admin-only CSS tweaks inside that `<style>` block rather than polluting
|
||||
`static/css/custom.css`.
|
||||
|
||||
### Why the override works — `TEMPLATES.DIRS` setting
|
||||
Django's template loader tries `TEMPLATES[0].DIRS` before the app-dirs loader. Since
|
||||
`django.contrib.admin` comes before `core` in `INSTALLED_APPS` (the standard order),
|
||||
its `admin/base_site.html` would normally win. Adding `BASE_DIR / 'core' / 'templates'`
|
||||
to `TEMPLATES[0]['DIRS']` in `config/settings.py` makes our override take priority
|
||||
without reordering `INSTALLED_APPS` (which would risk subtle side effects on signals,
|
||||
migrations, and admin URL registration).
|
||||
|
||||
## Backup & Restore (production safety net)
|
||||
|
||||
Flatlogic doesn't expose MySQL directly (no SSH, no `mysqldump`, no DB console).
|
||||
Instead, the app ships two management commands + two admin-only browser URLs
|
||||
that back up and restore every row via Django's ORM — platform-independent,
|
||||
works anywhere Django does.
|
||||
|
||||
### Making a backup (before any risky deploy)
|
||||
|
||||
**From the browser (production)**:
|
||||
1. Log in as admin
|
||||
2. Visit `/backup-data/`
|
||||
3. Browser downloads `foxlog_backup_<timestamp>.json` to your laptop
|
||||
4. Move that file somewhere safe (Google Drive, local disk — NOT the repo)
|
||||
|
||||
**From the command line (local dev or SSH-able host)**:
|
||||
```
|
||||
python manage.py backup_data # → backups/foxlog_<timestamp>.json
|
||||
python manage.py backup_data --output=custom.json # → custom.json
|
||||
```
|
||||
|
||||
Either method produces an identical JSON file covering:
|
||||
- All auth tables: User, Group, Permission, ContentType (so accounts restore correctly)
|
||||
- UserProfile, Project, Worker, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning
|
||||
|
||||
File size is roughly 1 KB per row (14 workers / 100 work logs → ~90 KB).
|
||||
|
||||
### Restoring a backup
|
||||
|
||||
**From the browser**:
|
||||
1. Log in as admin
|
||||
2. Visit `/restore-data/`
|
||||
3. Upload your `.json` file
|
||||
4. Tick "Yes, I understand" checkbox
|
||||
5. Click Restore — runs inside a database transaction, all-or-nothing
|
||||
|
||||
**Behaviour**:
|
||||
- Rows with matching primary key are **UPDATED** (no duplicates)
|
||||
- Rows with primary keys not yet in the DB are **INSERTED**
|
||||
- Rows in the DB but NOT in the backup are **KEPT** (restore doesn't delete)
|
||||
- If any row fails to load, the whole restore is rolled back (no partial state)
|
||||
|
||||
**For a clean restore** (wipe everything first, then load the backup):
|
||||
```
|
||||
python manage.py flush # irreversible — deletes ALL data
|
||||
python manage.py restore_data backup.json
|
||||
```
|
||||
|
||||
### Recommended workflow before any deploy
|
||||
|
||||
1. `/backup-data/` on production before pushing the change
|
||||
2. Push the change to `ai-dev`, let Flatlogic rebuild
|
||||
3. Verify the new version works
|
||||
4. If broken: restore from the backup you just took via `/restore-data/`
|
||||
5. Delete the backup file from your laptop once you're confident the deploy is stable
|
||||
|
||||
### Files involved
|
||||
- `core/management/commands/backup_data.py` — CLI command + reusable `build_backup_payload()` helper
|
||||
- `core/management/commands/restore_data.py` — CLI command + reusable `restore_from_json_string()` helper
|
||||
- `core/views.py::backup_data` — browser view that reuses the helper
|
||||
- `core/views.py::restore_data` — browser view with minimal HTML upload UI
|
||||
- URLs: `/backup-data/`, `/restore-data/` (both `@login_required` + `is_admin()` gated)
|
||||
|
||||
## Environment Variables
|
||||
```
|
||||
@ -186,5 +529,5 @@ PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
|
||||
## Important Context
|
||||
- The owner (Konrad) is not a developer — explain changes clearly and avoid unnecessary complexity
|
||||
- This system handles real payroll for field workers — accuracy is critical
|
||||
- `render_to_pdf()` uses lazy import of xhtml2pdf to prevent app crash if library missing
|
||||
- `render_to_pdf()` uses lazy import of WeasyPrint to prevent app crash if library missing; on Windows it also auto-registers the GTK3 runtime's DLL directory so `ctypes.find_library()` can locate `gobject-2.0-0` (Python 3.8+ requires explicit `os.add_dll_directory()`)
|
||||
- Django admin is available at `/admin/` with full model registration and search/filter
|
||||
|
||||
@ -12,13 +12,40 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
# === DEBUG ===
|
||||
# DEBUG defaults to FALSE — must be explicitly enabled via env var.
|
||||
# Previously defaulted to "true" which exposed full tracebacks and
|
||||
# settings to anyone who hit a 500 error in production.
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
|
||||
|
||||
# === DEV MODE DETECTION ===
|
||||
# Local dev uses SQLite (see run_dev.bat). When USE_SQLITE is set we're
|
||||
# in dev and can relax a few "must be set in prod" checks.
|
||||
_IS_DEV = os.getenv("USE_SQLITE", "").lower() == "true"
|
||||
|
||||
# === SECRET_KEY ===
|
||||
# Must be provided via DJANGO_SECRET_KEY env var in any non-dev deploy.
|
||||
# In dev mode (USE_SQLITE=true) we fall back to a known-insecure key so
|
||||
# local development works out of the box. In prod the absence of the
|
||||
# env var raises a startup error rather than silently using a weak key.
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
|
||||
if not SECRET_KEY:
|
||||
if _IS_DEV or DEBUG:
|
||||
# Dev-only key — NEVER set this value in a production env var.
|
||||
SECRET_KEY = "dev-only-insecure-key-do-not-use-in-production"
|
||||
else:
|
||||
raise ImproperlyConfigured(
|
||||
"DJANGO_SECRET_KEY environment variable is not set. "
|
||||
"Set it in the deploy platform's environment variables. "
|
||||
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
|
||||
"to generate a new one."
|
||||
)
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
@ -27,17 +54,33 @@ ALLOWED_HOSTS = [
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
]
|
||||
|
||||
# === CSRF TRUSTED ORIGINS ===
|
||||
# Build the list, then normalise each entry to have an https:// prefix.
|
||||
# Guard against the double-prefix bug: if the user sets HOST_FQDN to
|
||||
# "https://example.com" (with a scheme), the raw f-string would produce
|
||||
# "https://https://example.com" which Django rejects.
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin for origin in [
|
||||
"foxlog.flatlogic.app",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", ""),
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
|
||||
def _normalize_origin(host):
|
||||
"""Ensure `host` has an http:// or https:// scheme; default to https.
|
||||
|
||||
Accepts any of: 'example.com' / 'https://example.com' / 'http://localhost'
|
||||
Returns a string with a scheme every time.
|
||||
"""
|
||||
host = host.strip()
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host
|
||||
return f"https://{host}"
|
||||
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [_normalize_origin(h) for h in CSRF_TRUSTED_ORIGINS]
|
||||
|
||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
@ -78,7 +121,11 @@ ROOT_URLCONF = 'config.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
# Explicitly load core/templates first so we can override specific
|
||||
# Django admin templates (e.g. admin/base_site.html) without having
|
||||
# to reorder INSTALLED_APPS. Without this entry, the app-dirs loader
|
||||
# finds django.contrib.admin's version before ours.
|
||||
'DIRS': [BASE_DIR / 'core' / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
@ -163,26 +210,55 @@ MEDIA_ROOT = BASE_DIR / 'media'
|
||||
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
|
||||
# The App Password is a 16-character code from Google Account settings —
|
||||
# it lets the app send email through Gmail without your actual password.
|
||||
# === EMAIL CONFIGURATION ===
|
||||
# NO FALLBACKS for credentials — they MUST come from environment variables.
|
||||
# Previous versions had the Gmail App Password committed in source as a
|
||||
# fallback default, which is a critical security leak via git history.
|
||||
# In local dev (USE_SQLITE=true) empty credentials are fine; email sends
|
||||
# will just fail with an auth error — which is what you want locally.
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set on deploy platform
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set on deploy platform
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "")
|
||||
CONTACT_EMAIL_TO = [
|
||||
item.strip()
|
||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting import
|
||||
# Spark Receipt Email — payslip and receipt PDFs routed here for accounting import.
|
||||
# This is a routing address, not a secret, so a default is acceptable — but override
|
||||
# via env var for flexibility. Set to empty string if you want to disable sending.
|
||||
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
|
||||
|
||||
# Fail loudly at startup in production if credentials are missing — catches the
|
||||
# "I forgot to set env vars on the new deploy platform" mistake before a user
|
||||
# triggers a payroll payment and the email silently fails.
|
||||
if not DEBUG and not _IS_DEV:
|
||||
_missing_email_vars = [
|
||||
name for name, val in [
|
||||
("EMAIL_HOST_USER", EMAIL_HOST_USER),
|
||||
("EMAIL_HOST_PASSWORD", EMAIL_HOST_PASSWORD),
|
||||
("DEFAULT_FROM_EMAIL", DEFAULT_FROM_EMAIL),
|
||||
] if not val
|
||||
]
|
||||
if _missing_email_vars:
|
||||
# Don't crash — email sending isn't critical for the app to boot —
|
||||
# but log a loud warning so it's visible in deploy logs.
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Email configuration incomplete in production. Missing env vars: %s. "
|
||||
"Payslip and receipt emails will fail to send until these are set.",
|
||||
", ".join(_missing_email_vars),
|
||||
)
|
||||
|
||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||
if EMAIL_USE_SSL:
|
||||
EMAIL_USE_TLS = False
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from .models import (
|
||||
UserProfile, Project, Worker, Team, WorkLog,
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem
|
||||
UserProfile, Project, Worker, Team, WorkLog,
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem,
|
||||
WorkerCertificate, WorkerWarning,
|
||||
)
|
||||
|
||||
@admin.register(UserProfile)
|
||||
@ -17,27 +18,72 @@ class ProjectAdmin(admin.ModelAdmin):
|
||||
search_fields = ('name', 'description')
|
||||
filter_horizontal = ('supervisors',)
|
||||
|
||||
# === INLINE ADMINS FOR WORKER ===
|
||||
# Let admins manage a worker's certifications and warnings directly
|
||||
# from the Worker change page, without navigating to a separate screen.
|
||||
class WorkerCertificateInline(admin.TabularInline):
|
||||
model = WorkerCertificate
|
||||
extra = 0 # no blank rows by default — admin clicks "Add another" to create
|
||||
readonly_fields = ('created_at',)
|
||||
fields = ('cert_type', 'document', 'issued_date', 'valid_until', 'notes', 'created_at')
|
||||
|
||||
|
||||
class WorkerWarningInline(admin.TabularInline):
|
||||
model = WorkerWarning
|
||||
extra = 0
|
||||
readonly_fields = ('created_at',)
|
||||
fields = ('date', 'severity', 'reason', 'description', 'issued_by', 'document', 'created_at')
|
||||
|
||||
|
||||
@admin.register(Worker)
|
||||
class WorkerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'id_number', 'monthly_salary', 'active')
|
||||
list_filter = ('active', 'has_drivers_license')
|
||||
search_fields = ('name', 'id_number', 'phone_number')
|
||||
|
||||
# Inline sections for certs + warnings appear below the main Worker form
|
||||
inlines = [WorkerCertificateInline, WorkerWarningInline]
|
||||
|
||||
# === FIELDSETS ===
|
||||
# Organise the worker edit form into clear sections
|
||||
# Organise the worker edit form into clear sections.
|
||||
# Banking & Tax fields (UIF, Bank, Acc No.) live inside Personal Info
|
||||
# per product requirement — help_text strings render as hints under
|
||||
# each field in admin (and as tooltips on the friendly edit page).
|
||||
fieldsets = (
|
||||
('Personal Info', {
|
||||
'fields': ('name', 'id_number', 'phone_number', 'monthly_salary',
|
||||
'tax_number', 'uif_number',
|
||||
'bank_name', 'bank_account_number',
|
||||
'employment_date', 'active', 'notes'),
|
||||
}),
|
||||
('Sizing', {
|
||||
'fields': ('shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size'),
|
||||
}),
|
||||
('Documents & License', {
|
||||
'fields': ('photo', 'id_document', 'has_drivers_license', 'drivers_license'),
|
||||
'fields': ('photo', 'id_document',
|
||||
'has_drivers_license', 'drivers_license', 'drivers_license_code'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
# === STANDALONE ADMINS FOR CERTS + WARNINGS ===
|
||||
# Separate pages for bulk operations across workers — "show me all
|
||||
# certs expiring this month" or "show me all final warnings".
|
||||
@admin.register(WorkerCertificate)
|
||||
class WorkerCertificateAdmin(admin.ModelAdmin):
|
||||
list_display = ('worker', 'cert_type', 'issued_date', 'valid_until', 'is_expired')
|
||||
list_filter = ('cert_type',)
|
||||
search_fields = ('worker__name', 'worker__id_number')
|
||||
date_hierarchy = 'valid_until'
|
||||
|
||||
|
||||
@admin.register(WorkerWarning)
|
||||
class WorkerWarningAdmin(admin.ModelAdmin):
|
||||
list_display = ('worker', 'date', 'severity', 'reason', 'issued_by')
|
||||
list_filter = ('severity',)
|
||||
search_fields = ('worker__name', 'reason', 'description')
|
||||
date_hierarchy = 'date'
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
|
||||
|
||||
243
core/forms.py
243
core/forms.py
@ -3,10 +3,34 @@
|
||||
# - AttendanceLogForm: daily work log creation with date ranges and conflict detection
|
||||
# - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments
|
||||
# - ExpenseReceiptForm + ExpenseLineItemFormSet: expense receipt creation with dynamic line items
|
||||
# - WorkerForm + WorkerCertificateFormSet + WorkerWarningFormSet: friendly
|
||||
# worker management (alternative to the Django admin)
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
|
||||
from .models import (
|
||||
WorkLog, Project, Team, Worker, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem,
|
||||
WorkerCertificate, WorkerWarning,
|
||||
)
|
||||
|
||||
|
||||
# === FILE SIZE VALIDATOR ===
|
||||
# Reusable 5 MB ceiling for uploads (photos, IDs, certificates, warning docs).
|
||||
# Keeps the MEDIA_ROOT from being filled with a single accidental 50 MB scan.
|
||||
MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
def validate_max_5mb(f):
|
||||
"""Raise ValidationError if the uploaded file exceeds 5 MB."""
|
||||
if f and hasattr(f, 'size') and f.size > MAX_UPLOAD_SIZE:
|
||||
mb = f.size / (1024 * 1024)
|
||||
raise ValidationError(
|
||||
f'File is {mb:.1f} MB — maximum allowed is 5 MB. '
|
||||
'Please reduce the file size (e.g. scan at a lower resolution) and try again.'
|
||||
)
|
||||
|
||||
|
||||
class AttendanceLogForm(forms.ModelForm):
|
||||
@ -213,3 +237,220 @@ ExpenseLineItemFormSet = inlineformset_factory(
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# === WORKER MANAGEMENT FORMS ===
|
||||
# =============================================================
|
||||
|
||||
class WorkerForm(forms.ModelForm):
|
||||
"""Main worker edit form — covers all the flat fields on Worker.
|
||||
|
||||
Certifications and warnings are handled separately by the formsets
|
||||
below (they have their own rows in their own tables).
|
||||
"""
|
||||
class Meta:
|
||||
model = Worker
|
||||
fields = [
|
||||
'name', 'id_number', 'phone_number', 'monthly_salary',
|
||||
'tax_number', 'uif_number', 'bank_name', 'bank_account_number',
|
||||
'employment_date', 'active', 'notes',
|
||||
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
|
||||
'photo', 'id_document',
|
||||
'has_drivers_license', 'drivers_license', 'drivers_license_code',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'id_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'phone_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+27...'}),
|
||||
'monthly_salary': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
|
||||
# Banking & Tax
|
||||
'tax_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'uif_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'bank_name': forms.TextInput(attrs={'class': 'form-control',
|
||||
'placeholder': 'e.g. FNB, Standard Bank, Capitec'}),
|
||||
'bank_account_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'employment_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'shoe_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 9 / 43'}),
|
||||
'overall_top_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
|
||||
'pants_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 34'}),
|
||||
'tshirt_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
|
||||
'photo': forms.ClearableFileInput(attrs={'class': 'form-control'}),
|
||||
'id_document': forms.ClearableFileInput(attrs={'class': 'form-control'}),
|
||||
'has_drivers_license': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'drivers_license': forms.ClearableFileInput(attrs={'class': 'form-control'}),
|
||||
'drivers_license_code': forms.TextInput(attrs={'class': 'form-control',
|
||||
'placeholder': 'e.g. EB, C1'}),
|
||||
}
|
||||
|
||||
def clean_photo(self):
|
||||
f = self.cleaned_data.get('photo')
|
||||
validate_max_5mb(f)
|
||||
return f
|
||||
|
||||
def clean_id_document(self):
|
||||
f = self.cleaned_data.get('id_document')
|
||||
validate_max_5mb(f)
|
||||
return f
|
||||
|
||||
def clean_drivers_license(self):
|
||||
f = self.cleaned_data.get('drivers_license')
|
||||
validate_max_5mb(f)
|
||||
return f
|
||||
|
||||
|
||||
class WorkerCertificateForm(forms.ModelForm):
|
||||
"""Single certificate row. Used inside the formset — not rendered directly."""
|
||||
class Meta:
|
||||
model = WorkerCertificate
|
||||
fields = ['cert_type', 'document', 'issued_date', 'valid_until', 'notes']
|
||||
widgets = {
|
||||
'cert_type': forms.Select(attrs={'class': 'form-select form-select-sm'}),
|
||||
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
|
||||
'issued_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
|
||||
'valid_until': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2}),
|
||||
}
|
||||
|
||||
def clean_document(self):
|
||||
f = self.cleaned_data.get('document')
|
||||
validate_max_5mb(f)
|
||||
return f
|
||||
|
||||
|
||||
class WorkerWarningForm(forms.ModelForm):
|
||||
"""Single warning row. Used inside the formset — not rendered directly."""
|
||||
class Meta:
|
||||
model = WorkerWarning
|
||||
fields = ['date', 'severity', 'reason', 'description', 'document']
|
||||
widgets = {
|
||||
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
|
||||
'severity': forms.Select(attrs={'class': 'form-select form-select-sm'}),
|
||||
'reason': forms.TextInput(attrs={'class': 'form-control form-control-sm',
|
||||
'placeholder': 'Short summary'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2,
|
||||
'placeholder': 'Full context...'}),
|
||||
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
|
||||
}
|
||||
|
||||
def clean_document(self):
|
||||
f = self.cleaned_data.get('document')
|
||||
validate_max_5mb(f)
|
||||
return f
|
||||
|
||||
|
||||
# === WORKER CERTIFICATE FORMSET ===
|
||||
# extra=0: don't render blank rows by default — user clicks "+ Add" to create.
|
||||
# can_delete: user can tick the delete checkbox to remove a cert on save.
|
||||
WorkerCertificateFormSet = inlineformset_factory(
|
||||
Worker, WorkerCertificate,
|
||||
form=WorkerCertificateForm,
|
||||
extra=0,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
||||
# === WORKER WARNING FORMSET ===
|
||||
WorkerWarningFormSet = inlineformset_factory(
|
||||
Worker, WorkerWarning,
|
||||
form=WorkerWarningForm,
|
||||
extra=0,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# === TEAM & PROJECT MANAGEMENT FORMS ===
|
||||
# =============================================================
|
||||
# Friendly edit forms for Teams and Projects — alternative to Django
|
||||
# admin. Both are simple ModelForms (no inline formsets — these models
|
||||
# only have M2M relationships, handled by standard multi-select widgets).
|
||||
|
||||
|
||||
def _supervisor_user_queryset():
|
||||
"""Users eligible to supervise a team or project.
|
||||
|
||||
Matches the app's role model (see `is_supervisor` in views.py):
|
||||
anyone who is a Django admin (is_staff/is_superuser) OR is a member
|
||||
of the "Work Logger" group. Active accounts only — no deactivated
|
||||
users in the picker.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
return (
|
||||
User.objects.filter(is_active=True)
|
||||
.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger'))
|
||||
.distinct()
|
||||
.order_by('username')
|
||||
)
|
||||
|
||||
|
||||
class TeamForm(forms.ModelForm):
|
||||
"""Team edit form — covers every Team field plus the `workers` M2M."""
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = [
|
||||
'name', 'supervisor', 'active',
|
||||
'pay_frequency', 'pay_start_date',
|
||||
'workers',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'supervisor': forms.Select(attrs={'class': 'form-select'}),
|
||||
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'pay_frequency': forms.Select(attrs={'class': 'form-select'}),
|
||||
'pay_start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
# CheckboxSelectMultiple is kinder for small worker lists; the
|
||||
# template groups active/inactive visually via template logic.
|
||||
'workers': forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Supervisor dropdown — show anyone who is either admin (is_staff/
|
||||
# is_superuser) OR a member of the "Work Logger" group. This matches
|
||||
# the app's role model: team supervisors are typically Work Loggers,
|
||||
# not admins, so filtering by is_staff alone hides the people who
|
||||
# actually supervise teams day-to-day. We use `active=True` to drop
|
||||
# deactivated accounts from the picker.
|
||||
self.fields['supervisor'].queryset = _supervisor_user_queryset()
|
||||
self.fields['supervisor'].required = False
|
||||
# Include inactive workers too — matches admin parity. The template
|
||||
# badges inactive ones so users can tell at a glance.
|
||||
self.fields['workers'].queryset = Worker.objects.all().order_by('-active', 'name')
|
||||
self.fields['workers'].required = False
|
||||
|
||||
|
||||
class ProjectForm(forms.ModelForm):
|
||||
"""Project edit form — covers every Project field plus the `supervisors` M2M."""
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
'name', 'description', 'active',
|
||||
'start_date', 'end_date',
|
||||
'supervisors',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'supervisors': forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Project supervisors follow the same rule as team supervisors — admins
|
||||
# or Work Loggers are eligible.
|
||||
self.fields['supervisors'].queryset = _supervisor_user_queryset()
|
||||
self.fields['supervisors'].required = False
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
start = cleaned.get('start_date')
|
||||
end = cleaned.get('end_date')
|
||||
if start and end and end < start:
|
||||
raise ValidationError("End date must be on or after the start date.")
|
||||
return cleaned
|
||||
|
||||
132
core/management/commands/backup_data.py
Normal file
132
core/management/commands/backup_data.py
Normal file
@ -0,0 +1,132 @@
|
||||
# === BACKUP DATA MANAGEMENT COMMAND ===
|
||||
# Exports every row of every core model to a single JSON file that can
|
||||
# be restored later via `python manage.py restore_data <file.json>`.
|
||||
#
|
||||
# WHY THIS EXISTS:
|
||||
# Flatlogic doesn't expose MySQL directly — no mysqldump, no SSH, no
|
||||
# DB console. Django's built-in `dumpdata` / `loaddata` give us a
|
||||
# platform-independent backup format that travels with the code.
|
||||
#
|
||||
# WHY NOT JUST USE `dumpdata`?
|
||||
# This command is a thin wrapper around dumpdata that:
|
||||
# - Pins the exact set of app+model rows we want to back up
|
||||
# - Writes to a timestamped file so you never overwrite a backup
|
||||
# - Includes Users + Groups + auth content types (so permissions
|
||||
# restore correctly too)
|
||||
# - Prints a row-count summary so you can confirm it worked
|
||||
#
|
||||
# USAGE (local):
|
||||
# python manage.py backup_data → backups/foxlog_YYYYMMDD_HHMMSS.json
|
||||
# python manage.py backup_data --output=my.json → my.json
|
||||
#
|
||||
# USAGE (Flatlogic, via browser):
|
||||
# Visit /backup-data/ as admin — downloads the backup file to your browser.
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core import serializers
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from core.models import (
|
||||
UserProfile, Project, Worker, Team, WorkLog,
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem,
|
||||
WorkerCertificate, WorkerWarning,
|
||||
)
|
||||
|
||||
|
||||
# === BACKUP SCOPE ===
|
||||
# The exact list of models we back up. Order matters for restore —
|
||||
# we list models in dependency order (no FK should point at something
|
||||
# that comes later in the list). Django's loaddata handles this
|
||||
# correctly regardless, but keeping it sorted helps humans read it.
|
||||
MODELS_TO_BACKUP = [
|
||||
# Auth fundamentals — restore these first so FKs from UserProfile
|
||||
# etc. find their user rows.
|
||||
ContentType,
|
||||
Permission,
|
||||
Group,
|
||||
User,
|
||||
# Core app
|
||||
UserProfile,
|
||||
Project,
|
||||
Worker,
|
||||
Team,
|
||||
WorkLog,
|
||||
PayrollRecord,
|
||||
Loan,
|
||||
PayrollAdjustment,
|
||||
ExpenseReceipt,
|
||||
ExpenseLineItem,
|
||||
WorkerCertificate,
|
||||
WorkerWarning,
|
||||
]
|
||||
|
||||
|
||||
def build_backup_payload():
|
||||
"""Return (json_str, summary_dict) for the current DB state.
|
||||
|
||||
Separated from the Command class so the browser view can reuse it
|
||||
to stream the backup to the user's browser.
|
||||
"""
|
||||
# Pull every row of every model we care about, serialise as JSON.
|
||||
# serializers.serialize("json", queryset) returns a JSON string.
|
||||
# We concatenate by building one big list first, then dumping once.
|
||||
all_rows = []
|
||||
summary = {}
|
||||
for model in MODELS_TO_BACKUP:
|
||||
qs = list(model.objects.all())
|
||||
summary[f"{model._meta.app_label}.{model._meta.model_name}"] = len(qs)
|
||||
# Use the built-in Django serializer for proper natural-key support
|
||||
serialized = serializers.serialize("python", qs)
|
||||
all_rows.extend(serialized)
|
||||
|
||||
payload = {
|
||||
"version": 1,
|
||||
"exported_at": datetime.datetime.now().isoformat(),
|
||||
"row_counts": summary,
|
||||
"data": all_rows,
|
||||
}
|
||||
return json.dumps(payload, indent=2, default=str), summary
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Export every core-app row to a JSON file for backup/restore. "
|
||||
"Writes to backups/foxlog_<timestamp>.json unless --output is given."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output filepath. Default: backups/foxlog_<timestamp>.json",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
json_str, summary = build_backup_payload()
|
||||
|
||||
# Default path: ./backups/foxlog_<timestamp>.json
|
||||
if options["output"]:
|
||||
output_path = Path(options["output"])
|
||||
else:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = Path("backups") / f"foxlog_{ts}.json"
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json_str, encoding="utf-8")
|
||||
|
||||
# Print a summary so you can verify at a glance
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Backup written to: {output_path}"
|
||||
))
|
||||
self.stdout.write(f"File size: {output_path.stat().st_size:,} bytes")
|
||||
self.stdout.write("Row counts by model:")
|
||||
for model_name, count in sorted(summary.items()):
|
||||
self.stdout.write(f" {model_name:<40} {count:>6}")
|
||||
141
core/management/commands/restore_data.py
Normal file
141
core/management/commands/restore_data.py
Normal file
@ -0,0 +1,141 @@
|
||||
# === RESTORE DATA MANAGEMENT COMMAND ===
|
||||
# Restores a backup produced by `backup_data` — takes a JSON file and
|
||||
# loads every row into the database.
|
||||
#
|
||||
# SAFETY:
|
||||
# By default this command REFUSES to run against a non-empty database
|
||||
# (prevents accidentally overwriting live data). Pass --force to
|
||||
# bypass — but only when you know the target is empty or already
|
||||
# matches the backup.
|
||||
#
|
||||
# USAGE (local):
|
||||
# python manage.py restore_data backups/foxlog_20260421_120000.json
|
||||
# python manage.py restore_data backup.json --force (overwrite existing)
|
||||
#
|
||||
# USAGE (Flatlogic, via browser):
|
||||
# Upload a .json backup file via /restore-data/ (admin only).
|
||||
#
|
||||
# BEHAVIOUR:
|
||||
# Uses Django's built-in `loaddata` under the hood, which:
|
||||
# - Updates existing rows if their pk matches (no duplicates)
|
||||
# - Creates new rows for any pk not yet in the DB
|
||||
# - Respects FK/M2M dependencies
|
||||
# - Runs inside a transaction — if any row fails, nothing is saved
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management import call_command
|
||||
from django.db import transaction
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from core.models import Worker, WorkLog, PayrollRecord
|
||||
|
||||
|
||||
def check_database_is_populated():
|
||||
"""Return True if the database already has meaningful data.
|
||||
|
||||
Used as a guardrail: by default we refuse to restore into a DB that
|
||||
already contains workers, work logs, or payroll records, because
|
||||
that could double-insert and corrupt the state.
|
||||
"""
|
||||
has_workers = Worker.objects.exists()
|
||||
has_logs = WorkLog.objects.exists()
|
||||
has_payments = PayrollRecord.objects.exists()
|
||||
return has_workers or has_logs or has_payments
|
||||
|
||||
|
||||
def restore_from_json_string(json_str):
|
||||
"""Load a JSON backup string into the database.
|
||||
|
||||
Returns (success, message_or_summary). Used both by this management
|
||||
command and by the browser-accessible `/restore-data/` view so the
|
||||
same logic runs in both places.
|
||||
|
||||
Raises no exceptions — returns (False, error_message) on failure so
|
||||
the caller (CLI or web view) can format the error appropriately.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"File is not valid JSON: {e}"
|
||||
|
||||
# Backups produced by `backup_data` wrap rows in a top-level dict.
|
||||
# Raw dumpdata output is a bare list — support both for flexibility.
|
||||
if isinstance(payload, dict) and "data" in payload:
|
||||
rows = payload["data"]
|
||||
elif isinstance(payload, list):
|
||||
rows = payload
|
||||
else:
|
||||
return False, "Unexpected JSON structure — expected dict with 'data' key or a list."
|
||||
|
||||
if not rows:
|
||||
return False, "Backup file contains no rows."
|
||||
|
||||
# Write the rows to a tmp file then let Django's loaddata do the work
|
||||
# (it handles FK order, transaction wrapping, and natural keys).
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False,
|
||||
encoding="utf-8") as tmp:
|
||||
# loaddata expects the bare list format
|
||||
json.dump(rows, tmp, default=str)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
call_command("loaddata", tmp_path, verbosity=0)
|
||||
except Exception as e:
|
||||
return False, f"Restore failed: {e}"
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass # cleanup best-effort
|
||||
|
||||
# Build a summary for the caller to display
|
||||
summary = {
|
||||
"users": User.objects.count(),
|
||||
"workers": Worker.objects.count(),
|
||||
"work_logs": WorkLog.objects.count(),
|
||||
"payroll_records": PayrollRecord.objects.count(),
|
||||
"rows_in_backup": len(rows),
|
||||
}
|
||||
return True, summary
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Restore a JSON backup produced by `backup_data`."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("backup_file", type=str, help="Path to a .json backup file")
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Allow restore even if the target database already has data",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_path = Path(options["backup_file"])
|
||||
if not backup_path.exists():
|
||||
raise CommandError(f"Backup file not found: {backup_path}")
|
||||
|
||||
if not options["force"] and check_database_is_populated():
|
||||
raise CommandError(
|
||||
"Database already contains data (workers/logs/payments). "
|
||||
"Restoring now could duplicate or corrupt rows.\n"
|
||||
"If you really want to proceed, run again with --force.\n"
|
||||
"Or flush first: python manage.py flush (irreversible)."
|
||||
)
|
||||
|
||||
json_str = backup_path.read_text(encoding="utf-8")
|
||||
ok, result = restore_from_json_string(json_str)
|
||||
|
||||
if not ok:
|
||||
raise CommandError(result)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Restore complete."))
|
||||
self.stdout.write("Rows in database after restore:")
|
||||
for k, v in result.items():
|
||||
self.stdout.write(f" {k}: {v}")
|
||||
18
core/migrations/0007_vat_type_default.py
Normal file
18
core/migrations/0007_vat_type_default.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-20 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_worker_drivers_license_worker_has_drivers_license_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='expensereceipt',
|
||||
name='vat_type',
|
||||
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='Included', max_length=20),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0008_vat_type_default_none.py
Normal file
18
core/migrations/0008_vat_type_default_none.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-20 19:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_vat_type_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='expensereceipt',
|
||||
name='vat_type',
|
||||
field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='None', max_length=20),
|
||||
),
|
||||
]
|
||||
51
core/migrations/0009_workerwarning_workercertificate.py
Normal file
51
core/migrations/0009_workerwarning_workercertificate.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-21 12:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_vat_type_default_none'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkerWarning',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(default=django.utils.timezone.now)),
|
||||
('severity', models.CharField(choices=[('verbal', 'Verbal Warning'), ('written', 'Written Warning'), ('final', 'Final Warning')], max_length=20)),
|
||||
('reason', models.CharField(help_text='Short summary — e.g. "Repeated lateness"', max_length=200)),
|
||||
('description', models.TextField(blank=True, help_text='Full context of what happened')),
|
||||
('document', models.FileField(blank=True, help_text='Signed warning form (optional)', null=True, upload_to='workers/warnings/')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('issued_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warnings_issued', to=settings.AUTH_USER_MODEL)),
|
||||
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warnings', to='core.worker')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkerCertificate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('cert_type', models.CharField(choices=[('skills', 'Skills Certificate'), ('pdp', 'PDP (Professional Driving Permit)'), ('first_aid', 'First Aid'), ('medical', 'Medical'), ('work_at_height', 'Work at Height')], max_length=30)),
|
||||
('document', models.FileField(blank=True, help_text='Scan or photo of the certificate', null=True, upload_to='workers/certificates/')),
|
||||
('issued_date', models.DateField(blank=True, null=True)),
|
||||
('valid_until', models.DateField(blank=True, help_text='Expiry date — leave blank if the cert does not expire', null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='core.worker')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['worker', 'cert_type'],
|
||||
'constraints': [models.UniqueConstraint(fields=('worker', 'cert_type'), name='unique_cert_per_worker')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-21 14:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_workerwarning_workercertificate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='worker',
|
||||
name='bank_account_number',
|
||||
field=models.CharField(blank=True, help_text='Bank account number', max_length=50, verbose_name='Acc No.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='worker',
|
||||
name='bank_name',
|
||||
field=models.CharField(blank=True, help_text='Account at which Institution', max_length=100, verbose_name='Bank'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='worker',
|
||||
name='drivers_license_code',
|
||||
field=models.CharField(blank=True, help_text='Drivers License Code (e.g. A, B, C, EB, EC)', max_length=20, verbose_name='Code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='worker',
|
||||
name='uif_number',
|
||||
field=models.CharField(blank=True, help_text='Unemployment Insurance Fund number', max_length=50, verbose_name='UIF'),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0011_worker_tax_number.py
Normal file
18
core/migrations/0011_worker_tax_number.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-21 14:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_worker_bank_account_number_worker_bank_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='worker',
|
||||
name='tax_number',
|
||||
field=models.CharField(blank=True, help_text='Registered Tax Number', max_length=50, verbose_name='Tax No'),
|
||||
),
|
||||
]
|
||||
147
core/models.py
147
core/models.py
@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
@ -38,6 +39,33 @@ class Worker(models.Model):
|
||||
id_number = models.CharField(max_length=50, unique=True)
|
||||
phone_number = models.CharField(max_length=20, blank=True)
|
||||
monthly_salary = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
# === BANKING & TAX ===
|
||||
# Payroll-related identifiers. Shown in the "Personal Info" fieldset in
|
||||
# Django admin and the "Personal & Pay" section of the friendly edit form.
|
||||
# verbose_name becomes the form label; help_text becomes the tooltip
|
||||
# (friendly page) or the admin's under-field hint.
|
||||
tax_number = models.CharField(
|
||||
'Tax No',
|
||||
max_length=50, blank=True,
|
||||
help_text='Registered Tax Number',
|
||||
)
|
||||
uif_number = models.CharField(
|
||||
'UIF',
|
||||
max_length=50, blank=True,
|
||||
help_text='Unemployment Insurance Fund number',
|
||||
)
|
||||
bank_name = models.CharField(
|
||||
'Bank',
|
||||
max_length=100, blank=True,
|
||||
help_text='Account at which Institution',
|
||||
)
|
||||
bank_account_number = models.CharField(
|
||||
'Acc No.',
|
||||
max_length=50, blank=True,
|
||||
help_text='Bank account number',
|
||||
)
|
||||
|
||||
photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True)
|
||||
id_document = models.FileField(upload_to='workers/documents/', blank=True, null=True)
|
||||
employment_date = models.DateField(default=timezone.now)
|
||||
@ -55,6 +83,11 @@ class Worker(models.Model):
|
||||
# Track which workers have a valid drivers license and store a scanned copy
|
||||
has_drivers_license = models.BooleanField(default=False)
|
||||
drivers_license = models.FileField(upload_to='workers/documents/', blank=True, null=True)
|
||||
drivers_license_code = models.CharField(
|
||||
'Code',
|
||||
max_length=20, blank=True,
|
||||
help_text='Drivers License Code (e.g. A, B, C, EB, EC)',
|
||||
)
|
||||
|
||||
@property
|
||||
def daily_rate(self):
|
||||
@ -195,7 +228,7 @@ class ExpenseReceipt(models.Model):
|
||||
vendor_name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES)
|
||||
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES)
|
||||
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None')
|
||||
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
@ -210,3 +243,115 @@ class ExpenseLineItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.product_name
|
||||
|
||||
|
||||
# =============================================================
|
||||
# === WORKER CERTIFICATIONS ===
|
||||
# =============================================================
|
||||
# Each row means "this worker currently holds this certificate".
|
||||
# Delete the row to record that they no longer hold it.
|
||||
# Use valid_until to track when the cert expires — certs without a
|
||||
# valid_until date are treated as non-expiring (e.g. a completed
|
||||
# skills course with no expiry).
|
||||
class WorkerCertificate(models.Model):
|
||||
# === CERT TYPES ===
|
||||
# Fixed list for now; add more entries here when new cert types
|
||||
# become relevant (e.g. scaffolding, electrical, confined spaces).
|
||||
CERT_TYPES = [
|
||||
('skills', 'Skills Certificate'),
|
||||
('pdp', 'PDP (Professional Driving Permit)'),
|
||||
('first_aid', 'First Aid'),
|
||||
('medical', 'Medical'),
|
||||
('work_at_height', 'Work at Height'),
|
||||
]
|
||||
|
||||
worker = models.ForeignKey(
|
||||
Worker, related_name='certificates', on_delete=models.CASCADE,
|
||||
)
|
||||
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
|
||||
document = models.FileField(
|
||||
upload_to='workers/certificates/', blank=True, null=True,
|
||||
help_text='Scan or photo of the certificate',
|
||||
)
|
||||
issued_date = models.DateField(blank=True, null=True)
|
||||
valid_until = models.DateField(
|
||||
blank=True, null=True,
|
||||
help_text='Expiry date — leave blank if the cert does not expire',
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# One row per (worker, cert_type) — no duplicate cert types per worker
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['worker', 'cert_type'],
|
||||
name='unique_cert_per_worker',
|
||||
),
|
||||
]
|
||||
ordering = ['worker', 'cert_type']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.worker.name} — {self.get_cert_type_display()}'
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""True if the certificate's valid_until date is in the past."""
|
||||
if not self.valid_until:
|
||||
return False
|
||||
return self.valid_until < timezone.now().date()
|
||||
|
||||
@property
|
||||
def expires_soon(self):
|
||||
"""True if the cert expires within the next 30 days (but not yet expired)."""
|
||||
if not self.valid_until:
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
return today <= self.valid_until <= today + datetime.timedelta(days=30)
|
||||
|
||||
|
||||
# =============================================================
|
||||
# === WORKER WARNINGS / DISCIPLINARY ===
|
||||
# =============================================================
|
||||
# A disciplinary record per worker. Severity escalates: Verbal →
|
||||
# Written → Final. Keep all historical warnings for audit purposes;
|
||||
# don't delete rows. If a warning was issued in error, update the
|
||||
# reason/description to note that rather than removing it.
|
||||
class WorkerWarning(models.Model):
|
||||
# === SEVERITY LEVELS ===
|
||||
# Standard South African labour-relations escalation order.
|
||||
SEVERITY_CHOICES = [
|
||||
('verbal', 'Verbal Warning'),
|
||||
('written', 'Written Warning'),
|
||||
('final', 'Final Warning'),
|
||||
]
|
||||
|
||||
worker = models.ForeignKey(
|
||||
Worker, related_name='warnings', on_delete=models.CASCADE,
|
||||
)
|
||||
date = models.DateField(default=timezone.now)
|
||||
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
|
||||
reason = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Short summary — e.g. "Repeated lateness"',
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text='Full context of what happened',
|
||||
)
|
||||
issued_by = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='warnings_issued',
|
||||
)
|
||||
document = models.FileField(
|
||||
upload_to='workers/warnings/', blank=True, null=True,
|
||||
help_text='Signed warning form (optional)',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# Newest warnings first — that's what the UI will show at the top
|
||||
ordering = ['-date']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.worker.name} — {self.get_severity_display()} ({self.date})'
|
||||
|
||||
45
core/templates/admin/base_site.html
Normal file
45
core/templates/admin/base_site.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{# ===========================================================
|
||||
Minimal override of the default admin/base_site.html.
|
||||
The sole purpose right now is to inject a small <style> block
|
||||
into every Django admin page. Add more admin CSS tweaks here
|
||||
as needed — keeps them in one place and isolated from the
|
||||
main app's custom.css.
|
||||
=========================================================== #}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
<h1 id="site-header"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
<style>
|
||||
/* === FILTERED SELECT WIDGET (M2M pickers like Group permissions) ===
|
||||
The default Django admin filtered-select has a fairly short
|
||||
`height: 16em` on each side, so long permission lists need lots
|
||||
of scrolling. Make both "Available" and "Chosen" boxes taller
|
||||
by default — they expand with the viewport up to a cap.
|
||||
Applies to any filter_horizontal / filter_vertical M2M field. */
|
||||
.selector .selector-available select,
|
||||
.selector .selector-chosen select {
|
||||
min-height: 30em; /* was ~16em by default */
|
||||
height: 30em;
|
||||
}
|
||||
/* On large screens, push a bit taller */
|
||||
@media (min-height: 900px) {
|
||||
.selector .selector-available select,
|
||||
.selector .selector-chosen select {
|
||||
min-height: 40em;
|
||||
height: 40em;
|
||||
}
|
||||
}
|
||||
/* Match the titles' layout so the boxes stay aligned */
|
||||
.selector .selector-available,
|
||||
.selector .selector-chosen {
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block nav-global %}{% endblock %}
|
||||
@ -33,79 +33,149 @@
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<!-- ===================================================================
|
||||
APP LAYOUT — sidebar (desktop) + top bar (mobile) + content
|
||||
APP LAYOUT — top bar (desktop + mobile) + bottom tab bar (mobile)
|
||||
=================================================================== -->
|
||||
<div class="app-layout">
|
||||
|
||||
<!-- === SIDEBAR (desktop only, hidden on mobile via CSS) === -->
|
||||
<aside class="app-sidebar d-print-none">
|
||||
<!-- === TOP BAR (always visible — nav links on desktop, brand-only on mobile) === -->
|
||||
<header class="app-topbar d-print-none">
|
||||
<div class="topbar-inner">
|
||||
|
||||
<!-- Brand / Logo -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand__icon">
|
||||
<i class="fas fa-bolt"></i>
|
||||
</div>
|
||||
<a href="{% url 'home' %}" class="sidebar-brand__text">
|
||||
<span>Fox</span>Fitt
|
||||
<!-- Brand / Logo -->
|
||||
<a href="{% url 'home' %}" class="topbar-brand">
|
||||
<div class="sidebar-brand__icon">
|
||||
<i class="fas fa-bolt"></i>
|
||||
</div>
|
||||
<span class="topbar-brand__text">
|
||||
<span>Fox</span>Fitt
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="sidebar-nav">
|
||||
<a href="{% url 'home' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{% url 'attendance_log' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
<span>Log Work</span>
|
||||
</a>
|
||||
<a href="{% url 'work_history' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>History</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'payroll_dashboard' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
|
||||
<i class="fas fa-wallet"></i>
|
||||
<span>Payroll</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'create_receipt' %}" class="sidebar-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
|
||||
<i class="fas fa-receipt"></i>
|
||||
<span>Receipts</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'admin:index' %}" class="sidebar-nav__link">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<!-- Desktop Navigation Links (hidden on mobile — bottom tab bar handles it) -->
|
||||
<!-- Order: Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
|
||||
<nav class="topbar-nav">
|
||||
<a href="{% url 'home' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i><span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{% url 'attendance_log' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'payroll_dashboard' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
|
||||
<i class="fas fa-wallet"></i><span>Payroll</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'work_history' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i><span>History</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<!-- Resources dropdown: Workers, Teams, Projects -->
|
||||
<div class="dropdown">
|
||||
<a href="#" class="topbar-nav__link dropdown-toggle {% if 'worker' in request.resolver_match.url_name|default:'' or 'team' in request.resolver_match.url_name|default:'' or 'project' in request.resolver_match.url_name|default:'' %}active{% endif %}"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" role="button">
|
||||
<i class="fas fa-hard-hat"></i><span>Resources</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'worker_list' %}"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'team_list' %}"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Teams</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'project_list' %}"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Projects</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url 'create_receipt' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
|
||||
<i class="fas fa-receipt"></i><span>Receipts</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'admin:index' %}" class="topbar-nav__link">
|
||||
<i class="fas fa-cog"></i><span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer: theme toggle + user -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<!-- Right side: theme toggle + user + logout + hamburger (mobile) -->
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="theme-toggle" id="themeToggle" title="Toggle dark/light mode">
|
||||
<i class="fas fa-moon" id="themeIcon"></i>
|
||||
</button>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
<div class="topbar-user d-none d-md-flex">
|
||||
<div class="topbar-user__avatar">
|
||||
{{ user.username|make_list|first|upper }}
|
||||
</div>
|
||||
<span class="topbar-user__name">{{ user.first_name|default:user.username }}</span>
|
||||
</div>
|
||||
<form method="post" action="{% url 'logout' %}" class="d-none d-lg-block">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
<!-- Hamburger button (mobile only) -->
|
||||
<button type="button" class="hamburger-btn d-lg-none" id="hamburgerBtn" aria-label="Open menu">
|
||||
<i class="fas fa-bars" id="hamburgerIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user__avatar">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- === MOBILE MENU (slides down from topbar when hamburger is tapped) === -->
|
||||
<div class="mobile-menu d-lg-none d-print-none" id="mobileMenu">
|
||||
<!-- Mobile nav — same order as desktop:
|
||||
Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
|
||||
<nav class="mobile-menu__nav">
|
||||
<a href="{% url 'home' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
|
||||
<i class="fas fa-th-large"></i><span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{% url 'attendance_log' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'payroll_dashboard' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
|
||||
<i class="fas fa-wallet"></i><span>Payroll</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'work_history' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i><span>History</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<!-- Resources: flat list on mobile (dropdowns are clumsy in slide-down drawers) -->
|
||||
<a href="{% url 'worker_list' %}" class="mobile-menu__link">
|
||||
<i class="fas fa-hard-hat"></i><span>Workers</span>
|
||||
</a>
|
||||
<a href="{% url 'team_list' %}" class="mobile-menu__link">
|
||||
<i class="fas fa-users"></i><span>Teams</span>
|
||||
</a>
|
||||
<a href="{% url 'project_list' %}" class="mobile-menu__link">
|
||||
<i class="fas fa-project-diagram"></i><span>Projects</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'create_receipt' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
|
||||
<i class="fas fa-receipt"></i><span>Receipts</span>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'admin:index' %}" class="mobile-menu__link">
|
||||
<i class="fas fa-cog"></i><span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<!-- User info + logout at bottom of mobile menu -->
|
||||
<div class="mobile-menu__footer">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="topbar-user__avatar">
|
||||
{{ user.username|make_list|first|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="sidebar-user__name">{{ user.first_name|default:user.username }}</div>
|
||||
<div class="sidebar-user__role">{% if user.is_staff %}Administrator{% else %}Supervisor{% endif %}</div>
|
||||
<div style="color: var(--text-on-nav); font-size: 0.85rem; font-weight: 500;">{{ user.first_name|default:user.username }}</div>
|
||||
<div style="color: var(--text-on-nav-muted); font-size: 0.7rem;">{% if user.is_staff %}Administrator{% else %}Supervisor{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- === MAIN CONTENT AREA === -->
|
||||
<div class="app-main">
|
||||
@ -113,24 +183,6 @@
|
||||
<!-- Decorative gradient glows (separate from app-main to avoid stacking context trapping modals) -->
|
||||
<div class="app-glow d-print-none"></div>
|
||||
|
||||
<!-- === TOP BAR (mobile only, hidden on desktop via CSS) === -->
|
||||
<div class="app-topbar d-print-none">
|
||||
<a href="{% url 'home' %}" style="text-decoration: none; font-family: 'Poppins', sans-serif; font-weight: 700; font-size: 1.2rem;">
|
||||
<span style="color: var(--accent);">Fox</span><span style="color: var(--text-on-nav);">Fitt</span>
|
||||
</a>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="theme-toggle" id="themeToggleMobile" title="Toggle dark/light mode">
|
||||
<i class="fas fa-moon" id="themeIconMobile"></i>
|
||||
</button>
|
||||
<form method="post" action="{% url 'logout' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === Flash messages (Django messages framework) === -->
|
||||
{% if messages %}
|
||||
<div class="container-fluid px-3 px-lg-4 mt-3">
|
||||
@ -205,40 +257,137 @@
|
||||
<!-- Bootstrap 5.3 JS Bundle (includes Popper) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
|
||||
<!-- === GLOBAL TOOLTIP INIT ===
|
||||
Any element on any page with `data-bs-toggle="tooltip"` and a `title`
|
||||
attribute will automatically become a Bootstrap tooltip. Expose
|
||||
`window.initTooltipsIn(element)` so dynamic content (e.g. newly-added
|
||||
formset rows) can re-init without a full page reload.
|
||||
Cost: one querySelectorAll on page load + ~1KB state per tooltip.
|
||||
Negligible for this app's scale. -->
|
||||
<script>
|
||||
(function() {
|
||||
// Both desktop sidebar and mobile top bar toggle buttons
|
||||
var toggles = [
|
||||
{ btn: document.getElementById('themeToggle'), icon: document.getElementById('themeIcon') },
|
||||
{ btn: document.getElementById('themeToggleMobile'), icon: document.getElementById('themeIconMobile') }
|
||||
];
|
||||
|
||||
function updateIcons() {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||
toggles.forEach(function(t) {
|
||||
if (t.icon) {
|
||||
t.icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||
}
|
||||
if (t.btn) {
|
||||
t.btn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
||||
function initTooltipsIn(root) {
|
||||
root = root || document;
|
||||
var triggers = root.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
triggers.forEach(function(el) {
|
||||
// Avoid double-init if called on the same element twice
|
||||
if (!bootstrap.Tooltip.getInstance(el)) {
|
||||
new bootstrap.Tooltip(el, { container: 'body' });
|
||||
}
|
||||
});
|
||||
}
|
||||
window.initTooltipsIn = initTooltipsIn;
|
||||
document.addEventListener('DOMContentLoaded', function() { initTooltipsIn(document); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
updateIcons();
|
||||
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var icon = document.getElementById('themeIcon');
|
||||
|
||||
toggles.forEach(function(t) {
|
||||
if (t.btn) {
|
||||
t.btn.addEventListener('click', function() {
|
||||
var current = document.documentElement.getAttribute('data-theme');
|
||||
var next = (current === 'light') ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('foxfitt-theme', next);
|
||||
updateIcons();
|
||||
});
|
||||
function updateIcon() {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||
if (icon) icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||
if (btn) btn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
||||
}
|
||||
|
||||
updateIcon();
|
||||
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var current = document.documentElement.getAttribute('data-theme');
|
||||
var next = (current === 'light') ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('foxfitt-theme', next);
|
||||
updateIcon();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- === HAMBURGER MENU — toggles mobile navigation panel open/closed === -->
|
||||
<!-- Menu is position: fixed below the topbar so it stays visible when scrolled down -->
|
||||
<script>
|
||||
(function() {
|
||||
var hamburger = document.getElementById('hamburgerBtn');
|
||||
var menu = document.getElementById('mobileMenu');
|
||||
var icon = document.getElementById('hamburgerIcon');
|
||||
|
||||
// Create a backdrop overlay — closes the menu when tapping outside it
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'mobile-menu-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
function closeMenu() {
|
||||
menu.classList.remove('open');
|
||||
backdrop.classList.remove('open');
|
||||
if (icon) icon.className = 'fas fa-bars';
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
menu.classList.add('open');
|
||||
backdrop.classList.add('open');
|
||||
if (icon) icon.className = 'fas fa-times';
|
||||
}
|
||||
|
||||
if (hamburger && menu) {
|
||||
hamburger.addEventListener('click', function() {
|
||||
if (menu.classList.contains('open')) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when backdrop is tapped
|
||||
backdrop.addEventListener('click', closeMenu);
|
||||
|
||||
// Close menu when a nav link is tapped (instant navigation feel)
|
||||
var links = menu.querySelectorAll('.mobile-menu__link');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
links[i].addEventListener('click', closeMenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- === NUMBER FORMATTING — adds space thousands separators to R amounts === -->
|
||||
<!-- Finds text like "R 8000.00" and reformats to "R 8 000.00" (SA convention) -->
|
||||
<!-- Uses non-breaking spaces (\u00A0) so "R 6 666.00" never wraps mid-number -->
|
||||
<script>
|
||||
(function() {
|
||||
var NBSP = '\u00A0'; // non-breaking space — prevents line break inside number
|
||||
|
||||
function formatMoney(text) {
|
||||
// Match "R" optionally followed by space, then a number (with optional decimals and minus)
|
||||
return text.replace(/R\s*(-?\d[\d]*(?:\.\d+)?)/g, function(match, num) {
|
||||
var parts = num.split('.');
|
||||
var whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, NBSP);
|
||||
return 'R' + NBSP + whole + (parts[1] ? '.' + parts[1] : '');
|
||||
});
|
||||
}
|
||||
|
||||
// Walk all text nodes inside the main content area and format monetary values
|
||||
function formatAllMoney() {
|
||||
var root = document.querySelector('.app-content') || document.body;
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
var node;
|
||||
while (node = walker.nextNode()) {
|
||||
// Only process nodes that have "R" followed by 4+ digit numbers (worth formatting)
|
||||
if (/R\s*-?\d{4,}/.test(node.nodeValue)) {
|
||||
node.nodeValue = formatMoney(node.nodeValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', formatAllMoney);
|
||||
} else {
|
||||
formatAllMoney();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
134
core/templates/core/_report_config_modal.html
Normal file
134
core/templates/core/_report_config_modal.html
Normal file
@ -0,0 +1,134 @@
|
||||
{% comment %}
|
||||
=== REPORT CONFIGURATION MODAL (shared partial) ===
|
||||
Renders the "Generate Report" modal and its month-vs-custom-dates
|
||||
toggle script. Included by both the Dashboard (index.html) and the
|
||||
Report page (report.html) so users can launch a new report from
|
||||
either place without duplicating the modal HTML or the JS.
|
||||
|
||||
Requires in the parent template context:
|
||||
- `projects` (queryset of Project, for the project dropdown)
|
||||
- `teams` (queryset of Team, for the team dropdown)
|
||||
|
||||
If those are missing, the dropdowns simply show "All Projects" /
|
||||
"All Teams" — no crash.
|
||||
{% endcomment %}
|
||||
|
||||
<div class="modal fade" id="reportConfigModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Generate Report</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="get" action="{% url 'generate_report' %}" id="reportForm">
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<!-- Date Mode Toggle -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Date Selection</label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="date_mode" id="modeMonth" value="month" checked>
|
||||
<label class="btn btn-outline-secondary" for="modeMonth">
|
||||
<i class="fas fa-calendar-alt me-1"></i>Month(s)
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="date_mode" id="modeCustom" value="custom">
|
||||
<label class="btn btn-outline-secondary" for="modeCustom">
|
||||
<i class="fas fa-calendar-week me-1"></i>Custom Dates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month Range Picker (shown by default) -->
|
||||
<div class="col-6" id="fromMonthGroup">
|
||||
<label class="form-label fw-semibold">From</label>
|
||||
<input type="month" name="from_month" class="form-control" id="reportFromMonth">
|
||||
</div>
|
||||
<div class="col-6" id="toMonthGroup">
|
||||
<label class="form-label fw-semibold">To</label>
|
||||
<input type="month" name="to_month" class="form-control" id="reportToMonth">
|
||||
</div>
|
||||
|
||||
<!-- Custom Date Range (hidden by default) -->
|
||||
<div class="col-6 d-none" id="startDateGroup">
|
||||
<label class="form-label fw-semibold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" id="reportStartDate">
|
||||
</div>
|
||||
<div class="col-6 d-none" id="endDateGroup">
|
||||
<label class="form-label fw-semibold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control" id="reportEndDate">
|
||||
</div>
|
||||
|
||||
<!-- Project Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="project" class="form-select">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Team Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="team" class="form-select">
|
||||
<option value="">All Teams</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-accent"><i class="fas fa-chart-bar me-1"></i>Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
=== REPORT MODAL — toggle month range vs custom dates ===
|
||||
Defaults both month pickers to the current month on page load so
|
||||
clicking "Generate" without changing anything produces a
|
||||
current-month report. Guarded by `if (!modeMonth)` so it's a no-op
|
||||
on pages that don't include the modal.
|
||||
-->
|
||||
<script>
|
||||
(function() {
|
||||
var modeMonth = document.getElementById('modeMonth');
|
||||
if (!modeMonth) return; // modal not on this page — skip
|
||||
var modeCustom = document.getElementById('modeCustom');
|
||||
var fromMonthGroup = document.getElementById('fromMonthGroup');
|
||||
var toMonthGroup = document.getElementById('toMonthGroup');
|
||||
var startGroup = document.getElementById('startDateGroup');
|
||||
var endGroup = document.getElementById('endDateGroup');
|
||||
var fromMonth = document.getElementById('reportFromMonth');
|
||||
var toMonth = document.getElementById('reportToMonth');
|
||||
|
||||
// Default both month pickers to current month
|
||||
var now = new Date();
|
||||
var curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||
if (fromMonth) fromMonth.value = curMonth;
|
||||
if (toMonth) toMonth.value = curMonth;
|
||||
|
||||
function toggleMode() {
|
||||
if (modeMonth.checked) {
|
||||
fromMonthGroup.classList.remove('d-none');
|
||||
toMonthGroup.classList.remove('d-none');
|
||||
startGroup.classList.add('d-none');
|
||||
endGroup.classList.add('d-none');
|
||||
} else {
|
||||
fromMonthGroup.classList.add('d-none');
|
||||
toMonthGroup.classList.add('d-none');
|
||||
startGroup.classList.remove('d-none');
|
||||
endGroup.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
modeMonth.addEventListener('change', toggleMode);
|
||||
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
|
||||
})();
|
||||
</script>
|
||||
@ -4,11 +4,13 @@
|
||||
{% block title %}Dashboard | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- === DASHBOARD HEADER — gradient banner with welcome + CTA === -->
|
||||
<div class="dashboard-header mb-5 rounded-0 p-4 d-flex justify-content-between align-items-center d-print-none">
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === DASHBOARD HEADER — welcome + CTA button === -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 d-print-none">
|
||||
<div>
|
||||
<h1 class="h3 mb-1 text-white" style="font-family: 'Poppins', sans-serif;">Dashboard</h1>
|
||||
<p class="mb-0" style="color: rgba(255,255,255,0.6); font-size: 0.9rem;">
|
||||
<h1 class="page-title"><i class="fas fa-th-large me-2" style="color: var(--accent);"></i>Dashboard</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
Welcome back, {{ user.first_name|default:user.username }}
|
||||
</p>
|
||||
</div>
|
||||
@ -17,8 +19,6 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="container py-2" style="margin-top: -3rem;">
|
||||
|
||||
{% if is_admin %}
|
||||
<!-- ===================================================================
|
||||
ADMIN VIEW — stats, quick actions, activity, resources
|
||||
@ -96,6 +96,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Certifications Expiring — shown ONLY when count > 0
|
||||
Clicking it goes to the Worker Batch Report which shows per-worker cert columns. -->
|
||||
{% if certs_alert_total %}
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<a href="{% url 'worker_batch_report' %}?status=active" class="stat-card stat-card--danger h-100 p-3 d-block" style="text-decoration: none; color: inherit;">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Certifications Need Attention</div>
|
||||
<div class="stat-value" style="font-size: 1.5rem;">{{ certs_alert_total }}</div>
|
||||
<div style="font-size: 0.75rem; margin-top: 0.35rem; color: var(--text-secondary);">
|
||||
{% if certs_expired_count %}{{ certs_expired_count }} expired{% endif %}
|
||||
{% if certs_expired_count and certs_expiring_count %} | {% endif %}
|
||||
{% if certs_expiring_count %}{{ certs_expiring_count }} expiring in 30 days{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon stat-icon--danger">
|
||||
<i class="fas fa-certificate"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Outstanding by Project -->
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="stat-card stat-card--info h-100 p-3">
|
||||
@ -161,6 +184,10 @@
|
||||
<i class="fas fa-receipt"></i>
|
||||
<span>New Receipt</span>
|
||||
</a>
|
||||
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<span>Generate Report</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -205,13 +232,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Manage Resources -->
|
||||
<!-- Note: the worker CSV export lives on the Workers page now
|
||||
(nav: Workers → Export CSV). Dashboard card stays focused on
|
||||
toggling active/inactive status, not on data export. -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-sliders-h me-2" style="color: var(--accent);"></i>Manage Resources</h6>
|
||||
<a href="{% url 'export_workers_csv' %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-file-csv me-1"></i> Export
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0 pt-2">
|
||||
<p class="px-3 mb-2" style="font-size: 0.75rem; color: var(--text-tertiary);">
|
||||
@ -254,6 +281,11 @@
|
||||
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No workers found.</p>
|
||||
{% endfor %}
|
||||
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
|
||||
<div class="text-center py-2 border-top">
|
||||
<a href="{% url 'worker_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
|
||||
<i class="fas fa-arrow-right me-1"></i>Manage All Workers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === PROJECTS === #}
|
||||
@ -269,6 +301,11 @@
|
||||
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No projects found.</p>
|
||||
{% endfor %}
|
||||
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
|
||||
<div class="text-center py-2 border-top">
|
||||
<a href="{% url 'project_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
|
||||
<i class="fas fa-arrow-right me-1"></i>Manage All Projects
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === TEAMS === #}
|
||||
@ -284,6 +321,11 @@
|
||||
<p class="text-center py-3" style="color: var(--text-tertiary); font-size: 0.85rem;">No teams found.</p>
|
||||
{% endfor %}
|
||||
<p class="text-center py-2 resource-empty" style="display:none; color: var(--text-tertiary); font-size: 0.85rem;">No matching items.</p>
|
||||
<div class="text-center py-2 border-top">
|
||||
<a href="{% url 'team_list' %}" class="small fw-semibold" style="color: var(--accent); text-decoration: none;">
|
||||
<i class="fas fa-arrow-right me-1"></i>Manage All Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -292,6 +334,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === CONFIRM DEACTIVATION MODAL (Bootstrap — works in all browsers unlike confirm()) === #}
|
||||
<div class="modal fade" id="confirmDeactivateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="fas fa-exclamation-triangle fa-2x mb-3" style="color: var(--color-warning);"></i>
|
||||
<p class="mb-1 fw-bold" id="deactivateTitle">Deactivate?</p>
|
||||
<p class="text-muted small mb-0">This will hide it from forms and dropdowns.</p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center border-0 pt-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="confirmDeactivateBtn">Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- ===================================================================
|
||||
SUPERVISOR VIEW — projects, teams, workers + activity
|
||||
@ -432,40 +491,117 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
applyFilter();
|
||||
|
||||
// === TOGGLE HANDLER — AJAX POST to activate/deactivate resources ===
|
||||
//
|
||||
// How this works — and why the old code had a bug:
|
||||
// ---------------------------------------------------
|
||||
// The authoritative state of a resource lives on the row's
|
||||
// `data-active` attribute (written by the server). The checkbox's
|
||||
// `checked` property is just a visual mirror that the browser
|
||||
// flips when the user clicks.
|
||||
//
|
||||
// We determine intent by comparing the row's CURRENT state
|
||||
// (data-active) against the user's CLICK (which flips it):
|
||||
// wasActive=false + click → intent is "activate" (no confirm)
|
||||
// wasActive=true + click → intent is "deactivate" (confirm first)
|
||||
//
|
||||
// The old code read `this.checked` directly, which works in the
|
||||
// happy path but got confused after a confirmed-deactivation
|
||||
// because the hidden.bs.modal handler was re-setting checked=true
|
||||
// whether the modal was cancelled OR confirmed. That desynced the
|
||||
// UI from the server, and subsequent clicks fired deactivate
|
||||
// modals on what looked to the user like "reactivation".
|
||||
//
|
||||
// Uses a Bootstrap modal for deactivation confirmation (native
|
||||
// confirm() is blocked by Chrome in some popup-blocker configs).
|
||||
var deactivateModal = new bootstrap.Modal(document.getElementById('confirmDeactivateModal'));
|
||||
var deactivateTitle = document.getElementById('deactivateTitle');
|
||||
var confirmBtn = document.getElementById('confirmDeactivateBtn');
|
||||
var pendingSwitch = null; // toggle awaiting confirmation
|
||||
var userConfirmed = false; // true if the user clicked "Deactivate" (not "Cancel")
|
||||
|
||||
// User confirmed deactivation — do the AJAX call
|
||||
confirmBtn.addEventListener('click', function() {
|
||||
userConfirmed = true;
|
||||
deactivateModal.hide();
|
||||
if (pendingSwitch) {
|
||||
doToggle(pendingSwitch, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Modal closed — either via Confirm, Cancel, Esc, or backdrop click.
|
||||
// Only revert the toggle visually if the user CANCELLED.
|
||||
document.getElementById('confirmDeactivateModal').addEventListener('hidden.bs.modal', function() {
|
||||
if (pendingSwitch && !userConfirmed) {
|
||||
// User cancelled — snap toggle back to checked (its pre-click state)
|
||||
pendingSwitch.checked = true;
|
||||
}
|
||||
pendingSwitch = null;
|
||||
userConfirmed = false;
|
||||
});
|
||||
|
||||
// Shared function that performs the actual AJAX toggle.
|
||||
// `wantsActive` is the desired NEW state (true = activate, false = deactivate).
|
||||
function doToggle(switchEl, wantsActive) {
|
||||
var type = switchEl.getAttribute('data-type');
|
||||
var id = switchEl.getAttribute('data-id');
|
||||
var row = switchEl.closest('.resource-row');
|
||||
|
||||
fetch('/toggle/' + type + '/' + id + '/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === 'success') {
|
||||
// Keep data-active and the checkbox visual in sync with the server
|
||||
row.dataset.active = wantsActive ? 'true' : 'false';
|
||||
switchEl.checked = wantsActive;
|
||||
applyFilter();
|
||||
} else {
|
||||
// Server rejected — revert the checkbox to its pre-click state
|
||||
switchEl.checked = !wantsActive;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
// Network error — revert the checkbox to its pre-click state
|
||||
switchEl.checked = !wantsActive;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.toggle-active').forEach(function(switchEl) {
|
||||
switchEl.addEventListener('change', function() {
|
||||
var type = this.getAttribute('data-type');
|
||||
var id = this.getAttribute('data-id');
|
||||
var isChecked = this.checked;
|
||||
var row = this.closest('.resource-row');
|
||||
// Use data-active (server truth) as the "before" state, not
|
||||
// this.checked (which has already been flipped by the browser).
|
||||
var wasActive = row.dataset.active === 'true';
|
||||
var wantsActive = !wasActive; // user's intent = flip it
|
||||
|
||||
fetch('/toggle/' + type + '/' + id + '/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === 'success') {
|
||||
row.dataset.active = isChecked ? 'true' : 'false';
|
||||
applyFilter();
|
||||
} else {
|
||||
switchEl.checked = !isChecked;
|
||||
alert('Error updating status.');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
switchEl.checked = !isChecked;
|
||||
alert('Error updating status.');
|
||||
});
|
||||
if (wantsActive) {
|
||||
// Reactivating — no confirmation needed
|
||||
doToggle(this, true);
|
||||
} else {
|
||||
// Deactivating — show Bootstrap confirmation modal
|
||||
var type = this.getAttribute('data-type');
|
||||
var name = row.querySelector('.fw-medium').textContent.trim();
|
||||
deactivateTitle.textContent = 'Deactivate ' + type + ' "' + name + '"?';
|
||||
pendingSwitch = this;
|
||||
userConfirmed = false;
|
||||
deactivateModal.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- === REPORT CONFIGURATION MODAL === -->
|
||||
<!-- Extracted to a shared partial so the report page can use the same
|
||||
modal without duplicating the HTML or the toggle script. -->
|
||||
{% include 'core/_report_config_modal.html' %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -23,27 +23,69 @@
|
||||
<div class="container py-4">
|
||||
|
||||
{# === PAGE HEADER === #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="page-title"><i class="fas fa-wallet me-2" style="color: var(--accent);"></i>Payroll Dashboard</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-info shadow-sm" id="workerLookupBtn">
|
||||
{# On desktop: title left, buttons right in a row #}
|
||||
{# On mobile: title on top, buttons below in a 2x2 grid #}
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<h1 class="page-title mb-0"><i class="fas fa-wallet me-2" style="color: var(--accent);"></i>Payroll Dashboard</h1>
|
||||
<div class="d-flex flex-wrap gap-2 payroll-actions">
|
||||
<button type="button" class="btn btn-outline-info shadow-sm btn-sm btn-md-normal" id="workerLookupBtn">
|
||||
<i class="fas fa-id-card fa-sm me-1"></i> Worker Lookup
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
|
||||
<button type="button" class="btn btn-primary shadow-sm btn-sm btn-md-normal" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
|
||||
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
|
||||
</button>
|
||||
<button type="button" class="btn btn-accent shadow-sm" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
|
||||
<button type="button" class="btn btn-outline-success shadow-sm btn-sm btn-md-normal fw-bold" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
|
||||
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning shadow-sm" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
|
||||
<button type="button" class="btn btn-outline-warning shadow-sm btn-sm btn-md-normal" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
|
||||
<i class="fas fa-clock fa-sm me-1"></i> Price Overtime
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === ANALYTICS CARDS === #}
|
||||
{# Left side: 3 single-value stat cards (2 on top + 1 below) #}
|
||||
{# Right side: Project breakdown card spanning full height — no scroll #}
|
||||
{# === ANALYTICS SUMMARY BAR — compact row of key numbers === #}
|
||||
{# Always visible. Clicking "Show Details" expands the full stat cards and charts below. #}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
{# Key numbers in a compact row #}
|
||||
<div class="d-flex flex-wrap gap-3 analytics-summary">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-exclamation-circle" style="color: var(--color-danger); font-size: 0.8rem;"></i>
|
||||
<div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Outstanding</div>
|
||||
<div class="fw-bold" style="font-size: 0.9rem;">R {{ outstanding_total|floatformat:2 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-check-circle" style="color: var(--color-success); font-size: 0.8rem;"></i>
|
||||
<div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Paid (60d)</div>
|
||||
<div class="fw-bold" style="font-size: 0.9rem;">R {{ recent_payments_total|floatformat:2 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-hand-holding-usd" style="color: var(--color-warning); font-size: 0.8rem;"></i>
|
||||
<div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Loans ({{ active_loans_count }})</div>
|
||||
<div class="fw-bold" style="font-size: 0.9rem;">R {{ active_loans_balance|floatformat:2 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# Toggle button to expand/collapse full analytics #}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="analyticsToggle" style="font-size: 0.75rem;">
|
||||
<i class="fas fa-chart-bar me-1"></i><span id="analyticsToggleText">Show Details</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === FULL ANALYTICS (hidden by default — toggled by button above) === #}
|
||||
<div id="analyticsDetail" style="display: none;">
|
||||
|
||||
{# --- Stat cards row --- #}
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
{# --- Left column: stat cards --- #}
|
||||
@ -149,7 +191,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# === CHARTS === #}
|
||||
{# --- Charts row --- #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||
<div class="card h-100">
|
||||
@ -204,6 +246,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{# /analyticsDetail #}
|
||||
|
||||
{# === TAB NAVIGATION === #}
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -258,14 +302,17 @@
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="pendingTable">
|
||||
{# On mobile: hide Days, Day Rate, Log Amount, Adjustments, Net Adj columns #}
|
||||
{# Only show: Worker (with badges), Total, Adjust + Pay buttons #}
|
||||
{# All details are accessible by tapping the worker name (opens lookup modal) #}
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="ps-4">Worker</th>
|
||||
<th scope="col">Days</th>
|
||||
<th scope="col">Day Rate</th>
|
||||
<th scope="col">Log Amount</th>
|
||||
<th scope="col">Adjustments</th>
|
||||
<th scope="col">Net Adj</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Days</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Day Rate</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Log Amount</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell">Adjustments</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Net Adj</th>
|
||||
<th scope="col" class="fw-bold">Total</th>
|
||||
<th scope="col" class="pe-4 text-end">Actions</th>
|
||||
</tr>
|
||||
@ -276,22 +323,32 @@
|
||||
data-overdue="{{ wd.is_overdue|yesno:'true,false' }}"
|
||||
data-has-loan="{{ wd.has_loan|yesno:'true,false' }}">
|
||||
<td class="ps-4 align-middle">
|
||||
<a href="#" class="worker-lookup-link fw-bold"
|
||||
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
|
||||
{% if wd.is_overdue %}
|
||||
<span class="badge bg-danger ms-1" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
|
||||
{% endif %}
|
||||
{% if wd.has_loan %}
|
||||
<span class="badge bg-warning ms-1" title="Has active loan or advance">Loan</span>
|
||||
<div>
|
||||
<a href="#" class="worker-lookup-link fw-bold"
|
||||
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
|
||||
</div>
|
||||
{% if wd.is_overdue or wd.has_loan %}
|
||||
<div class="mt-1 d-flex gap-1">
|
||||
{% if wd.is_overdue %}
|
||||
<span class="badge bg-danger" style="font-size: 0.6rem;" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
|
||||
{% endif %}
|
||||
{% if wd.has_loan %}
|
||||
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">{{ wd.unpaid_count }}</td>
|
||||
<td class="align-middle">R {{ wd.day_rate }}</td>
|
||||
<td class="align-middle">R {{ wd.unpaid_amount|floatformat:2 }}</td>
|
||||
<td class="align-middle">
|
||||
<td class="align-middle d-none d-md-table-cell">{{ wd.unpaid_count }}</td>
|
||||
<td class="align-middle d-none d-md-table-cell">R {{ wd.day_rate }}</td>
|
||||
<td class="align-middle d-none d-md-table-cell">R {{ wd.unpaid_amount|floatformat:2 }}</td>
|
||||
<td class="align-middle d-none d-lg-table-cell">
|
||||
{# Show each pending adjustment as a badge #}
|
||||
{% for adj in wd.adjustments %}
|
||||
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
|
||||
{# Badge colour logic: #}
|
||||
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
|
||||
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
|
||||
{# RED = deductions (Deduction) #}
|
||||
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
|
||||
style="cursor: pointer;"
|
||||
data-adj-id="{{ adj.id }}"
|
||||
data-adj-type="{{ adj.type }}"
|
||||
@ -309,7 +366,7 @@
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
|
||||
<td class="align-middle d-none d-md-table-cell {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
|
||||
{% if wd.adj_amount >= 0 %}+{% endif %}R {{ wd.adj_amount|floatformat:2 }}
|
||||
</td>
|
||||
<td class="align-middle fw-bold">R {{ wd.total_payable|floatformat:2 }}</td>
|
||||
@ -331,7 +388,7 @@
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-accent"
|
||||
title="Pay all pending items. Use Preview (eye icon) for selective payment.">
|
||||
<i class="fas fa-money-bill-wave me-1"></i> Pay
|
||||
<i class="fas fa-money-bill-wave me-1"></i><span class="d-none d-sm-inline"> Pay</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -360,29 +417,31 @@
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
{# On mobile: hide Date, Work Logs, Adjustments columns #}
|
||||
{# Only show: Worker, Amount Paid, View button #}
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="ps-4">Date</th>
|
||||
<th scope="col">Worker</th>
|
||||
<th scope="col" class="ps-4 d-none d-md-table-cell">Date</th>
|
||||
<th scope="col" class="ps-4 ps-md-0">Worker</th>
|
||||
<th scope="col">Amount Paid</th>
|
||||
<th scope="col">Work Logs</th>
|
||||
<th scope="col">Adjustments</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Work Logs</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell">Adjustments</th>
|
||||
<th scope="col" class="pe-4 text-end">Payslip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in paid_records %}
|
||||
<tr>
|
||||
<td class="ps-4 align-middle">{{ record.date }}</td>
|
||||
<td class="align-middle"><a href="#" class="worker-lookup-link fw-bold"
|
||||
<td class="ps-4 align-middle d-none d-md-table-cell">{{ record.date }}</td>
|
||||
<td class="align-middle ps-4 ps-md-0"><a href="#" class="worker-lookup-link fw-bold"
|
||||
data-worker-id="{{ record.worker.id }}">{{ record.worker.name }}</a></td>
|
||||
<td class="align-middle">R {{ record.amount_paid|floatformat:2 }}</td>
|
||||
<td class="align-middle">
|
||||
<td class="align-middle d-none d-md-table-cell">
|
||||
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<td class="align-middle d-none d-lg-table-cell">
|
||||
{% for adj in record.adjustments.all %}
|
||||
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} me-1">
|
||||
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} me-1">
|
||||
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
|
||||
</span>
|
||||
{% empty %}
|
||||
@ -391,7 +450,7 @@
|
||||
</td>
|
||||
<td class="pe-4 align-middle text-end">
|
||||
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-file-alt me-1"></i> View
|
||||
<i class="fas fa-file-alt me-1"></i><span class="d-none d-sm-inline"> View</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@ -428,15 +487,17 @@
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
{# On mobile: hide Principal, Date, Reason, Status columns #}
|
||||
{# Only show: Worker, Type, Balance #}
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" class="ps-4">Worker</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Principal</th>
|
||||
<th scope="col">Balance</th>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Reason</th>
|
||||
<th scope="col" class="pe-4">Status</th>
|
||||
<th scope="col" class="d-none d-md-table-cell">Date</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell">Reason</th>
|
||||
<th scope="col" class="pe-4 d-none d-md-table-cell">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -453,9 +514,9 @@
|
||||
</td>
|
||||
<td class="align-middle">R {{ loan.principal_amount|floatformat:2 }}</td>
|
||||
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
|
||||
<td class="align-middle">{{ loan.date }}</td>
|
||||
<td class="align-middle">{{ loan.reason|default:"-" }}</td>
|
||||
<td class="pe-4 align-middle">
|
||||
<td class="align-middle d-none d-md-table-cell">{{ loan.date }}</td>
|
||||
<td class="align-middle d-none d-lg-table-cell">{{ loan.reason|default:"-" }}</td>
|
||||
<td class="pe-4 align-middle d-none d-md-table-cell">
|
||||
{% if loan.active %}
|
||||
<span class="badge bg-warning text-dark">Active</span>
|
||||
{% else %}
|
||||
@ -2832,9 +2893,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
];
|
||||
|
||||
activities.forEach(function(act) {
|
||||
var row = el('div', 'd-flex justify-content-between align-items-center py-2 border-bottom');
|
||||
var row = el('div', 'd-flex justify-content-between align-items-center py-1 border-bottom');
|
||||
row.style.fontSize = '0.78rem';
|
||||
var left = el('div', '');
|
||||
var icon = el('i', act.icon + ' me-2 ' + act.color);
|
||||
icon.style.fontSize = '0.7rem';
|
||||
left.appendChild(icon);
|
||||
left.appendChild(document.createTextNode(act.label));
|
||||
row.appendChild(left);
|
||||
@ -2842,14 +2905,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (act.data) {
|
||||
var right = el('div', 'text-end');
|
||||
right.appendChild(el('span', 'fw-bold me-2', formatRand(act.data.amount)));
|
||||
right.appendChild(el('span', 'text-muted small', formatDate(act.data.date)));
|
||||
right.appendChild(el('span', 'text-muted', formatDate(act.data.date)));
|
||||
if (act.data.reason) {
|
||||
right.appendChild(document.createTextNode(' '));
|
||||
right.appendChild(el('span', 'text-muted small fst-italic', '(' + act.data.reason + ')'));
|
||||
right.appendChild(el('span', 'text-muted fst-italic', '(' + act.data.reason + ')'));
|
||||
}
|
||||
row.appendChild(right);
|
||||
} else {
|
||||
row.appendChild(el('span', 'text-muted small', 'None'));
|
||||
row.appendChild(el('span', 'text-muted', 'None'));
|
||||
}
|
||||
|
||||
actSection.appendChild(row);
|
||||
@ -2970,6 +3033,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// === ANALYTICS TOGGLE — show/hide the full stat cards and charts ===
|
||||
var analyticsToggle = document.getElementById('analyticsToggle');
|
||||
var analyticsDetail = document.getElementById('analyticsDetail');
|
||||
var analyticsToggleText = document.getElementById('analyticsToggleText');
|
||||
|
||||
if (analyticsToggle && analyticsDetail) {
|
||||
analyticsToggle.addEventListener('click', function() {
|
||||
var isHidden = analyticsDetail.style.display === 'none';
|
||||
analyticsDetail.style.display = isHidden ? '' : 'none';
|
||||
analyticsToggleText.textContent = isHidden ? 'Hide Details' : 'Show Details';
|
||||
// Remember preference
|
||||
localStorage.setItem('foxfitt-analytics', isHidden ? 'open' : 'closed');
|
||||
});
|
||||
|
||||
// Restore saved preference
|
||||
if (localStorage.getItem('foxfitt-analytics') === 'open') {
|
||||
analyticsDetail.style.display = '';
|
||||
analyticsToggleText.textContent = 'Hide Details';
|
||||
}
|
||||
}
|
||||
|
||||
}); // end DOMContentLoaded
|
||||
</script>
|
||||
|
||||
|
||||
623
core/templates/core/pdf/report_pdf.html
Normal file
623
core/templates/core/pdf/report_pdf.html
Normal file
@ -0,0 +1,623 @@
|
||||
{% load format_tags %}<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
/* ==========================================================
|
||||
PAGE SETUP
|
||||
========================================================== */
|
||||
@page {
|
||||
size: a4 portrait;
|
||||
margin: 2cm 1.8cm 1.6cm 1.8cm;
|
||||
@frame footer_frame {
|
||||
-pdf-frame-content: footerContent;
|
||||
bottom: 0.6cm;
|
||||
margin-left: 1.8cm;
|
||||
margin-right: 1.8cm;
|
||||
height: 0.8cm;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
TYPOGRAPHY
|
||||
========================================================== */
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.45;
|
||||
color: #334155;
|
||||
}
|
||||
p { margin: 3pt 0; }
|
||||
|
||||
/* ==========================================================
|
||||
COVER
|
||||
========================================================== */
|
||||
.brand-eyebrow {
|
||||
font-size: 7.5pt;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
letter-spacing: 3pt;
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
table.cover-band {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
}
|
||||
table.cover-band td {
|
||||
border-top: 1pt solid #10b981;
|
||||
border-bottom: 1pt solid #10b981;
|
||||
padding: 9pt 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.cover-band td.cover-title {
|
||||
font-size: 22pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
width: 60%;
|
||||
}
|
||||
table.cover-band td.cover-date {
|
||||
font-size: 11pt;
|
||||
color: #1e293b;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cover-filters {
|
||||
font-size: 10pt;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.3pt;
|
||||
margin: 4pt 0 14pt 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
SECTION STRUCTURE
|
||||
========================================================== */
|
||||
.section {
|
||||
margin-top: 16pt;
|
||||
}
|
||||
h2.section-title {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.break-before {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
letter-spacing: 2.5pt;
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
h2.section-title {
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
margin: 0 0 10pt 0;
|
||||
padding-bottom: 4pt;
|
||||
border-bottom: 0.5pt solid #cbd5e1;
|
||||
}
|
||||
h3.sub-title {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
letter-spacing: 1pt;
|
||||
margin: 8pt 0 3pt 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
HERO CARD — 50% SMALLER
|
||||
Halved the overall visual weight per feedback:
|
||||
• padding dropped from 9pt → 4pt top/bottom
|
||||
• hero-value dropped from 22pt → 14pt
|
||||
• label/caption scaled down in proportion
|
||||
Result: card is roughly half the height it was before.
|
||||
========================================================== */
|
||||
table.hero {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 4pt 0 14pt 0;
|
||||
}
|
||||
table.hero td {
|
||||
background-color: #f8fafc;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.hero td.hero-accent {
|
||||
background-color: #10b981;
|
||||
width: 3pt;
|
||||
padding: 0;
|
||||
}
|
||||
table.hero td.hero-body {
|
||||
padding: 4pt 14pt;
|
||||
}
|
||||
/* Hero spacing is dominated by line-height, not margin.
|
||||
line-height: 1 collapses the phantom "leading" above/below
|
||||
the value glyphs → ~50% tighter gaps around "R 64 939.00". */
|
||||
.hero-label {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 2pt;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.hero-value {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
margin: 1pt 0 0 0;
|
||||
}
|
||||
.hero-caption {
|
||||
font-size: 8pt;
|
||||
color: #64748b;
|
||||
line-height: 1.1;
|
||||
margin: 1pt 0 0 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
LEDGER LINES — with R-symbol aligned in its own column
|
||||
Splitting the value cell into two cells (rsym + rnum) means
|
||||
every "R" in a column appears at the same x-position, while
|
||||
the numbers right-align neatly on their own edge.
|
||||
========================================================== */
|
||||
table.ledger {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2pt 0 6pt 0;
|
||||
}
|
||||
table.ledger td {
|
||||
padding: 4pt 0 4pt 0;
|
||||
border-bottom: 0.4pt solid #e2e8f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.ledger td.rank {
|
||||
color: #94a3b8;
|
||||
font-size: 8pt;
|
||||
font-weight: bold;
|
||||
width: 16pt;
|
||||
padding-right: 4pt;
|
||||
}
|
||||
table.ledger td.lbl {
|
||||
color: #334155;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
table.ledger td.meta {
|
||||
color: #64748b;
|
||||
font-size: 8.5pt;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-right: 10pt;
|
||||
width: 55pt;
|
||||
}
|
||||
/* The two cells that together form a money value.
|
||||
rsym: left-aligned "R" anchored at a fixed x-position
|
||||
rnum: right-aligned number, bold black */
|
||||
table.ledger td.rsym {
|
||||
text-align: left;
|
||||
color: #0f172a;
|
||||
font-weight: bold;
|
||||
font-size: 10pt;
|
||||
width: 12pt;
|
||||
padding-left: 6pt;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.ledger td.rnum {
|
||||
text-align: right;
|
||||
color: #0f172a;
|
||||
font-weight: bold;
|
||||
font-size: 10pt;
|
||||
white-space: nowrap;
|
||||
width: 65pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
TWO-COLUMN LAYOUT
|
||||
========================================================== */
|
||||
table.cols {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
}
|
||||
table.cols td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
table.cols td.colL { width: 45%; }
|
||||
table.cols td.gap { width: 10%; }
|
||||
table.cols td.colR { width: 45%; }
|
||||
|
||||
/* Extra breathing room between the two rows of the Period
|
||||
Breakdown section (Labour Cost row ⇢ Payments/Adjustments row) */
|
||||
table.cols-spaced {
|
||||
margin-top: 18pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
PERIOD DETAIL — 15% smaller text in this section only
|
||||
Scoped via the .period-detail wrapper so other sections keep
|
||||
their normal size.
|
||||
========================================================== */
|
||||
.period-detail h3.sub-title {
|
||||
font-size: 8pt; /* was 9pt */
|
||||
}
|
||||
.period-detail table.ledger td.lbl {
|
||||
font-size: 8pt; /* was 9.5pt */
|
||||
}
|
||||
.period-detail table.ledger td.meta {
|
||||
font-size: 7.5pt; /* was 8.5pt */
|
||||
}
|
||||
.period-detail table.ledger td.rsym,
|
||||
.period-detail table.ledger td.rnum {
|
||||
font-size: 8.5pt; /* was 10pt */
|
||||
}
|
||||
/* Use split padding-top/bottom (NOT the shorthand) so horizontal
|
||||
padding defined on .meta and .rsym is preserved — otherwise the
|
||||
shorthand clobbers it and you get "130 daysR" with no gap. */
|
||||
.period-detail table.ledger td {
|
||||
padding-top: 3pt;
|
||||
padding-bottom: 3pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
WORKER BREAKDOWN TABLE
|
||||
Money values inside use a nested mini-table so R and number
|
||||
live in their own columns (same alignment trick as ledger).
|
||||
========================================================== */
|
||||
table.worker {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 4pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
table.worker th {
|
||||
text-align: left;
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.8pt;
|
||||
padding: 4pt 5pt 5pt 5pt;
|
||||
border-bottom: 1pt solid #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.worker th.r { text-align: right; }
|
||||
table.worker td {
|
||||
padding: 5pt;
|
||||
border-bottom: 0.4pt solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.worker td.name {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
}
|
||||
table.worker td.r {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Total Paid column: bolder, darker for emphasis */
|
||||
table.worker td.total {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Empty-value variant (em-dash) */
|
||||
table.worker td.dim {
|
||||
color: #cbd5e1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
MISC
|
||||
========================================================== */
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-size: 9pt;
|
||||
padding: 5pt 0;
|
||||
}
|
||||
#footerContent {
|
||||
font-size: 7pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5pt;
|
||||
border-top: 0.3pt solid #e2e8f0;
|
||||
padding-top: 4pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==============================================================
|
||||
COVER
|
||||
============================================================== -->
|
||||
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
|
||||
<table class="cover-band">
|
||||
<tr>
|
||||
<td class="cover-title">Payroll Report</td>
|
||||
<td class="cover-date">{{ start_date|date:"d F Y" }} – {{ end_date|date:"d F Y" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cover-filters">{{ project_name }} • {{ 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 — 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 }} — 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 — 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 — 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 {{ w.total_paid|money }}</td>
|
||||
{% for val in w.adj_values %}
|
||||
{% if val %}
|
||||
<td class="r">R {{ val|money }}</td>
|
||||
{% else %}
|
||||
<td class="dim">—</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" }} • FOXFITT CONSTRUCTION • CONFIDENTIAL
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
188
core/templates/core/pdf/workers_report_pdf.html
Normal file
188
core/templates/core/pdf/workers_report_pdf.html
Normal file
@ -0,0 +1,188 @@
|
||||
{% load format_tags %}<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
/* === PAGE SETUP (A4 landscape — many columns) === */
|
||||
@page {
|
||||
size: a4 landscape;
|
||||
margin: 1.5cm 1.5cm 1.2cm 1.5cm;
|
||||
@frame footer_frame {
|
||||
-pdf-frame-content: footerContent;
|
||||
bottom: 0.4cm;
|
||||
margin-left: 1.5cm;
|
||||
margin-right: 1.5cm;
|
||||
height: 0.7cm;
|
||||
}
|
||||
}
|
||||
|
||||
/* === TYPOGRAPHY === */
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.35;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* === COVER === */
|
||||
.brand-eyebrow {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
letter-spacing: 3pt;
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
table.cover-band {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
}
|
||||
table.cover-band td {
|
||||
border-top: 1pt solid #10b981;
|
||||
border-bottom: 1pt solid #10b981;
|
||||
padding: 7pt 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.cover-band td.cover-title {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
width: 60%;
|
||||
}
|
||||
table.cover-band td.cover-date {
|
||||
font-size: 10pt;
|
||||
color: #1e293b;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cover-filters {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.2pt;
|
||||
margin: 4pt 0 10pt 0;
|
||||
}
|
||||
|
||||
/* === MAIN TABLE === */
|
||||
table.report {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6pt;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
table.report th {
|
||||
text-align: left;
|
||||
font-size: 6.5pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.5pt;
|
||||
padding: 4pt 4pt 5pt 4pt;
|
||||
border-bottom: 1pt solid #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.report th.r { text-align: right; }
|
||||
table.report th.c { text-align: center; }
|
||||
table.report td {
|
||||
padding: 4pt;
|
||||
border-bottom: 0.4pt solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.report td.r {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.report td.c {
|
||||
text-align: center;
|
||||
}
|
||||
table.report td.name {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
}
|
||||
table.report td.total {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.report td.dim {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-size: 9pt;
|
||||
padding: 10pt 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#footerContent {
|
||||
font-size: 6.5pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5pt;
|
||||
border-top: 0.3pt solid #e2e8f0;
|
||||
padding-top: 3pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- === COVER === -->
|
||||
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
|
||||
<table class="cover-band">
|
||||
<tr>
|
||||
<td class="cover-title">Worker Roster Report</td>
|
||||
<td class="cover-date">{{ total_workers }} worker{{ total_workers|pluralize }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cover-filters">
|
||||
Status: {{ status|capfirst }}
|
||||
{% if project_name %} • {{ project_name }}{% endif %}
|
||||
{% if team_name %} • {{ 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 {{ r.worker.monthly_salary|money }}</td>
|
||||
<td class="c">{% if r.worker.active %}Yes{% else %}—{% 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 {{ 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 %}—{% 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" }} • FOXFITT CONSTRUCTION • CONFIDENTIAL
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
95
core/templates/core/projects/batch_report.html
Normal file
95
core/templates/core/projects/batch_report.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Project Batch Report | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Project Batch Report</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_projects }} project{{ total_projects|pluralize }} — lifetime aggregates
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'project_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-file-csv me-1"></i>Export CSV
|
||||
</a>
|
||||
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Projects
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All projects</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if rows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th>Supervisors</th>
|
||||
<th>Teams</th>
|
||||
<th class="text-end">Workers</th>
|
||||
<th class="text-end">Worker-Days</th>
|
||||
<th>First Activity</th>
|
||||
<th>Last Activity</th>
|
||||
<th class="text-end">Labour Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'project_detail' r.project.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.project.name }}</a>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if r.project.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
|
||||
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ r.supervisors|join:", "|default:"—" }}</td>
|
||||
<td style="max-width: 200px;">
|
||||
{% for t in r.teams %}<span class="badge me-1 mb-1" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6;">{{ t }}</span>{% endfor %}
|
||||
</td>
|
||||
<td class="text-end">{{ r.distinct_workers }}</td>
|
||||
<td class="text-end">{{ r.worker_days }}</td>
|
||||
<td>{{ r.first_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td>{{ r.last_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td class="text-end fw-semibold">R {{ r.total_labour_cost|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No projects match the filter. <a href="{% url 'project_batch_report' %}">Clear filters</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
184
core/templates/core/projects/detail.html
Normal file
184
core/templates/core/projects/detail.html
Normal file
@ -0,0 +1,184 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{{ project.name }} | Projects | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>{{ project.name }}
|
||||
{% if project.active %}<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
|
||||
{% else %}<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||
{{ project.supervisors.count }} supervisor{{ project.supervisors.count|pluralize }}
|
||||
{% if project.start_date or project.end_date %}
|
||||
| {{ project.start_date|date:"d M Y"|default:'?' }} → {{ project.end_date|date:"d M Y"|default:'ongoing' }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'project_edit' project.id %}" class="btn btn-accent shadow-sm"><i class="fas fa-pencil-alt me-1"></i>Edit</a>
|
||||
<a href="{% url 'project_list' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#supervisors" type="button">Supervisors <span class="badge bg-secondary ms-1">{{ project.supervisors.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#teams" type="button">Teams <span class="badge bg-secondary ms-1">{{ teams_worked.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers" type="button">Workers <span class="badge bg-secondary ms-1">{{ workers_worked.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<div class="tab-pane fade show active" id="profile">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0" style="font-size: 0.9rem;">
|
||||
<dt class="col-sm-3">Name</dt> <dd class="col-sm-9 fw-semibold">{{ project.name }}</dd>
|
||||
<dt class="col-sm-3">Description</dt> <dd class="col-sm-9" style="color: var(--text-secondary);">{{ project.description|default:'—'|linebreaksbr }}</dd>
|
||||
<dt class="col-sm-3">Start Date</dt> <dd class="col-sm-9">{{ project.start_date|date:"d M Y"|default:'—' }}</dd>
|
||||
<dt class="col-sm-3">End Date</dt> <dd class="col-sm-9">{{ project.end_date|date:"d M Y"|default:'—' }}</dd>
|
||||
<dt class="col-sm-3">Active</dt> <dd class="col-sm-9">{% if project.active %}<span class="text-success"><i class="fas fa-check-circle me-1"></i>Yes</span>{% else %}<span class="text-muted">No</span>{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="supervisors">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if project.supervisors.all %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Username</th><th>Full Name</th><th>Email</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in project.supervisors.all %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ s.username }}</td>
|
||||
<td>{{ s.get_full_name|default:'—' }}</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ s.email|default:'—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No supervisors assigned. <a href="{% url 'project_edit' project.id %}">Assign some</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="teams">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if teams_worked %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Team</th><th>Supervisor</th><th class="text-end">Workers</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in teams_worked %}
|
||||
<tr>
|
||||
<td class="fw-medium"><a href="{% url 'team_detail' t.id %}" style="color: var(--text-main); text-decoration: none;">{{ t.name }}</a></td>
|
||||
<td style="color: var(--text-secondary);">{{ t.supervisor.username|default:'—' }}</td>
|
||||
<td class="text-end">{{ t.workers.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No teams have logged work on this project.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="workers">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if workers_worked %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Worker</th><th>ID</th><th class="text-end">Salary</th></tr></thead>
|
||||
<tbody>
|
||||
{% for w in workers_worked %}
|
||||
<tr>
|
||||
<td class="fw-medium"><a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">{{ w.name }}</a></td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
|
||||
<td class="text-end">R {{ w.monthly_salary|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No workers have logged work on this project.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="history">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Activity Summary</h6></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0" style="font-size: 0.9rem;">
|
||||
<dt class="col-sm-7">Total Days Worked</dt> <dd class="col-sm-5 fw-semibold">{{ days_worked }}</dd>
|
||||
<dt class="col-sm-7">Total Labour Cost</dt> <dd class="col-sm-5 fw-semibold">R {{ total_labour_cost|money }}</dd>
|
||||
<dt class="col-sm-7">First Activity</dt> <dd class="col-sm-5">{{ first_activity|date:"d M Y"|default:'—' }}</dd>
|
||||
<dt class="col-sm-7">Last Activity</dt> <dd class="col-sm-5">{{ last_activity|date:"d M Y"|default:'—' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Labour Cost by Team</h6></div>
|
||||
<div class="card-body p-0">
|
||||
{% if cost_breakdown %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Team</th><th class="text-end">Days</th><th class="text-end">Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cost_breakdown %}
|
||||
<tr>
|
||||
<td>{{ c.team }}</td>
|
||||
<td class="text-end">{{ c.worker_days }}</td>
|
||||
<td class="text-end fw-semibold">R {{ c.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Recent Work Logs (last 10)</h6></div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_logs %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Date</th><th>Team</th><th class="text-end">Workers</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in recent_logs %}
|
||||
<tr>
|
||||
<td>{{ log.date|date:"d M Y" }}</td>
|
||||
<td>{{ log.team.name|default:'—' }}</td>
|
||||
<td class="text-end">{{ log.workers.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
104
core/templates/core/projects/edit.html
Normal file
104
core/templates/core/projects/edit.html
Normal file
@ -0,0 +1,104 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{% if is_new %}Add Project{% else %}Edit {{ project.name }}{% endif %} | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>
|
||||
{% if is_new %}Add Project{% else %}Edit {{ project.name }}{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{% if is_new %}All fields except Name are optional.
|
||||
{% else %}Update any section and Save.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% if project %}{% url 'project_detail' project.id %}{% else %}{% url 'project_list' %}{% endif %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Please fix the errors below.</strong>
|
||||
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-info-circle me-2" style="color: var(--accent);"></i>Project Basics</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Name *</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 align-self-end">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.active }}
|
||||
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Description</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-calendar-alt me-2" style="color: var(--accent);"></i>Timeline</h6></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Optional. Use to record when the project started and expected completion date.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Start Date</label>
|
||||
{{ form.start_date }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">End Date</label>
|
||||
{{ form.end_date }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-user-shield me-2" style="color: var(--accent);"></i>Supervisors</h6></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">Tick staff users responsible for this project. A project can have multiple supervisors.</p>
|
||||
<div style="max-height: 320px; overflow-y: auto; padding-right: 6px;">
|
||||
{% for choice in form.supervisors %}
|
||||
<div class="form-check">
|
||||
{{ choice.tag }}
|
||||
<label class="form-check-label" for="{{ choice.id_for_label }}">{{ choice.choice_label }}</label>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted small mb-0">No staff users available to assign. Create users in Django admin first.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<a href="{% if project %}{% url 'project_detail' project.id %}{% else %}{% url 'project_list' %}{% endif %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-accent btn-lg">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{% if is_new %}Create Project{% else %}Save Changes{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
core/templates/core/projects/list.html
Normal file
103
core/templates/core/projects/list.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Projects | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Projects</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_count }} project{{ total_count|pluralize }}
|
||||
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
|
||||
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'project_new' %}" class="btn btn-accent shadow-sm">
|
||||
<i class="fas fa-plus me-1"></i>Add Project
|
||||
</a>
|
||||
<a href="{% url 'project_batch_report' %}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-table me-1"></i>Batch Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" class="form-control" placeholder="Project name or description...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All projects</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-search me-1"></i>Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if projects %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Supervisors</th>
|
||||
<th class="text-end">Workers</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in projects %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'project_detail' p.id %}" style="color: var(--text-main); text-decoration: none;">{{ p.name }}</a>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{% for s in p.supervisors.all|slice:":2" %}{{ s.username }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% if p.supervisors.count > 2 %}<span class="text-muted"> +{{ p.supervisors.count|add:"-2" }}</span>{% endif %}
|
||||
{% if p.supervisors.count == 0 %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ p.workers_count }}</td>
|
||||
<td style="font-size: 0.85rem;">{{ p.start_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td style="font-size: 0.85rem;">{{ p.end_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if p.active %}<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
|
||||
{% else %}<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'project_detail' p.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="View details"><i class="fas fa-eye"></i></a>
|
||||
<a href="{% url 'project_edit' p.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Edit"><i class="fas fa-pencil-alt"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No projects{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
|
||||
{% if q or status != 'active' %}<br><a href="{% url 'project_list' %}">Clear filters</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
329
core/templates/core/report.html
Normal file
329
core/templates/core/report.html
Normal file
@ -0,0 +1,329 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Payroll Report | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === REPORT HEADER === -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 d-print-none">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Payroll Report</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ start_date|date:"d M Y" }} — {{ end_date|date:"d M Y" }}
|
||||
| {{ project_name }} | {{ 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 — Payroll Report</h2>
|
||||
<p class="text-center mb-0" style="font-size: 0.9rem;">
|
||||
{{ start_date|date:"d M Y" }} — {{ end_date|date:"d M Y" }}
|
||||
| {{ project_name }} | {{ 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 — 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 — 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 }} — 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 }} — 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" }} — {{ 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 %}
|
||||
95
core/templates/core/teams/batch_report.html
Normal file
95
core/templates/core/teams/batch_report.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Team Batch Report | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Team Batch Report</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_teams }} team{{ total_teams|pluralize }} — lifetime aggregates
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'team_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-file-csv me-1"></i>Export CSV
|
||||
</a>
|
||||
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All teams</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if rows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th>Supervisor</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th>Pay Schedule</th>
|
||||
<th class="text-end">Workers</th>
|
||||
<th class="text-end">Days</th>
|
||||
<th>Projects</th>
|
||||
<th class="text-end">Labour Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'team_detail' r.team.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.team.name }}</a>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">{{ r.team.supervisor.username|default:'—' }}</td>
|
||||
<td class="text-center">
|
||||
{% if r.team.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
|
||||
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{% if r.team.pay_frequency %}{{ r.team.get_pay_frequency_display }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ r.worker_count }}</td>
|
||||
<td class="text-end">{{ r.days_worked }}</td>
|
||||
<td style="max-width: 220px;">
|
||||
{% for p in r.projects %}<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p }}</span>{% endfor %}
|
||||
</td>
|
||||
<td class="text-end fw-semibold">R {{ r.total_labour_cost|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No teams match the filter. <a href="{% url 'team_batch_report' %}">Clear filters</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
175
core/templates/core/teams/detail.html
Normal file
175
core/templates/core/teams/detail.html
Normal file
@ -0,0 +1,175 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{{ team.name }} | Teams | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-users me-2" style="color: var(--accent);"></i>{{ team.name }}
|
||||
{% if team.active %}<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
|
||||
{% else %}<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||
Supervised by <strong>{{ team.supervisor.username|default:'— no supervisor —' }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'team_edit' team.id %}" class="btn btn-accent shadow-sm"><i class="fas fa-pencil-alt me-1"></i>Edit</a>
|
||||
<a href="{% url 'team_list' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#pay-schedule" type="button">Pay Schedule</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#workers" type="button">Workers <span class="badge bg-secondary ms-1">{{ workers.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<div class="tab-pane fade show active" id="profile">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0" style="font-size: 0.9rem;">
|
||||
<dt class="col-sm-3">Name</dt> <dd class="col-sm-9 fw-semibold">{{ team.name }}</dd>
|
||||
<dt class="col-sm-3">Supervisor</dt> <dd class="col-sm-9">{{ team.supervisor.username|default:'—' }}</dd>
|
||||
<dt class="col-sm-3">Pay Frequency</dt> <dd class="col-sm-9">{{ team.get_pay_frequency_display|default:'— not set —' }}</dd>
|
||||
<dt class="col-sm-3">Pay Start Date</dt> <dd class="col-sm-9">{{ team.pay_start_date|date:"d M Y"|default:'—' }}</dd>
|
||||
<dt class="col-sm-3">Active</dt> <dd class="col-sm-9">{% if team.active %}<span class="text-success"><i class="fas fa-check-circle me-1"></i>Yes</span>{% else %}<span class="text-muted">No</span>{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="pay-schedule">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if pay_periods %}
|
||||
<p class="text-muted small mb-3">Current + upcoming pay periods (based on the team's pay frequency and start date).</p>
|
||||
<table class="table table-sm mb-0" style="font-size: 0.9rem;">
|
||||
<thead><tr><th>#</th><th>Period Start</th><th>Period End</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in pay_periods %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{% if forloop.first %}Current{% else %}+{{ forloop.counter0 }}{% endif %}</td>
|
||||
<td>{{ p.0|date:"d M Y" }}</td>
|
||||
<td>{{ p.1|date:"d M Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No pay schedule set — assign a pay frequency and start date to see upcoming periods.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="workers">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if workers %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Name</th><th>ID</th><th class="text-end">Salary</th><th class="text-center">Active</th></tr></thead>
|
||||
<tbody>
|
||||
{% for w in workers %}
|
||||
<tr>
|
||||
<td class="fw-medium"><a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">{{ w.name }}</a></td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
|
||||
<td class="text-end">R {{ w.monthly_salary|money }}</td>
|
||||
<td class="text-center">
|
||||
{% if w.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
|
||||
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted text-center py-4 mb-0">No workers in this team. <a href="{% url 'team_edit' team.id %}">Add some</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="history">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Work Summary</h6></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0" style="font-size: 0.9rem;">
|
||||
<dt class="col-sm-7">Total Days Worked</dt> <dd class="col-sm-5 fw-semibold">{{ days_worked }}</dd>
|
||||
<dt class="col-sm-7">Total Labour Cost</dt> <dd class="col-sm-5 fw-semibold">R {{ total_labour_cost|money }}</dd>
|
||||
<dt class="col-sm-7">Projects Worked On</dt> <dd class="col-sm-5">{{ projects_worked.count }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Projects</h6></div>
|
||||
<div class="card-body">
|
||||
{% if projects_worked %}
|
||||
{% for p in projects_worked %}
|
||||
<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p.name }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">No projects yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Labour Cost Breakdown by Project</h6></div>
|
||||
<div class="card-body p-0">
|
||||
{% if cost_breakdown %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Project</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in cost_breakdown %}
|
||||
<tr>
|
||||
<td>{{ c.project }}</td>
|
||||
<td class="text-end">{{ c.worker_days }}</td>
|
||||
<td class="text-end fw-semibold">R {{ c.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold">Recent Work Logs (last 10)</h6></div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_logs %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Date</th><th>Project</th><th class="text-end">Workers</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in recent_logs %}
|
||||
<tr>
|
||||
<td>{{ log.date|date:"d M Y" }}</td>
|
||||
<td>{{ log.project.name }}</td>
|
||||
<td class="text-end">{{ log.workers.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No work logs yet.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
115
core/templates/core/teams/edit.html
Normal file
115
core/templates/core/teams/edit.html
Normal file
@ -0,0 +1,115 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{% if is_new %}Add Team{% else %}Edit {{ team.name }}{% endif %} | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-users me-2" style="color: var(--accent);"></i>
|
||||
{% if is_new %}Add Team{% else %}Edit {{ team.name }}{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{% if is_new %}Give the team a name; supervisor and workers are optional but recommended.
|
||||
{% else %}Update any section and Save.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% if team %}{% url 'team_detail' team.id %}{% else %}{% url 'team_list' %}{% endif %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Please fix the errors below.</strong>
|
||||
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-info-circle me-2" style="color: var(--accent);"></i>Team Basics</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Name *</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
Supervisor
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Staff user responsible for this team's daily work logs and payroll"></i>
|
||||
</label>
|
||||
{{ form.supervisor }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
{{ form.active }}
|
||||
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active (shown in forms and dropdowns)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-calendar-alt me-2" style="color: var(--accent);"></i>Pay Schedule</h6></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Optional. If set, payroll calculations use this schedule to determine pay periods. Leave both blank if this team doesn't have a fixed schedule.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Pay Frequency</label>
|
||||
{{ form.pay_frequency }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
Pay Start Date
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Anchor date — the FIRST day of the very first pay period. Future periods are calculated forward from this date. Never needs updating once set."></i>
|
||||
</label>
|
||||
{{ form.pay_start_date }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</h6></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">Tick workers to include in this team. Inactive workers are marked with a grey badge — you can still select them.</p>
|
||||
<div style="max-height: 400px; overflow-y: auto; padding-right: 6px;">
|
||||
{% for choice in form.workers %}
|
||||
<div class="form-check">
|
||||
{{ choice.tag }}
|
||||
<label class="form-check-label" for="{{ choice.id_for_label }}">
|
||||
{{ choice.choice_label }}
|
||||
{% with worker=choice.choice_value %}
|
||||
{# show an inactive badge next to inactive workers for visual scanning #}
|
||||
{% endwith %}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<a href="{% if team %}{% url 'team_detail' team.id %}{% else %}{% url 'team_list' %}{% endif %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-accent btn-lg">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{% if is_new %}Create Team{% else %}Save Changes{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
99
core/templates/core/teams/list.html
Normal file
99
core/templates/core/teams/list.html
Normal file
@ -0,0 +1,99 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Teams | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Teams</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_count }} team{{ total_count|pluralize }}
|
||||
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
|
||||
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'team_new' %}" class="btn btn-accent shadow-sm">
|
||||
<i class="fas fa-plus me-1"></i>Add Team
|
||||
</a>
|
||||
<a href="{% url 'team_batch_report' %}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-table me-1"></i>Batch Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" class="form-control" placeholder="Team name...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All teams</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-search me-1"></i>Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if teams %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Supervisor</th>
|
||||
<th class="text-end">Workers</th>
|
||||
<th>Pay Schedule</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in teams %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'team_detail' t.id %}" style="color: var(--text-main); text-decoration: none;">{{ t.name }}</a>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">{{ t.supervisor.username|default:'—' }}</td>
|
||||
<td class="text-end">{{ t.workers_count }}</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{% if t.pay_frequency %}{{ t.get_pay_frequency_display }}{% if t.pay_start_date %} from {{ t.pay_start_date|date:"d M Y" }}{% endif %}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if t.active %}<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
|
||||
{% else %}<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'team_detail' t.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="View details"><i class="fas fa-eye"></i></a>
|
||||
<a href="{% url 'team_edit' t.id %}" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Edit"><i class="fas fa-pencil-alt"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No teams{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
|
||||
{% if q or status != 'active' %}<br><a href="{% url 'team_list' %}">Clear filters</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
130
core/templates/core/workers/batch_report.html
Normal file
130
core/templates/core/workers/batch_report.html
Normal file
@ -0,0 +1,130 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Worker Batch Report | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === HEADER === -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Worker Batch Report</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_workers }} worker{{ total_workers|pluralize }} — lifetime aggregates
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'worker_batch_report_csv' %}?{{ query_string }}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-file-csv me-1"></i>Export CSV
|
||||
</a>
|
||||
<a href="{% url 'worker_batch_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
|
||||
<i class="fas fa-download me-1"></i>Download PDF
|
||||
</a>
|
||||
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Workers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === FILTER BAR === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Project <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="project" class="form-select">
|
||||
<option value="">All projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if project_id|stringformat:"s" == p.id|stringformat:"s" %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Team <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="team" class="form-select">
|
||||
<option value="">All teams</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}" {% if team_id|stringformat:"s" == t.id|stringformat:"s" %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-filter me-1"></i>Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === REPORT TABLE === -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if rows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" style="font-size: 0.85rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th class="text-end">Salary</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th class="text-end">Days</th>
|
||||
<th>Projects</th>
|
||||
<th>Teams</th>
|
||||
<th>First Payslip</th>
|
||||
<th>Last Payslip</th>
|
||||
<th class="text-end">Total Paid</th>
|
||||
<th class="text-center">Certs</th>
|
||||
<th class="text-center">Warnings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'worker_detail' r.worker.id %}" style="color: var(--text-main); text-decoration: none;">{{ r.worker.name }}</a>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">{{ r.worker.id_number }}</td>
|
||||
<td class="text-end">R {{ r.worker.monthly_salary|money }}</td>
|
||||
<td class="text-center">
|
||||
{% if r.worker.active %}<span class="text-success"><i class="fas fa-check-circle"></i></span>
|
||||
{% else %}<span class="text-muted"><i class="far fa-circle"></i></span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ r.days_worked }}</td>
|
||||
<td style="font-size: 0.8rem; max-width: 200px;">{% for p in r.projects %}<span class="badge me-1 mb-1" style="background: var(--accent-subtle); color: var(--accent-text);">{{ p }}</span>{% endfor %}</td>
|
||||
<td style="font-size: 0.8rem; max-width: 160px;">{% for t in r.teams %}<span class="badge me-1 mb-1" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6;">{{ t }}</span>{% endfor %}</td>
|
||||
<td>{{ r.first_payslip_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td>{{ r.last_payslip_date|date:"d M Y"|default:'—' }}</td>
|
||||
<td class="text-end fw-semibold">R {{ r.total_paid_lifetime|money }}</td>
|
||||
<td class="text-center">
|
||||
<span title="Active / Total">{{ r.certs_active }}/{{ r.certs_total }}</span>
|
||||
{% if r.certs_expiring %}<br><small style="color: #f59e0b;"><i class="fas fa-clock"></i> {{ r.certs_expiring }} expiring</small>{% endif %}
|
||||
{% if r.certs_expired %}<br><small style="color: #ef4444;"><i class="fas fa-exclamation-circle"></i> {{ r.certs_expired }} expired</small>{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if r.warnings_count %}<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">{{ r.warnings_count }}</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No workers match the filter. <a href="{% url 'worker_batch_report' %}">Clear filters</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
333
core/templates/core/workers/detail.html
Normal file
333
core/templates/core/workers/detail.html
Normal file
@ -0,0 +1,333 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{{ worker.name }} | Workers | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === HEADER === -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
{% if worker.photo %}
|
||||
<img src="{{ worker.photo.url }}" alt="{{ worker.name }}" class="me-3"
|
||||
style="width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid var(--accent);">
|
||||
{% else %}
|
||||
<div class="me-3 d-flex align-items-center justify-content-center"
|
||||
style="width: 64px; height: 64px; border-radius: 50%; background: var(--accent-subtle); color: var(--accent); font-size: 1.5rem; font-weight: bold;">
|
||||
{{ worker.name|make_list|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="page-title mb-0">
|
||||
{{ worker.name }}
|
||||
{% if worker.active %}
|
||||
<span class="badge ms-2" style="background: rgba(16, 185, 129, 0.15); color: #10b981; font-size: 0.6em; vertical-align: middle;">Active</span>
|
||||
{% else %}
|
||||
<span class="badge ms-2" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); font-size: 0.6em; vertical-align: middle;">Inactive</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||
{{ worker.id_number }}
|
||||
{% if worker.phone_number %} | <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 %}
|
||||
490
core/templates/core/workers/edit.html
Normal file
490
core/templates/core/workers/edit.html
Normal file
@ -0,0 +1,490 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}{% if is_new %}Add Worker{% else %}Edit {{ worker.name }}{% endif %} | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === HEADER === -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
<i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>
|
||||
{% if is_new %}Add Worker{% else %}Edit {{ worker.name }}{% endif %}
|
||||
</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{% if is_new %}Fill in the sections below. All fields except Name, ID Number, and Monthly Salary are optional.
|
||||
{% else %}Update any section. File uploads max 5 MB each.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
{% if not is_new %}
|
||||
<a href="{% url 'worker_detail' worker.id %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.errors or cert_formset.errors or warn_formset.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Please fix the errors below.</strong>
|
||||
{% if form.non_field_errors %}<div>{{ form.non_field_errors }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- === SECTION 1: PERSONAL & PAY === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-user me-2" style="color: var(--accent);"></i>Personal & Pay</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Name *</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}<div class="invalid-feedback d-block">{{ form.name.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">ID Number *</label>
|
||||
{{ form.id_number }}
|
||||
{% if form.id_number.errors %}<div class="invalid-feedback d-block">{{ form.id_number.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Phone</label>
|
||||
{{ form.phone_number }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Monthly Salary (R) *</label>
|
||||
{{ form.monthly_salary }}
|
||||
{% if form.monthly_salary.errors %}<div class="invalid-feedback d-block">{{ form.monthly_salary.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Employment Date</label>
|
||||
{{ form.employment_date }}
|
||||
</div>
|
||||
|
||||
<!-- === BANKING & TAX === -->
|
||||
<!-- Each label carries a small info-icon with a Bootstrap tooltip
|
||||
explaining what the field is for. Uses the global tooltip
|
||||
init from base.html. -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">
|
||||
Tax No
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Registered Tax Number"></i>
|
||||
</label>
|
||||
{{ form.tax_number }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">
|
||||
UIF
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Unemployment Insurance Fund number"></i>
|
||||
</label>
|
||||
{{ form.uif_number }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">
|
||||
Bank
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Account at which Institution"></i>
|
||||
</label>
|
||||
{{ form.bank_name }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">
|
||||
Acc No.
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Bank account number"></i>
|
||||
</label>
|
||||
{{ form.bank_account_number }}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
{{ form.active }}
|
||||
<label class="form-check-label fw-semibold" for="{{ form.active.id_for_label }}">Active (shown in forms and dropdowns)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SECTION 2: PPE SIZING === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-tshirt me-2" style="color: var(--accent);"></i>PPE Sizing</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3"><label class="form-label fw-semibold">Shoe</label> {{ form.shoe_size }}</div>
|
||||
<div class="col-md-3"><label class="form-label fw-semibold">Overall Top</label>{{ form.overall_top_size }}</div>
|
||||
<div class="col-md-3"><label class="form-label fw-semibold">Pants</label> {{ form.pants_size }}</div>
|
||||
<div class="col-md-3"><label class="form-label fw-semibold">T-Shirt</label> {{ form.tshirt_size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SECTION 3: DOCUMENTS === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Documents <span class="text-muted fw-normal small ms-2">5 MB max per file</span></h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Photo</label>
|
||||
{{ form.photo }}
|
||||
{% if form.photo.errors %}<div class="invalid-feedback d-block">{{ form.photo.errors|first }}</div>{% endif %}
|
||||
{% if worker.photo %}<small class="d-block mt-1"><a href="{{ worker.photo.url }}" target="_blank">View current</a></small>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">ID Document</label>
|
||||
{{ form.id_document }}
|
||||
{% if form.id_document.errors %}<div class="invalid-feedback d-block">{{ form.id_document.errors|first }}</div>{% endif %}
|
||||
{% if worker.id_document %}<small class="d-block mt-1"><a href="{{ worker.id_document.url }}" target="_blank">View current</a></small>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SECTION 4: DRIVER'S LICENSE === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h6 class="m-0 fw-bold"><i class="fas fa-id-card me-2" style="color: var(--accent);"></i>Driver's License</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch pt-2">
|
||||
{{ form.has_drivers_license }}
|
||||
<label class="form-check-label fw-semibold" for="{{ form.has_drivers_license.id_for_label }}">Has driver's license</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">
|
||||
Code
|
||||
<i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.8em;"
|
||||
data-bs-toggle="tooltip" title="Drivers License Code (e.g. A, B, C, EB, EC)"></i>
|
||||
</label>
|
||||
{{ form.drivers_license_code }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">License File</label>
|
||||
{{ form.drivers_license }}
|
||||
{% if form.drivers_license.errors %}<div class="invalid-feedback d-block">{{ form.drivers_license.errors|first }}</div>{% endif %}
|
||||
{% if worker.drivers_license %}<small class="d-block mt-1"><a href="{{ worker.drivers_license.url }}" target="_blank">View current</a></small>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SECTION 5: CERTIFICATIONS === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-certificate me-2" style="color: var(--accent);"></i>Certifications</h6>
|
||||
<button type="button" class="btn btn-sm btn-accent" id="addCertBtn"><i class="fas fa-plus me-1"></i>Add Certification</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ cert_formset.management_form }}
|
||||
<div id="certRows">
|
||||
{% for cf in cert_formset %}
|
||||
<div class="cert-row formset-row border rounded p-3 mb-2 {% if cf.DELETE.value %}row-marked-delete{% endif %}" data-index="{{ forloop.counter0 }}">
|
||||
{{ cf.id }}
|
||||
<!-- Hidden DELETE flag — toggled by the trash button below -->
|
||||
<input type="checkbox" class="row-delete-flag d-none" name="{{ cf.DELETE.html_name }}" {% if cf.DELETE.value %}checked{% endif %}>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1 fw-semibold">Type</label>
|
||||
{{ cf.cert_type }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1 fw-semibold">Issued</label>
|
||||
{{ cf.issued_date }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1 fw-semibold">Valid Until</label>
|
||||
{{ cf.valid_until }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1 fw-semibold">Document</label>
|
||||
{{ cf.document }}
|
||||
{% if cf.instance.document %}<small class="d-block mt-1"><a href="{{ cf.instance.document.url }}" target="_blank">Current</a></small>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
|
||||
data-bs-toggle="tooltip" title="Remove this certification when you save">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<a href="#" class="row-undo-btn small text-decoration-none d-none"
|
||||
data-bs-toggle="tooltip" title="Keep this certification — undo removal">
|
||||
<i class="fas fa-undo me-1"></i>Undo
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small mb-1 fw-semibold">Notes</label>
|
||||
{{ cf.notes }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-muted small mb-0 mt-2">Types: Skills, PDP (Professional Driving Permit), First Aid, Medical, Work at Height.</p>
|
||||
|
||||
<!-- Hidden blank template. JS clones the <template>.content node (a
|
||||
safe DocumentFragment — no innerHTML) and rewrites its name
|
||||
attributes to replace __PREFIX__ with the new index. -->
|
||||
<template id="certBlankTemplate">
|
||||
<div class="cert-row formset-row border rounded p-3 mb-2" data-index="__PREFIX__">
|
||||
<input type="checkbox" class="row-delete-flag d-none" name="certificates-__PREFIX__-DELETE">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1 fw-semibold">Type</label>
|
||||
<select name="certificates-__PREFIX__-cert_type" class="form-select form-select-sm">
|
||||
<option value="skills">Skills Certificate</option>
|
||||
<option value="pdp">PDP (Professional Driving Permit)</option>
|
||||
<option value="first_aid">First Aid</option>
|
||||
<option value="medical">Medical</option>
|
||||
<option value="work_at_height">Work at Height</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Issued</label>
|
||||
<input type="date" name="certificates-__PREFIX__-issued_date" class="form-control form-control-sm"></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Valid Until</label>
|
||||
<input type="date" name="certificates-__PREFIX__-valid_until" class="form-control form-control-sm"></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-1 fw-semibold">Document</label>
|
||||
<input type="file" name="certificates-__PREFIX__-document" class="form-control form-control-sm"></div>
|
||||
<div class="col-md-1 text-end">
|
||||
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
|
||||
data-bs-toggle="tooltip" title="Remove this certification when you save">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<a href="#" class="row-undo-btn small text-decoration-none d-none"
|
||||
data-bs-toggle="tooltip" title="Keep this certification — undo removal">
|
||||
<i class="fas fa-undo me-1"></i>Undo
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12"><label class="form-label small mb-1 fw-semibold">Notes</label>
|
||||
<textarea name="certificates-__PREFIX__-notes" rows="2" class="form-control form-control-sm"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SECTION 6: WARNINGS === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-exclamation-triangle me-2" style="color: var(--color-warning);"></i>Warnings & Disciplinary</h6>
|
||||
<button type="button" class="btn btn-sm btn-accent" id="addWarnBtn"><i class="fas fa-plus me-1"></i>Add Warning</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ warn_formset.management_form }}
|
||||
<div id="warnRows">
|
||||
{% for wf in warn_formset %}
|
||||
<div class="warn-row formset-row border rounded p-3 mb-2 {% if wf.DELETE.value %}row-marked-delete{% endif %}" data-index="{{ forloop.counter0 }}">
|
||||
{{ wf.id }}
|
||||
<input type="checkbox" class="row-delete-flag d-none" name="{{ wf.DELETE.html_name }}" {% if wf.DELETE.value %}checked{% endif %}>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1 fw-semibold">Date</label>
|
||||
{{ wf.date }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1 fw-semibold">Severity</label>
|
||||
{{ wf.severity }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1 fw-semibold">Reason</label>
|
||||
{{ wf.reason }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1 fw-semibold">Document</label>
|
||||
{{ wf.document }}
|
||||
{% if wf.instance.document %}<small class="d-block mt-1"><a href="{{ wf.instance.document.url }}" target="_blank">Current</a></small>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
|
||||
data-bs-toggle="tooltip" title="Remove this warning when you save">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<a href="#" class="row-undo-btn small text-decoration-none d-none"
|
||||
data-bs-toggle="tooltip" title="Keep this warning — undo removal">
|
||||
<i class="fas fa-undo me-1"></i>Undo
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small mb-1 fw-semibold">Description</label>
|
||||
{{ wf.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<template id="warnBlankTemplate">
|
||||
<div class="warn-row formset-row border rounded p-3 mb-2" data-index="__PREFIX__">
|
||||
<input type="checkbox" class="row-delete-flag d-none" name="warnings-__PREFIX__-DELETE">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Date</label>
|
||||
<input type="date" name="warnings-__PREFIX__-date" class="form-control form-control-sm"></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-1 fw-semibold">Severity</label>
|
||||
<select name="warnings-__PREFIX__-severity" class="form-select form-select-sm">
|
||||
<option value="verbal">Verbal</option>
|
||||
<option value="written">Written</option>
|
||||
<option value="final">Final</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4"><label class="form-label small mb-1 fw-semibold">Reason</label>
|
||||
<input type="text" name="warnings-__PREFIX__-reason" class="form-control form-control-sm" placeholder="Short summary"></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-1 fw-semibold">Document</label>
|
||||
<input type="file" name="warnings-__PREFIX__-document" class="form-control form-control-sm"></div>
|
||||
<div class="col-md-1 text-end">
|
||||
<label class="form-label small mb-1 d-block" style="color: transparent;">.</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger row-remove-btn"
|
||||
data-bs-toggle="tooltip" title="Remove this warning when you save">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<a href="#" class="row-undo-btn small text-decoration-none d-none"
|
||||
data-bs-toggle="tooltip" title="Keep this warning — undo removal">
|
||||
<i class="fas fa-undo me-1"></i>Undo
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12"><label class="form-label small mb-1 fw-semibold">Description</label>
|
||||
<textarea name="warnings-__PREFIX__-description" rows="2" class="form-control form-control-sm" placeholder="Full context..."></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SUBMIT === -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
{% if not is_new %}
|
||||
<a href="{% url 'worker_detail' worker.id %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
{% else %}
|
||||
<a href="{% url 'worker_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-accent btn-lg">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{% if is_new %}Create Worker{% else %}Save Changes{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- === FORMSET INTERACTION JAVASCRIPT (DOM-safe) ===
|
||||
Handles three concerns:
|
||||
1. "+ Add Certification / Warning" — clones a <template> safely via
|
||||
content.cloneNode (no innerHTML), rewrites __PREFIX__ in name
|
||||
attributes to the new formset index, and re-inits tooltips on the
|
||||
newly-added row.
|
||||
2. Trash button — marks a row for deletion by ticking the hidden
|
||||
DELETE checkbox and adding .row-marked-delete. Swaps the trash
|
||||
button out for an Undo link.
|
||||
3. Undo link — reverses the above.
|
||||
Uses event delegation on the row containers so new rows work without
|
||||
extra wiring. -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// --- "+ Add" button: clone the blank template and append ---
|
||||
function wireAdd(formsetPrefix, containerId, templateId, btnId) {
|
||||
var btn = document.getElementById(btnId);
|
||||
var template = document.getElementById(templateId);
|
||||
var container = document.getElementById(containerId);
|
||||
var totalForms = document.querySelector('input[name="' + formsetPrefix + '-TOTAL_FORMS"]');
|
||||
if (!btn || !template || !container || !totalForms) return;
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
var current = parseInt(totalForms.value, 10);
|
||||
var fragment = template.content.cloneNode(true);
|
||||
|
||||
// Replace __PREFIX__ in every `name` attribute
|
||||
fragment.querySelectorAll('[name]').forEach(function(el) {
|
||||
if (el.name && el.name.indexOf('__PREFIX__') !== -1) {
|
||||
el.name = el.name.split('__PREFIX__').join(current);
|
||||
}
|
||||
});
|
||||
var outer = fragment.querySelector('[data-index]');
|
||||
if (outer) outer.setAttribute('data-index', current);
|
||||
|
||||
// Append, then init tooltips on the new row (so the trash button
|
||||
// tooltip works immediately without a page reload).
|
||||
container.appendChild(fragment);
|
||||
if (window.initTooltipsIn) {
|
||||
// Re-query the last added row for tooltip init
|
||||
var rows = container.querySelectorAll('.formset-row');
|
||||
var newRow = rows[rows.length - 1];
|
||||
if (newRow) window.initTooltipsIn(newRow);
|
||||
}
|
||||
|
||||
totalForms.value = current + 1;
|
||||
});
|
||||
}
|
||||
|
||||
wireAdd('certificates', 'certRows', 'certBlankTemplate', 'addCertBtn');
|
||||
wireAdd('warnings', 'warnRows', 'warnBlankTemplate', 'addWarnBtn');
|
||||
|
||||
// --- Trash + Undo: mark/unmark a row for deletion ---
|
||||
// Event delegation on each container — works for both existing rows
|
||||
// (rendered server-side) and dynamically added rows.
|
||||
function markForDelete(row) {
|
||||
row.classList.add('row-marked-delete');
|
||||
var flag = row.querySelector('.row-delete-flag');
|
||||
if (flag) flag.checked = true;
|
||||
// Hide the remove button, show the undo link (Bootstrap utility)
|
||||
var removeBtn = row.querySelector('.row-remove-btn');
|
||||
var undoLink = row.querySelector('.row-undo-btn');
|
||||
if (removeBtn) removeBtn.classList.add('d-none');
|
||||
if (undoLink) undoLink.classList.remove('d-none');
|
||||
// Close any hovered tooltip on the remove button to clean up the UI
|
||||
if (removeBtn && bootstrap.Tooltip.getInstance(removeBtn)) {
|
||||
bootstrap.Tooltip.getInstance(removeBtn).hide();
|
||||
}
|
||||
}
|
||||
|
||||
function undoDelete(row) {
|
||||
row.classList.remove('row-marked-delete');
|
||||
var flag = row.querySelector('.row-delete-flag');
|
||||
if (flag) flag.checked = false;
|
||||
var removeBtn = row.querySelector('.row-remove-btn');
|
||||
var undoLink = row.querySelector('.row-undo-btn');
|
||||
if (removeBtn) removeBtn.classList.remove('d-none');
|
||||
if (undoLink) undoLink.classList.add('d-none');
|
||||
if (undoLink && bootstrap.Tooltip.getInstance(undoLink)) {
|
||||
bootstrap.Tooltip.getInstance(undoLink).hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate on both row containers
|
||||
['certRows', 'warnRows'].forEach(function(containerId) {
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.addEventListener('click', function(ev) {
|
||||
var removeBtn = ev.target.closest('.row-remove-btn');
|
||||
var undoLink = ev.target.closest('.row-undo-btn');
|
||||
if (removeBtn) {
|
||||
ev.preventDefault();
|
||||
var row = removeBtn.closest('.formset-row');
|
||||
if (row) markForDelete(row);
|
||||
} else if (undoLink) {
|
||||
ev.preventDefault();
|
||||
var row = undoLink.closest('.formset-row');
|
||||
if (row) undoDelete(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
117
core/templates/core/workers/list.html
Normal file
117
core/templates/core/workers/list.html
Normal file
@ -0,0 +1,117 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load format_tags %}
|
||||
|
||||
{% block title %}Workers | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
|
||||
<!-- === HEADER === -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4">
|
||||
<div>
|
||||
<h1 class="page-title"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</h1>
|
||||
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ total_count }} worker{{ total_count|pluralize }}
|
||||
{% if q %} matching "<strong>{{ q }}</strong>"{% endif %}
|
||||
{% if status != 'all' %} — {{ status|capfirst }} only{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<a href="{% url 'worker_new' %}" class="btn btn-accent shadow-sm">
|
||||
<i class="fas fa-plus me-1"></i>Add Worker
|
||||
</a>
|
||||
<a href="{% url 'worker_batch_report' %}" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-table me-1"></i>Batch Report
|
||||
</a>
|
||||
<a href="{% url 'export_workers_csv' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-file-csv me-1"></i>Export CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === SEARCH + FILTER BAR === -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" class="form-control"
|
||||
placeholder="Name, ID number, or phone...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive only</option>
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-search me-1"></i>Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === WORKER TABLE === -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if workers %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID Number</th>
|
||||
<th>Phone</th>
|
||||
<th class="text-end">Salary</th>
|
||||
<th class="text-end">Days Worked</th>
|
||||
<th class="text-center">Active</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for w in workers %}
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'worker_detail' w.id %}" style="color: var(--text-main); text-decoration: none;">
|
||||
{{ w.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.id_number }}</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ w.phone_number|default:'—' }}</td>
|
||||
<td class="text-end fw-semibold">R {{ w.monthly_salary|money }}</td>
|
||||
<td class="text-end">{{ w.days_worked }}</td>
|
||||
<td class="text-center">
|
||||
{% if w.active %}
|
||||
<span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">Active</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background: rgba(148, 163, 184, 0.15); color: var(--text-secondary);">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'worker_detail' w.id %}" class="btn btn-sm btn-outline-secondary" title="View details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'worker_edit' w.id %}" class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
|
||||
No workers{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
|
||||
{% if q or status != 'active' %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
core/templatetags/__init__.py
Normal file
0
core/templatetags/__init__.py
Normal file
27
core/templatetags/format_tags.py
Normal file
27
core/templatetags/format_tags.py
Normal file
@ -0,0 +1,27 @@
|
||||
# === CUSTOM TEMPLATE FILTERS ===
|
||||
# Number formatting filters for South African currency display.
|
||||
# Usage: {% load format_tags %} then {{ value|money }}
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def money(value):
|
||||
"""Format a number with space thousands separators and 2 decimal places.
|
||||
|
||||
South African convention uses spaces instead of commas:
|
||||
8000 → 8 000.00
|
||||
22500 → 22 500.00
|
||||
400.0 → 400.00
|
||||
-300.00 → -300.00
|
||||
"""
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
|
||||
# Python's :, format gives comma separators — swap commas for spaces
|
||||
formatted = f"{num:,.2f}"
|
||||
return formatted.replace(",", " ")
|
||||
41
core/urls.py
41
core/urls.py
@ -59,6 +59,40 @@ urlpatterns = [
|
||||
# View a completed payslip (print-friendly page)
|
||||
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
|
||||
|
||||
# === REPORTS ===
|
||||
# Generate payroll reports filtered by date range, project, or team
|
||||
path('report/', views.generate_report, name='generate_report'),
|
||||
path('report/pdf/', views.generate_report_pdf, name='generate_report_pdf'),
|
||||
|
||||
# === WORKERS ===
|
||||
# Admin-friendly worker management UI (alternative to /admin/core/worker/)
|
||||
path('workers/', views.worker_list, name='worker_list'),
|
||||
path('workers/new/', views.worker_edit, name='worker_new'),
|
||||
path('workers/<int:worker_id>/', views.worker_detail, name='worker_detail'),
|
||||
path('workers/<int:worker_id>/edit/', views.worker_edit, name='worker_edit'),
|
||||
# Batch report (table of all workers with aggregated history)
|
||||
path('workers/report/', views.worker_batch_report, name='worker_batch_report'),
|
||||
path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'),
|
||||
path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'),
|
||||
|
||||
# === TEAMS ===
|
||||
# Admin-friendly team management UI (alternative to /admin/core/team/)
|
||||
path('teams/', views.team_list, name='team_list'),
|
||||
path('teams/new/', views.team_edit, name='team_new'),
|
||||
path('teams/report/', views.team_batch_report, name='team_batch_report'),
|
||||
path('teams/report/csv/', views.team_batch_report_csv, name='team_batch_report_csv'),
|
||||
path('teams/<int:team_id>/', views.team_detail, name='team_detail'),
|
||||
path('teams/<int:team_id>/edit/', views.team_edit, name='team_edit'),
|
||||
|
||||
# === PROJECTS ===
|
||||
# Admin-friendly project management UI (alternative to /admin/core/project/)
|
||||
path('projects/', views.project_list, name='project_list'),
|
||||
path('projects/new/', views.project_edit, name='project_new'),
|
||||
path('projects/report/', views.project_batch_report, name='project_batch_report'),
|
||||
path('projects/report/csv/', views.project_batch_report_csv, name='project_batch_report_csv'),
|
||||
path('projects/<int:project_id>/', views.project_detail, name='project_detail'),
|
||||
path('projects/<int:project_id>/edit/', views.project_edit, name='project_edit'),
|
||||
|
||||
# === EXPENSE RECEIPTS ===
|
||||
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
|
||||
path('receipts/create/', views.create_receipt, name='create_receipt'),
|
||||
@ -70,4 +104,11 @@ urlpatterns = [
|
||||
# === TEMPORARY: Run migrations from browser ===
|
||||
# Visit /run-migrate/ to apply pending database migrations on production.
|
||||
path('run-migrate/', views.run_migrate, name='run_migrate'),
|
||||
|
||||
# === BACKUP / RESTORE (admin-only, browser-accessible) ===
|
||||
# Flatlogic has no SSH/shell — admins use these to snapshot and
|
||||
# restore all app data via the browser. See CLAUDE.md "Backup &
|
||||
# Restore" section for the full procedure.
|
||||
path('backup-data/', views.backup_data, name='backup_data'),
|
||||
path('restore-data/', views.restore_data, name='restore_data'),
|
||||
]
|
||||
|
||||
128
core/utils.py
128
core/utils.py
@ -1,26 +1,94 @@
|
||||
# === PDF GENERATION ===
|
||||
# Converts a Django HTML template into a PDF file using xhtml2pdf.
|
||||
# Used for payslip and receipt PDF attachments sent via email.
|
||||
# Converts a Django HTML template into a PDF file using WeasyPrint.
|
||||
# Used for payslip, receipt, and payroll report PDFs (both email and
|
||||
# browser download).
|
||||
#
|
||||
# IMPORTANT: xhtml2pdf is imported LAZILY (inside the function, not at the
|
||||
# top of the file). This is intentional — if xhtml2pdf fails to install on
|
||||
# the server (missing C libraries), the rest of the app still works.
|
||||
# Only PDF generation will fail gracefully.
|
||||
# Why WeasyPrint?
|
||||
# ----------------
|
||||
# Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade
|
||||
# HTML-to-PDF renderer — it supports modern CSS features that xhtml2pdf
|
||||
# could not: flexbox, grid, @font-face (custom web fonts), box-shadow,
|
||||
# border-radius, transforms, and proper CSS cascade handling.
|
||||
#
|
||||
# WeasyPrint has system dependencies (Pango, Cairo, GDK-PixBuf) that
|
||||
# xhtml2pdf did not need. On Flatlogic's Linux environment these are
|
||||
# already installed system-wide — no extra setup. On Windows dev
|
||||
# machines, we install the GTK3 runtime (via `winget install
|
||||
# tschoonj.GTKForWindows`) and then the `_ensure_gtk_on_windows()`
|
||||
# helper below tells Python where to find the DLLs.
|
||||
#
|
||||
# Why the Windows DLL dance?
|
||||
# --------------------------
|
||||
# Since Python 3.8, `PATH` alone is no longer sufficient to load native
|
||||
# DLLs — Python requires explicit `os.add_dll_directory()` calls for
|
||||
# security reasons. This helper walks common GTK3 install locations
|
||||
# and registers the first one found. On Linux it's a no-op.
|
||||
#
|
||||
# IMPORTANT: WeasyPrint is imported LAZILY (inside the function, not
|
||||
# at the top of the file). This is intentional — if WeasyPrint or its
|
||||
# system libraries are missing, the rest of the app still works;
|
||||
# only PDF generation fails gracefully and returns None.
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import os
|
||||
import sys
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Common install locations for the GTK3 runtime on Windows.
|
||||
# Checked in order; first hit wins.
|
||||
_WINDOWS_GTK_PATHS = (
|
||||
r"C:\Program Files\GTK3-Runtime Win64\bin",
|
||||
r"C:\Program Files (x86)\GTK3-Runtime Win64\bin",
|
||||
)
|
||||
|
||||
# Module-level flag so we only add the DLL directory once per process.
|
||||
_gtk_registered = False
|
||||
|
||||
|
||||
def _ensure_gtk_on_windows():
|
||||
"""Register the GTK3 runtime DLL directory on Windows.
|
||||
|
||||
WeasyPrint loads DLLs two ways:
|
||||
1. `ctypes.util.find_library('gobject-2.0-0')` — reads os.environ['PATH']
|
||||
2. `ctypes.CDLL(...)` — uses Windows DLL search, affected by
|
||||
`os.add_dll_directory()` since Python 3.8
|
||||
|
||||
We hit both paths so the library is discoverable regardless of
|
||||
which route WeasyPrint chooses. This is a no-op on Linux/macOS
|
||||
and on Windows when GTK is already registered.
|
||||
"""
|
||||
global _gtk_registered
|
||||
if _gtk_registered or sys.platform != "win32":
|
||||
return
|
||||
for path in _WINDOWS_GTK_PATHS:
|
||||
if os.path.isdir(path):
|
||||
# Prepend to PATH so find_library() locates the DLLs.
|
||||
current_path = os.environ.get("PATH", "")
|
||||
if path.lower() not in current_path.lower():
|
||||
os.environ["PATH"] = path + os.pathsep + current_path
|
||||
# Register via add_dll_directory for ctypes.CDLL() loads.
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
os.add_dll_directory(path)
|
||||
_gtk_registered = True
|
||||
logger.debug("Registered GTK3 runtime directory: %s", path)
|
||||
return
|
||||
logger.debug(
|
||||
"No GTK3 runtime directory found on Windows in %s", _WINDOWS_GTK_PATHS,
|
||||
)
|
||||
|
||||
|
||||
def render_to_pdf(template_src, context_dict=None):
|
||||
"""
|
||||
Render a Django template to PDF bytes.
|
||||
Render a Django template to PDF bytes using WeasyPrint.
|
||||
|
||||
Args:
|
||||
template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html')
|
||||
context_dict: Template context variables
|
||||
template_src: Path to the template
|
||||
(e.g. 'core/pdf/payslip_pdf.html')
|
||||
context_dict: Template context variables
|
||||
|
||||
Returns:
|
||||
PDF content as bytes, or None if there was an error.
|
||||
@ -28,25 +96,37 @@ def render_to_pdf(template_src, context_dict=None):
|
||||
if context_dict is None:
|
||||
context_dict = {}
|
||||
|
||||
# --- Lazy import: only load xhtml2pdf when actually generating a PDF ---
|
||||
# This prevents the entire app from crashing if xhtml2pdf isn't installed.
|
||||
# On Windows we need to tell Python where the GTK3 DLLs live
|
||||
# BEFORE importing weasyprint. Harmless no-op on Linux/macOS.
|
||||
_ensure_gtk_on_windows()
|
||||
|
||||
# --- Lazy import: only load WeasyPrint when actually generating a PDF ---
|
||||
# ImportError covers the Python package being missing.
|
||||
# OSError covers missing native libs (Pango, Cairo) at runtime.
|
||||
try:
|
||||
from xhtml2pdf import pisa
|
||||
except ImportError:
|
||||
from weasyprint import HTML
|
||||
except (ImportError, OSError) as e:
|
||||
logger.error(
|
||||
"xhtml2pdf is not installed — cannot generate PDF. "
|
||||
"Install it with: pip install xhtml2pdf"
|
||||
"WeasyPrint is not available — cannot generate PDF. "
|
||||
"Install with: pip install weasyprint. "
|
||||
"On Windows, also install the GTK3 runtime via "
|
||||
"`winget install tschoonj.GTKForWindows`. "
|
||||
"Underlying error: %s", e,
|
||||
)
|
||||
return None
|
||||
|
||||
# Load and render the HTML template
|
||||
# Render the Django template to HTML first
|
||||
template = get_template(template_src)
|
||||
html = template.render(context_dict)
|
||||
|
||||
# Convert HTML to PDF
|
||||
result = BytesIO()
|
||||
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
|
||||
|
||||
if not pdf.err:
|
||||
return result.getvalue()
|
||||
return None
|
||||
# Convert HTML to PDF bytes.
|
||||
# base_url lets WeasyPrint resolve relative paths to static files
|
||||
# (e.g. fonts in static/fonts/, images in static/img/). We fall back
|
||||
# to "." so the call still succeeds when STATIC_ROOT isn't set
|
||||
# (e.g. during local dev before collectstatic has run).
|
||||
base = getattr(settings, "STATIC_ROOT", None) or "."
|
||||
try:
|
||||
return HTML(string=html, base_url=base).write_pdf()
|
||||
except Exception as e:
|
||||
logger.exception("WeasyPrint render failed: %s", e)
|
||||
return None
|
||||
|
||||
1326
core/views.py
1326
core/views.py
File diff suppressed because it is too large
Load Diff
294
docs/plans/2026-04-21-deploy-audit-and-fork.md
Normal file
294
docs/plans/2026-04-21-deploy-audit-and-fork.md
Normal file
@ -0,0 +1,294 @@
|
||||
# Deploy Readiness Audit + Fork Plan
|
||||
|
||||
**Created:** 21 April 2026
|
||||
**Status:** Draft plan — not yet executed
|
||||
**Author:** Claude, based on full-repo audit
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Get the app into a known-good state before attempting to test-deploy it on a non-Flatlogic platform, so that any issues surfaced during the test deploy are *real deploy issues*, not latent code bugs. Then fork the repo into a clean branch for the test-deploy.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ BLOCKING QUESTION — Platform choice
|
||||
|
||||
You said "test deploy this using **SpacetimeDB**." I want to confirm before we proceed, because **SpacetimeDB is not a platform where this Django app can run**.
|
||||
|
||||
**SpacetimeDB** (Clockwork Labs) is a specialized relational database designed for real-time multiplayer games — you write your app logic in **Rust or C#**, compile it to WebAssembly, and it runs *inside* the database. It doesn't speak the PostgreSQL/MySQL wire protocol, doesn't host HTTP apps, and isn't a Django deploy target. Using SpacetimeDB would mean **rewriting the entire payroll app from scratch in Rust** (multiple weeks, not a test deploy).
|
||||
|
||||
I think you may have meant one of these instead:
|
||||
|
||||
| Candidate | What it is | Django fit |
|
||||
|---|---|---|
|
||||
| **Fly.io** | Containerised Django hosting, cheap, persistent volumes | ✅ Drop-in |
|
||||
| **Railway** | Similar to Fly.io, simpler UX | ✅ Drop-in |
|
||||
| **Render** | Mature PaaS, handles Django well | ✅ Drop-in |
|
||||
| **Supabase** | PostgreSQL-based BaaS (not Django-host, but could replace MySQL) | Partial — you'd still need a Django host |
|
||||
| **PlanetScale** | Serverless MySQL replacement | Partial — same |
|
||||
| **DigitalOcean App Platform** | Containerised Django hosting | ✅ Drop-in |
|
||||
|
||||
**Please confirm which you meant.** The rest of this plan assumes **Fly.io** as a sensible default for "test deploy a Django app cheaply on a platform other than Flatlogic" — swap the platform in if you meant something different.
|
||||
|
||||
---
|
||||
|
||||
## Part A — Audit findings (prioritised)
|
||||
|
||||
I did a full-repo audit looking at performance, latent bugs, and Flatlogic-specific deploy risks. Findings below with **severity** — fix CRITICALs before any deploy (Flatlogic or elsewhere).
|
||||
|
||||
### 🔴 CRITICAL — fix before ANY deploy
|
||||
|
||||
#### 1. Gmail App Password committed to source control
|
||||
**File:** `config/settings.py:177`
|
||||
```python
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
|
||||
```
|
||||
The fallback value `cwvhpcwyijneukax` is a real 16-character Gmail App Password. Anyone with read access to this repo (public GitHub history included) has full send-access to `konrad@foxfitt.co.za`.
|
||||
|
||||
**Also on lines 176, 188** — `EMAIL_HOST_USER` defaults to Konrad's real email, and `SPARK_RECEIPT_EMAIL` defaults to a real inbound address.
|
||||
|
||||
**Fix:** Remove the string fallbacks. If env var missing, either raise an error on startup or disable email features. ~10 minutes.
|
||||
|
||||
**Additional action:** Rotate the Gmail App Password immediately after fix lands, since it's already exposed in git history.
|
||||
|
||||
---
|
||||
|
||||
#### 2. SECRET_KEY has a weak default
|
||||
**File:** `config/settings.py:20`
|
||||
```python
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
```
|
||||
If `DJANGO_SECRET_KEY` env var isn't set on the deploy platform, Django boots with `"change-me"` — sessions become forgeable, password reset tokens become predictable.
|
||||
|
||||
**Fix:** Remove the fallback. If env var missing, raise `ImproperlyConfigured`. ~5 minutes.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 HIGH — fix before test deploy (and before production)
|
||||
|
||||
#### 3. `DEBUG` defaults to `true`
|
||||
**File:** `config/settings.py:21`
|
||||
```python
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
```
|
||||
If `DJANGO_DEBUG` env var isn't set, the app runs in DEBUG mode in production — full tracebacks on 500 errors expose DB schema, file paths, secret fragments.
|
||||
|
||||
**Fix:** Change default to `"false"`. ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
#### 4. Media uploads will be lost on Flatlogic rebuilds
|
||||
`MEDIA_ROOT = BASE_DIR / 'media'`. Worker photos, ID documents, cert PDFs, warning PDFs all upload here. On Flatlogic, the app container is rebuilt on every deploy from git — **local filesystem is ephemeral**, so any uploaded file disappears on the next "Pull Latest".
|
||||
|
||||
Currently you have no uploaded files (you mentioned this), so the problem isn't visible yet. The moment you upload a worker photo and do a deploy, it's gone.
|
||||
|
||||
**Fix options:**
|
||||
- **(a) S3/Cloudflare R2 bucket** via `django-storages` — durable, costs cents/month
|
||||
- **(b) Ask Flatlogic to mount a persistent volume** at `/app/media`
|
||||
- **(c) For the test deploy only:** use ephemeral storage, accept the risk for testing
|
||||
|
||||
Recommend (a) for production, (c) for the test deploy since no uploaded files exist yet.
|
||||
|
||||
---
|
||||
|
||||
#### 5. Batch reports load entire tables into Python memory
|
||||
`worker_batch_report`, `team_batch_report`, `project_batch_report` all build a full list in memory before rendering. At ~14 workers this is fine; at 1,000+ it will strain the 512MB e2-micro RAM especially when WeasyPrint PDF rendering runs concurrently.
|
||||
|
||||
**Fix:** Add pagination (50 rows per page) on the HTML view. CSV/PDF can still be "export all" but should use `.iterator()` to stream rows rather than building a full list. ~30 minutes.
|
||||
|
||||
**Not urgent at current data scale — deferrable.**
|
||||
|
||||
---
|
||||
|
||||
#### 6. `CSRF_TRUSTED_ORIGINS` has a URL-joining bug
|
||||
**File:** `config/settings.py:30-40`
|
||||
|
||||
If someone sets `HOST_FQDN=https://example.com` (with scheme), the settings code prepends `https://` again → `https://https://example.com`, which Django rejects and breaks CSRF validation entirely.
|
||||
|
||||
**Fix:** Check if the value already has a scheme before prepending. ~5 minutes.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM — worth fixing, not deploy-blocking
|
||||
|
||||
#### 7. PDF generation failures not handled in email send path
|
||||
`_send_payslip_email()` and `create_receipt()` both call `render_to_pdf()` which can return `None` if WeasyPrint fails. Some paths check `if pdf:`, but not all — if the return is `None`, `email.attach(filename, None, "application/pdf")` may raise or silently send an unattached email. The user would get a notification but no payslip.
|
||||
|
||||
**Fix:** Guard every `render_to_pdf()` return with `if pdf_bytes is None: log + skip attachment`. ~15 minutes.
|
||||
|
||||
---
|
||||
|
||||
#### 8. `price_overtime()` silently swallows all exceptions
|
||||
The view loops over overtime entries and any exception inside the loop is caught and ignored — including typos, DB errors, and missing-record issues. The UI reports "Priced 5" even if 10 silently failed.
|
||||
|
||||
**Fix:** Catch specific expected exceptions only; log the rest. ~10 minutes.
|
||||
|
||||
---
|
||||
|
||||
#### 9. `X_FRAME_OPTIONS = 'ALLOWALL'` — clickjacking risk
|
||||
Deliberately disabled for Flatlogic's iframe preview. Any third party can embed the app in their own iframe and attack logged-in users.
|
||||
|
||||
**Fix options:**
|
||||
- On any platform *other* than Flatlogic: remove the middleware exclusion entirely
|
||||
- On Flatlogic: restrict to Flatlogic's iframe parent domain via a custom middleware
|
||||
|
||||
Relevant mainly if deploying off Flatlogic.
|
||||
|
||||
---
|
||||
|
||||
#### 10. Hard-coded daily-rate divisor = 20
|
||||
`Worker.daily_rate` = `monthly_salary / 20`. Fine as a business rule, but hardcoded with no validation. If `monthly_salary == 0`, every payslip becomes R 0.00 silently.
|
||||
|
||||
**Fix:** Add `monthly_salary > 0` validation in `Worker.save()` or the form. Low priority. ~10 minutes.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 LOW — performance, N+1 refinements
|
||||
|
||||
The `index` dashboard, `payroll_dashboard`, and `work_history` views all have some `for-loop-with-related-access` patterns that would be N+1 without the existing `prefetch_related` calls. The prefetches ARE present — they're just not documented clearly enough that a future Claude/dev will preserve them.
|
||||
|
||||
**Fix:** Add a comment above each loop explaining the prefetch, to prevent regression. No code change needed. ~30 minutes of doc comments.
|
||||
|
||||
---
|
||||
|
||||
## Part B — Fork & test-deploy strategy
|
||||
|
||||
### Overview
|
||||
|
||||
The test deploy goal is to verify: *can this app run and function correctly on a non-Flatlogic platform, so we're not locked in*. The fork gives us an isolated place to experiment without touching the working `ai-dev`/`master` branches or our current `redesign-weasyprint` work.
|
||||
|
||||
### Fork strategy (git)
|
||||
|
||||
Since this repo is already on GitHub at `Konradzar/LabourPay_v5`, create a fork branch (not a separate GitHub fork — we don't need the ceremony):
|
||||
|
||||
1. From `redesign-weasyprint` branch (which has all the recent work including WeasyPrint, worker/team/project management), cut a new branch:
|
||||
```
|
||||
git checkout -b test-deploy-<platform>
|
||||
```
|
||||
2. Commit the CRITICAL fixes (#1, #2, #3) on this branch **first** — those changes should eventually land everywhere, not just in the test deploy branch.
|
||||
3. Add platform-specific deploy config on top (Dockerfile, fly.toml / railway.json / render.yaml, depending on platform).
|
||||
4. Push only this branch — not the other local branches — so GitHub sees it but `ai-dev` and `master` stay unaffected.
|
||||
|
||||
Flatlogic keeps syncing only `ai-dev`. The test-deploy branch is invisible to Flatlogic.
|
||||
|
||||
### Platform-specific deploy config (assuming Fly.io — adjust if you meant different)
|
||||
|
||||
1. **`Dockerfile`** — single-stage Python 3.13 image with WeasyPrint system deps:
|
||||
```dockerfile
|
||||
FROM python:3.13-slim
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpango-1.0-0 libcairo2 libgdk-pixbuf2.0-0 \
|
||||
libffi-dev shared-mime-info \
|
||||
default-libmysqlclient-dev pkg-config build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN python manage.py collectstatic --noinput
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
```
|
||||
|
||||
2. **`fly.toml`** — minimal Fly config:
|
||||
```toml
|
||||
app = "foxfitt-test"
|
||||
primary_region = "jnb" # Johannesburg, closest to South Africa
|
||||
[env]
|
||||
DJANGO_DEBUG = "false"
|
||||
USE_SQLITE = "false"
|
||||
HOST_FQDN = "foxfitt-test.fly.dev"
|
||||
[[services]]
|
||||
internal_port = 8000
|
||||
protocol = "tcp"
|
||||
[[services.ports]]
|
||||
handlers = ["http", "tls"]
|
||||
port = 443
|
||||
```
|
||||
|
||||
3. **Secrets** (set via `fly secrets set`):
|
||||
- `DJANGO_SECRET_KEY`
|
||||
- `DB_NAME`, `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT` (for managed MySQL/Postgres)
|
||||
- `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`
|
||||
- `SPARK_RECEIPT_EMAIL`
|
||||
- `DEFAULT_FROM_EMAIL`
|
||||
|
||||
4. **Add WhiteNoise** for static file serving (one-liner in `MIDDLEWARE` + add to `requirements.txt`) — Apache-style static serving doesn't exist on Fly.
|
||||
|
||||
5. **Database decision**:
|
||||
- **Simplest:** provision Fly's managed MySQL (or PostgreSQL — needs settings.py `ENGINE` tweak)
|
||||
- **Alternative:** external managed MySQL (PlanetScale free tier, AWS RDS)
|
||||
|
||||
6. **Media storage decision**:
|
||||
- For test deploy: ephemeral is fine
|
||||
- For anything beyond test: add `django-storages` + S3 bucket
|
||||
|
||||
### Test deploy verification checklist
|
||||
|
||||
Once deployed, verify:
|
||||
- [ ] Home page loads (`/`)
|
||||
- [ ] Login works with a seeded admin user
|
||||
- [ ] Dashboard renders
|
||||
- [ ] Create a worker via friendly UI
|
||||
- [ ] Log attendance
|
||||
- [ ] Process a payment (critical path)
|
||||
- [ ] Download a payroll report PDF (verifies WeasyPrint + system libs)
|
||||
- [ ] Generate CSV exports
|
||||
- [ ] Django admin pages load
|
||||
- [ ] Session cookies survive across requests
|
||||
- [ ] Static files (CSS, images) load from `/static/`
|
||||
- [ ] Admin can log in and out
|
||||
|
||||
### Rollback
|
||||
|
||||
Test deploy is entirely on its own branch. If it fails catastrophically, delete the Fly app and the branch. Zero impact on Flatlogic production.
|
||||
|
||||
---
|
||||
|
||||
## Part C — Recommended execution order
|
||||
|
||||
I don't recommend executing this as one big batch. Split into four phases, ship each, observe, then proceed:
|
||||
|
||||
**Phase 1 — Critical security fixes (ship to everything)**
|
||||
- Remove hardcoded email credentials (#1)
|
||||
- Fix SECRET_KEY default (#2)
|
||||
- Fix DEBUG default (#3)
|
||||
- Fix CSRF_TRUSTED_ORIGINS bug (#6)
|
||||
- **Rotate exposed Gmail App Password**
|
||||
- Land this on `redesign-weasyprint`, merge to `ai-dev`, let Flatlogic rebuild, verify still working.
|
||||
|
||||
**Phase 2 — Create the fork branch**
|
||||
- Cut `test-deploy-<platform>` off `redesign-weasyprint` after Phase 1 is in.
|
||||
- No code changes — just the branch exists.
|
||||
|
||||
**Phase 3 — Add deploy config for chosen platform**
|
||||
- Dockerfile, platform config file, WhiteNoise, static-root adjustments, etc.
|
||||
- Commit on `test-deploy-<platform>` only.
|
||||
|
||||
**Phase 4 — Deploy + verify**
|
||||
- Push branch, deploy, run the verification checklist above.
|
||||
- Document any platform-specific quirks in a follow-up note.
|
||||
|
||||
**Phase 5 (optional, only if going beyond test) — Address MEDIUM findings**
|
||||
- PDF-None handling (#7)
|
||||
- `price_overtime` exception leak (#8)
|
||||
- Clickjacking header (#9)
|
||||
- Salary validation (#10)
|
||||
|
||||
---
|
||||
|
||||
## Open questions / decisions needed
|
||||
|
||||
1. **What platform did you actually mean?** (SpacetimeDB blocker — see top of doc)
|
||||
2. For the test deploy, is the database allowed to start empty, or do you want the production MySQL data copied in?
|
||||
3. Do you want the CRITICAL fixes merged into Flatlogic production (via `ai-dev`) as part of Phase 1, or hold off until the whole plan is approved?
|
||||
4. Budget sensitivity — Fly.io's free tier is ~$5/mo equivalent; Railway's is similar. Are we constrained to free-tier, or is a few dollars a month OK for test?
|
||||
|
||||
---
|
||||
|
||||
## Not in scope (explicit)
|
||||
|
||||
- Adding new app features (no new views, models, or migrations beyond what exists)
|
||||
- Rewriting in a different language/framework (e.g., actual SpacetimeDB migration)
|
||||
- Performance tuning at scale (current data size is small; defer until needed)
|
||||
- Removing Flatlogic as the production platform — this is a *test* deploy to prove portability, not a migration
|
||||
663
docs/plans/2026-04-21-worker-management-expansion.md
Normal file
663
docs/plans/2026-04-21-worker-management-expansion.md
Normal file
@ -0,0 +1,663 @@
|
||||
# Worker Management Expansion — Design & Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. REQUIRED SUB-SKILL: Use superpowers:test-driven-development where tests are called for.
|
||||
|
||||
**Goal:** Extend the Worker model with certifications + disciplinary records, add a friendly in-app worker-edit form (replacing the need to use Django admin for common edits), and build a batch worker report showing projects, teams, days worked, and first/last payslip dates.
|
||||
|
||||
**Architecture:**
|
||||
- Two new models (`WorkerCertificate`, `WorkerWarning`) with ForeignKey to existing `Worker`.
|
||||
- A worker list page + worker edit page, both admin-only, replacing most everyday admin needs.
|
||||
- A batch-report page (HTML + CSV + PDF) summarising each worker's full work history.
|
||||
- All new work lives inside the existing `core/` app — no new apps. Reuses existing patterns: `is_admin()` gating, `@login_required`, Bootstrap modals, `render_to_pdf()`, inline formsets.
|
||||
|
||||
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod), Bootstrap 5, WeasyPrint for PDFs, existing `format_tags.money` filter.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
**Why this is being built:**
|
||||
The owner (Konrad) currently manages workers via `/admin/core/worker/` — which is functional but lacks:
|
||||
- Certification tracking (Skills, PDP, First Aid, Medical, Work at Height) with expiry dates and document uploads
|
||||
- A disciplinary/warnings record per worker
|
||||
- A friendlier edit UI than Django admin's default form
|
||||
- A consolidated worker-history report (projects worked, days, payslips) for review or regulatory/auditor purposes
|
||||
|
||||
This plan closes all four gaps in one coherent feature.
|
||||
|
||||
**Outcomes when complete:**
|
||||
1. Per worker, the admin can see and maintain: all 5 cert types, all warnings, in one page.
|
||||
2. The admin has a worker list and worker edit page that are easier than Django admin.
|
||||
3. The admin can generate a batch report of all workers' project/team/day/payslip history — viewable, CSV-exportable, PDF-exportable.
|
||||
|
||||
---
|
||||
|
||||
## Scope (explicit)
|
||||
|
||||
### In scope
|
||||
- New models: `WorkerCertificate`, `WorkerWarning`
|
||||
- Django admin registrations for both (inline on Worker)
|
||||
- Worker list page (admin-only): `/workers/`
|
||||
- Worker edit page (admin-only): `/workers/<id>/edit/` and `/workers/new/`
|
||||
- Worker detail page (admin-only, read-only view with history): `/workers/<id>/`
|
||||
- Batch worker report: `/workers/report/` (HTML), `/workers/report/csv/`, `/workers/report/pdf/`
|
||||
- Nav links added to base.html
|
||||
- Migrations for the two new models
|
||||
- Updates to `CLAUDE.md` documenting the new pieces
|
||||
|
||||
### Out of scope (not in this plan — can be follow-ups)
|
||||
- Cert expiry email alerts
|
||||
- Worker self-service portal (only admins use this)
|
||||
- Photo cropping / file optimisation
|
||||
- Bulk cert upload (edit one worker at a time)
|
||||
|
||||
---
|
||||
|
||||
## Files to Create / Modify
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `core/models.py` | Modify | Add `WorkerCertificate` and `WorkerWarning` model classes |
|
||||
| `core/migrations/0009_worker_certificates_warnings.py` | Create (auto) | Schema changes |
|
||||
| `core/admin.py` | Modify | Register new models; add inlines to WorkerAdmin |
|
||||
| `core/forms.py` | Modify | `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` |
|
||||
| `core/views.py` | Modify | 6 new views: worker_list, worker_detail, worker_edit, worker_batch_report, worker_batch_report_csv, worker_batch_report_pdf |
|
||||
| `core/urls.py` | Modify | 6 new routes |
|
||||
| `core/templates/core/workers/list.html` | Create | Worker list with search + filters + "Add Worker" button |
|
||||
| `core/templates/core/workers/edit.html` | Create | Section-based edit form with inline certs + warnings |
|
||||
| `core/templates/core/workers/detail.html` | Create | Read-only worker profile with history tabs |
|
||||
| `core/templates/core/workers/batch_report.html` | Create | HTML batch report (extends base.html) |
|
||||
| `core/templates/core/pdf/workers_report_pdf.html` | Create | PDF version of batch report (uses WeasyPrint) |
|
||||
| `core/templates/base.html` | Modify | Add "Workers" nav link (admin only) |
|
||||
| `CLAUDE.md` | Modify | Document new models, URLs, admin patterns |
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `WorkerCertificate`
|
||||
|
||||
```python
|
||||
class WorkerCertificate(models.Model):
|
||||
"""A certification held by a worker (Skills, PDP, First Aid, etc.).
|
||||
|
||||
One row per (worker, cert_type) — existence of the row means the
|
||||
worker currently holds this certification. Delete the row to record
|
||||
that they no longer hold it. Use `valid_until` to track expiry.
|
||||
"""
|
||||
CERT_TYPES = [
|
||||
('skills', 'Skills Certificate'),
|
||||
('pdp', 'PDP (Professional Driving Permit)'),
|
||||
('first_aid', 'First Aid'),
|
||||
('medical', 'Medical'),
|
||||
('work_at_height', 'Work at Height'),
|
||||
]
|
||||
|
||||
worker = models.ForeignKey(
|
||||
Worker, related_name='certificates', on_delete=models.CASCADE,
|
||||
)
|
||||
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
|
||||
document = models.FileField(
|
||||
upload_to='workers/certificates/', blank=True, null=True,
|
||||
help_text='Scan or photo of the certificate',
|
||||
)
|
||||
issued_date = models.DateField(blank=True, null=True)
|
||||
valid_until = models.DateField(
|
||||
blank=True, null=True,
|
||||
help_text='Expiry date — leave blank if the cert does not expire',
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('worker', 'cert_type')]
|
||||
ordering = ['worker', 'cert_type']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.worker.name} — {self.get_cert_type_display()}'
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
if not self.valid_until:
|
||||
return False
|
||||
return self.valid_until < timezone.now().date()
|
||||
|
||||
@property
|
||||
def expires_soon(self):
|
||||
"""True if the cert expires within the next 30 days."""
|
||||
if not self.valid_until:
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
return today <= self.valid_until <= today + datetime.timedelta(days=30)
|
||||
```
|
||||
|
||||
### `WorkerWarning`
|
||||
|
||||
```python
|
||||
class WorkerWarning(models.Model):
|
||||
"""A disciplinary warning issued to a worker."""
|
||||
SEVERITY_CHOICES = [
|
||||
('verbal', 'Verbal Warning'),
|
||||
('written', 'Written Warning'),
|
||||
('final', 'Final Warning'),
|
||||
]
|
||||
|
||||
worker = models.ForeignKey(
|
||||
Worker, related_name='warnings', on_delete=models.CASCADE,
|
||||
)
|
||||
date = models.DateField(default=timezone.now)
|
||||
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
|
||||
reason = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Short summary — e.g. "Repeated lateness"',
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text='Full context of what happened',
|
||||
)
|
||||
issued_by = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='warnings_issued',
|
||||
)
|
||||
document = models.FileField(
|
||||
upload_to='workers/warnings/', blank=True, null=True,
|
||||
help_text='Signed warning form (optional)',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.worker.name} — {self.get_severity_display()} ({self.date})'
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
`python manage.py makemigrations core` → produces `0009_worker_certificates_warnings.py`.
|
||||
Safe migration: two new tables, no changes to existing ones. Reversible.
|
||||
|
||||
---
|
||||
|
||||
## URL Routes (new)
|
||||
|
||||
Add to `core/urls.py` before the `# === EXPENSE RECEIPTS ===` section:
|
||||
|
||||
```python
|
||||
# === WORKERS ===
|
||||
# Admin-friendly worker management UI (alternative to /admin/core/worker/)
|
||||
path('workers/', views.worker_list, name='worker_list'),
|
||||
path('workers/new/', views.worker_edit, name='worker_new'),
|
||||
path('workers/<int:worker_id>/', views.worker_detail, name='worker_detail'),
|
||||
path('workers/<int:worker_id>/edit/', views.worker_edit, name='worker_edit'),
|
||||
# Batch report (table of all workers with aggregated history)
|
||||
path('workers/report/', views.worker_batch_report, name='worker_batch_report'),
|
||||
path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'),
|
||||
path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Views (new)
|
||||
|
||||
All admin-gated via `@login_required` + `is_admin(request.user)` → 403 if not admin.
|
||||
|
||||
### `worker_list(request)`
|
||||
- Fetches all Workers (not just active).
|
||||
- Filters: `?q=search_term`, `?status=active|inactive|all`.
|
||||
- Template: `core/workers/list.html`.
|
||||
- Shows table: name, ID, phone, salary, days worked, active toggle.
|
||||
|
||||
### `worker_detail(request, worker_id)`
|
||||
- Worker profile (read-only).
|
||||
- Shows: personal info, PPE, license, certs (with expiry highlights), warnings, and a "History" tab with projects/teams/days/payslips.
|
||||
- Template: `core/workers/detail.html`.
|
||||
|
||||
### `worker_edit(request, worker_id=None)`
|
||||
- GET: renders form pre-filled (or blank if `worker_id is None`).
|
||||
- POST: validates + saves Worker + inline formsets for certs + warnings.
|
||||
- Redirect on success: → `worker_detail`.
|
||||
- Template: `core/workers/edit.html`.
|
||||
|
||||
### `worker_batch_report(request)`
|
||||
- Builds per-worker aggregates using a shared `_build_worker_report_context()` helper (parallel to `_build_report_context`).
|
||||
- Filters: `?status=`, `?project=`, `?team=`.
|
||||
- Template: `core/workers/batch_report.html`.
|
||||
|
||||
### `worker_batch_report_csv(request)`
|
||||
- Same context builder; streams a CSV with all columns.
|
||||
|
||||
### `worker_batch_report_pdf(request)`
|
||||
- Same context builder; uses `render_to_pdf('core/pdf/workers_report_pdf.html', context)`.
|
||||
|
||||
### Shared helper `_build_worker_report_context(status=None, project_id=None, team_id=None)`
|
||||
Returns a list of dicts, one per worker:
|
||||
```python
|
||||
{
|
||||
'worker': worker_obj,
|
||||
'projects': ['Solar Farm Alpha', 'Solar Farm Beta'], # distinct project names
|
||||
'teams': ['Team Alpha'], # distinct team names
|
||||
'days_worked': 47, # distinct WorkLog dates
|
||||
'first_payslip_date': date(2025, 3, 14) or None,
|
||||
'last_payslip_date': date(2026, 4, 5) or None,
|
||||
'total_paid_lifetime': Decimal('127450.00'),
|
||||
'payslip_count': 12,
|
||||
'active_certs': 3,
|
||||
'expiring_certs': 1, # expires within 30 days
|
||||
'expired_certs': 0,
|
||||
'active_warnings_count': 1, # warnings issued in last 12 months
|
||||
}
|
||||
```
|
||||
|
||||
Aggregation approach (efficient — one query per aggregate, not per worker):
|
||||
```python
|
||||
qs = Worker.objects.annotate(
|
||||
days_worked=Count('work_logs__date', distinct=True),
|
||||
first_payslip_date=Min('payroll_records__date'),
|
||||
last_payslip_date=Max('payroll_records__date'),
|
||||
total_paid_lifetime=Sum('payroll_records__amount_paid'),
|
||||
payslip_count=Count('payroll_records', distinct=True),
|
||||
)
|
||||
# then a separate prefetch for projects/teams
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forms (new)
|
||||
|
||||
```python
|
||||
# core/forms.py
|
||||
|
||||
class WorkerForm(forms.ModelForm):
|
||||
"""Main worker edit form — covers the flat fields on Worker."""
|
||||
class Meta:
|
||||
model = Worker
|
||||
fields = [
|
||||
'name', 'id_number', 'phone_number', 'monthly_salary',
|
||||
'employment_date', 'active', 'notes',
|
||||
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
|
||||
'photo', 'id_document',
|
||||
'has_drivers_license', 'drivers_license',
|
||||
]
|
||||
widgets = {
|
||||
'employment_date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
|
||||
WorkerCertificateFormSet = inlineformset_factory(
|
||||
Worker, WorkerCertificate,
|
||||
fields=['cert_type', 'document', 'issued_date', 'valid_until', 'notes'],
|
||||
extra=0, # no blank rows by default
|
||||
can_delete=True,
|
||||
widgets={
|
||||
'issued_date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'valid_until': forms.DateInput(attrs={'type': 'date'}),
|
||||
'notes': forms.Textarea(attrs={'rows': 2}),
|
||||
},
|
||||
)
|
||||
|
||||
WorkerWarningFormSet = inlineformset_factory(
|
||||
Worker, WorkerWarning,
|
||||
fields=['date', 'severity', 'reason', 'description', 'document'],
|
||||
extra=0,
|
||||
can_delete=True,
|
||||
widgets={
|
||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'description': forms.Textarea(attrs={'rows': 3}),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In the edit view, `request.POST`/`request.FILES` flow through all three (form + two formsets); all must be valid before saving.
|
||||
|
||||
---
|
||||
|
||||
## Templates (new)
|
||||
|
||||
### `core/workers/list.html`
|
||||
- Search box (name/ID) + status filter dropdown
|
||||
- Table columns: Name, ID, Phone, Salary, Days Worked, Active, Actions (View / Edit / Toggle)
|
||||
- Buttons: "Add Worker", "Batch Report", "Export CSV"
|
||||
- Styled with existing stat-card / resource-row patterns from index.html
|
||||
|
||||
### `core/workers/edit.html`
|
||||
- Section-based layout (no tabs — long-form scroll for easier visual review):
|
||||
1. **Personal & Pay** — name, id_number, phone, salary, employment_date, active, notes
|
||||
2. **PPE Sizing** — shoe, overall top, pants, t-shirt
|
||||
3. **Documents** — photo, id_document
|
||||
4. **Driver's License** — has_drivers_license, drivers_license file
|
||||
5. **Certifications** (formset with + add button, × delete)
|
||||
6. **Warnings & Disciplinary** (formset with + add button, × delete)
|
||||
- Client-side JS: "Add Certification" / "Add Warning" buttons clone a hidden blank formset row and bump the TOTAL_FORMS counter (standard Django formset pattern)
|
||||
- Submit button at the bottom; Cancel goes back to `worker_detail`
|
||||
|
||||
### `core/workers/detail.html`
|
||||
- Header: worker photo, name, ID, active badge
|
||||
- Tabs:
|
||||
1. **Profile** — personal, PPE, license info
|
||||
2. **Certifications** — list with colored badges: green (valid > 30 days), amber (expires within 30), red (expired)
|
||||
3. **Warnings** — chronological list
|
||||
4. **History** — projects worked, teams, days, last 10 payslips
|
||||
- "Edit" button links to `worker_edit`
|
||||
|
||||
### `core/workers/batch_report.html`
|
||||
- Report header + filter bar (status / project / team)
|
||||
- Table with columns:
|
||||
- Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (n/m) | Warnings
|
||||
- "Export CSV" + "Download PDF" buttons at top-right
|
||||
- Row click → `worker_detail`
|
||||
|
||||
### `core/pdf/workers_report_pdf.html`
|
||||
- Print-optimized A4 layout using WeasyPrint
|
||||
- Header: "FoxFitt Construction — Worker Roster Report"
|
||||
- Filter summary subhead
|
||||
- Table (narrower columns, landscape orientation may be needed for many fields)
|
||||
- Uses the same amber accent and typography as `report_pdf.html`
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
`base.html` desktop topbar: add a "Workers" link after "Receipts" and before "Admin" (admin-only):
|
||||
|
||||
```html
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'worker_list' %}" class="topbar-nav__link {% if 'worker' in request.resolver_match.url_name %}active{% endif %}">
|
||||
<i class="fas fa-hard-hat"></i><span>Workers</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
Also add matching entries to the mobile menu (the `.mobile-menu__nav` block) and the bottom tab bar (if room).
|
||||
|
||||
---
|
||||
|
||||
## Django Admin Enhancements
|
||||
|
||||
Register the new models:
|
||||
|
||||
```python
|
||||
# core/admin.py
|
||||
|
||||
class WorkerCertificateInline(admin.TabularInline):
|
||||
model = WorkerCertificate
|
||||
extra = 0
|
||||
|
||||
class WorkerWarningInline(admin.TabularInline):
|
||||
model = WorkerWarning
|
||||
extra = 0
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
@admin.register(WorkerCertificate)
|
||||
class WorkerCertificateAdmin(admin.ModelAdmin):
|
||||
list_display = ('worker', 'cert_type', 'valid_until', 'is_expired')
|
||||
list_filter = ('cert_type',)
|
||||
search_fields = ('worker__name',)
|
||||
|
||||
@admin.register(WorkerWarning)
|
||||
class WorkerWarningAdmin(admin.ModelAdmin):
|
||||
list_display = ('worker', 'date', 'severity', 'reason')
|
||||
list_filter = ('severity',)
|
||||
search_fields = ('worker__name', 'reason')
|
||||
```
|
||||
|
||||
Then update `WorkerAdmin` to include the inlines:
|
||||
|
||||
```python
|
||||
class WorkerAdmin(admin.ModelAdmin):
|
||||
# ...existing config...
|
||||
inlines = [WorkerCertificateInline, WorkerWarningInline]
|
||||
```
|
||||
|
||||
This means the Django admin ALSO gets the new sections — the in-app edit page is a better UX, but admin remains fully functional.
|
||||
|
||||
---
|
||||
|
||||
## Task-by-Task Execution Plan
|
||||
|
||||
Each task is 5–15 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.
|
||||
274
docs/plans/2026-04-22-push-to-ai-dev.md
Normal file
274
docs/plans/2026-04-22-push-to-ai-dev.md
Normal file
@ -0,0 +1,274 @@
|
||||
# Push `redesign-weasyprint` → `ai-dev` — Deploy Plan
|
||||
|
||||
**Created:** 22 April 2026
|
||||
**Status:** Draft — awaiting your approval before execution
|
||||
**Target:** Deploy ~6 weeks of local work to Flatlogic production safely
|
||||
|
||||
---
|
||||
|
||||
## What's going live
|
||||
|
||||
### 3 commits already committed (inherited from the `redesign` branch)
|
||||
- `82c1906` Redesign UI with premium orange theme, sidebar nav, bottom tab bar
|
||||
- `16d0342` Fix modal z-index stacking issue
|
||||
- `deef851` Fix dark mode contrast
|
||||
|
||||
### 40 working-directory changes from this session (must be committed)
|
||||
|
||||
**Security fixes** (critical):
|
||||
- Remove hardcoded Gmail App Password + email defaults from `settings.py`
|
||||
- Remove weak `SECRET_KEY` default (raise in prod, safe fallback in dev)
|
||||
- Flip `DEBUG` default to `false`
|
||||
- Fix `CSRF_TRUSTED_ORIGINS` double-scheme bug
|
||||
|
||||
**New database models** (3 migrations to run on production):
|
||||
- `0009` — `WorkerCertificate` + `WorkerWarning` tables
|
||||
- `0010` — `Worker` fields: `bank_name`, `bank_account_number`, `uif_number`, `drivers_license_code`
|
||||
- `0011` — `Worker.tax_number` field
|
||||
- Plus `0007` / `0008` (vat_type defaults) — these were made locally but never committed
|
||||
|
||||
**PDF engine** — xhtml2pdf → WeasyPrint (`requirements.txt` pins `weasyprint==68.1`)
|
||||
|
||||
**New features** (all admin-only UI):
|
||||
- Worker management UI: `/workers/`, `/workers/<id>/`, `/workers/<id>/edit/`
|
||||
- Team management UI: `/teams/` + detail + edit + batch report
|
||||
- Project management UI: `/projects/` + detail + edit + batch report
|
||||
- Worker batch report: HTML + CSV + PDF
|
||||
- Team/Project batch reports: HTML + CSV
|
||||
- Payroll report: Resources dropdown nav, `New Report` button, money filter
|
||||
- Dashboard cert-expiry stat card (conditional)
|
||||
|
||||
**Infrastructure**:
|
||||
- Backup + restore management commands
|
||||
- `/backup-data/` + `/restore-data/` browser endpoints
|
||||
- Bootstrap tooltips (global init + theme-aware CSS)
|
||||
- Django admin template override (taller M2M pickers)
|
||||
- `TEMPLATES.DIRS` change so admin overrides work
|
||||
|
||||
**Documentation**:
|
||||
- Massive `CLAUDE.md` expansion: users/roles/permissions, backup/restore, admin overrides
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ The backup problem
|
||||
|
||||
**Our `/backup-data/` feature is NOT yet on Flatlogic** — it's on this local branch, not yet pushed. So we can't use it to back up production right now.
|
||||
|
||||
This matters because once we push, migrations will run (or need to be run via `/run-migrate/`), and the new code goes live. If something breaks badly, we'd have no restore path.
|
||||
|
||||
**Three backup options, in order of my preference**:
|
||||
|
||||
### Option A (RECOMMENDED): Split the push into two phases
|
||||
|
||||
**Phase 1** — A tiny push that just adds the backup/restore feature + security fixes + the 2 uncommitted "vat_type" migrations (which are tiny and safe).
|
||||
- Risk: very low. No new models, no PDF engine change, no UI.
|
||||
- Gets `/backup-data/` live on production.
|
||||
- You download a backup via that URL.
|
||||
- Backup is on your laptop — you now have a real safety net.
|
||||
|
||||
**Phase 2** — The big push with everything else.
|
||||
- Risk: significant, but now recoverable.
|
||||
- If anything breaks, restore from Phase 1 backup.
|
||||
|
||||
### Option B: Ask Gemini to take a manual backup first
|
||||
|
||||
Ask Gemini in the Flatlogic chat:
|
||||
> Before a planned code push, please run `python manage.py dumpdata --natural-foreign --natural-primary --exclude=contenttypes --exclude=auth.permission --output=/tmp/pre_deploy_backup_20260422.json` and then make that file downloadable to me somehow (e.g., expose it at a temporary URL like we did with the env-setup page).
|
||||
|
||||
Pros: one push instead of two.
|
||||
Cons: relies on Gemini being able to do this cleanly; format may differ from our backup tool; another round of "build temp page, use it, delete it" like last time.
|
||||
|
||||
### Option C: Push all at once, no backup
|
||||
|
||||
Pros: fastest.
|
||||
Cons: if anything goes wrong, you're relying on Flatlogic's internal backups (which exist but aren't something you've tested).
|
||||
|
||||
---
|
||||
|
||||
## Recommended: Option A — two-phase push
|
||||
|
||||
### Phase 1 — Safety scaffolding (~10 minutes)
|
||||
|
||||
**Scope**: only these files
|
||||
- `config/settings.py` — security fixes
|
||||
- `core/management/commands/backup_data.py` (new)
|
||||
- `core/management/commands/restore_data.py` (new)
|
||||
- `core/views.py` — just the `backup_data` + `restore_data` view functions (not the other 8 new views)
|
||||
- `core/urls.py` — just the 2 new routes
|
||||
- `core/migrations/0007_vat_type_default.py` (new)
|
||||
- `core/migrations/0008_vat_type_default_none.py` (new)
|
||||
- `CLAUDE.md` — just the Backup & Restore section + the updated Authentication/Users section
|
||||
|
||||
**NOT in Phase 1**: new models, WeasyPrint, new UIs, the 3 new Worker migrations, new templates, tooltips, etc.
|
||||
|
||||
**Sequence**:
|
||||
1. On local `redesign-weasyprint` branch, make a sub-branch `phase-1-safety`:
|
||||
```
|
||||
git checkout -b phase-1-safety
|
||||
```
|
||||
2. Stage and commit only the Phase 1 files:
|
||||
```
|
||||
git add config/settings.py core/management/commands/backup_data.py core/management/commands/restore_data.py core/migrations/0007_vat_type_default.py core/migrations/0008_vat_type_default_none.py
|
||||
git add -p core/views.py core/urls.py # interactively pick just the backup/restore additions
|
||||
git add CLAUDE.md
|
||||
git commit -m "Security fixes + backup/restore feature + vat_type migrations"
|
||||
```
|
||||
3. Switch to `ai-dev`, merge `phase-1-safety`:
|
||||
```
|
||||
git checkout ai-dev
|
||||
git pull origin ai-dev # important — Flatlogic may have auto-committed since
|
||||
git merge phase-1-safety
|
||||
```
|
||||
4. Push:
|
||||
```
|
||||
git push origin ai-dev
|
||||
```
|
||||
5. Flatlogic auto-detects; click **Pull Latest** in the dashboard; wait ~5 min for rebuild.
|
||||
6. Visit `/run-migrate/` to apply migrations 0007 + 0008.
|
||||
7. Visit `/backup-data/` — download the JSON to your laptop. **Keep this file safe.**
|
||||
8. Basic verification: dashboard loads, payroll dashboard loads, an existing receipt can be viewed. Nothing should behave differently from before.
|
||||
|
||||
**If Phase 1 fails at any step**: revert the merge (`git revert -m 1 <merge-commit>`), push, Flatlogic rebuilds. Low risk because nothing user-facing changed.
|
||||
|
||||
### Phase 2 — The big feature release (~30–60 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 5–7 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)
|
||||
@ -2,4 +2,4 @@ Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
pillow==12.1.1
|
||||
xhtml2pdf==0.2.16
|
||||
weasyprint==68.1
|
||||
3
run_dev.bat
Normal file
3
run_dev.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
set USE_SQLITE=true
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
@ -1,7 +1,7 @@
|
||||
/* ===================================================================
|
||||
FoxFitt LabourPay v5 — Premium Orange Theme
|
||||
Dark-first design system with warm amber/orange accents.
|
||||
Sidebar navigation on desktop, bottom tab bar on mobile.
|
||||
Top bar navigation on desktop, bottom tab bar on mobile.
|
||||
All colours are CSS variables — the theme toggle switches them.
|
||||
=================================================================== */
|
||||
|
||||
@ -30,17 +30,17 @@
|
||||
--border-strong: rgba(255, 255, 255, 0.15);
|
||||
--border-accent: rgba(232, 133, 26, 0.3);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f0f0f0;
|
||||
/* Text — softened white (~85% brightness) for easier reading on dark backgrounds */
|
||||
--text-primary: #d8d8d8;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-on-accent: #ffffff;
|
||||
--text-on-nav: #f0f0f0;
|
||||
--text-on-nav: #d8d8d8;
|
||||
--text-on-nav-muted: #6b7280;
|
||||
--text-link: #e8851a;
|
||||
|
||||
/* Override Bootstrap */
|
||||
--bs-body-color: #f0f0f0;
|
||||
--bs-body-color: #d8d8d8;
|
||||
--bs-body-bg: #0c0e14;
|
||||
--bs-border-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
@ -75,8 +75,7 @@
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Sidebar dimensions */
|
||||
--sidebar-width: 240px;
|
||||
/* Layout dimensions */
|
||||
--bottom-nav-height: 64px;
|
||||
}
|
||||
|
||||
@ -167,138 +166,136 @@ a:hover {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
APP LAYOUT — sidebar + main content area
|
||||
APP LAYOUT — top bar + main content area
|
||||
=================================================================== */
|
||||
|
||||
/* Wrapper for the whole app (sidebar + content) */
|
||||
/* Wrapper for the whole app (topbar + content stacked vertically) */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === SIDEBAR (desktop only) === */
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
/* === TOP BAR (always visible — horizontal nav on desktop, brand-only on mobile) === */
|
||||
.app-topbar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-default);
|
||||
position: fixed;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1040;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
transition: background-color var(--transition-normal);
|
||||
z-index: 1030;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Sidebar brand/logo area */
|
||||
.sidebar-brand {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
/* Inner flexbox container for topbar items */
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
gap: 0.75rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Brand logo + text */
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-brand:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Bolt icon box (also reused on login page) */
|
||||
.sidebar-brand__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: linear-gradient(135deg, #e8851a 0%, #f59e0b 100%);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand__text {
|
||||
.topbar-brand__text {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-on-nav);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar-brand__text span {
|
||||
.topbar-brand__text span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Sidebar navigation links */
|
||||
.sidebar-nav {
|
||||
padding: 1rem 0.75rem;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.sidebar-nav__link {
|
||||
/* Horizontal nav links in topbar — centred between brand and actions */
|
||||
.topbar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-on-nav-muted);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-nav__link:hover {
|
||||
.topbar-nav__link:hover {
|
||||
color: var(--text-on-nav);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-nav__link.active {
|
||||
.topbar-nav__link.active {
|
||||
color: var(--accent);
|
||||
background: rgba(232, 133, 26, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav__link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.sidebar-nav__link i {
|
||||
width: 1.25rem;
|
||||
.topbar-nav__link i {
|
||||
font-size: 0.8rem;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sidebar footer (theme toggle + user info) */
|
||||
.sidebar-footer {
|
||||
padding: 1rem 0.75rem;
|
||||
border-top: 1px solid var(--border-default);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
/* Right side of topbar: theme toggle + user avatar + logout */
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sidebar-user__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
/* User avatar + name in topbar */
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.topbar-user__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
@ -306,27 +303,20 @@ a:hover {
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-user__name {
|
||||
.topbar-user__name {
|
||||
color: var(--text-on-nav);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-user__role {
|
||||
color: var(--text-on-nav-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* === MAIN CONTENT AREA === */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* NO position/z-index here — avoids trapping Bootstrap modals in a stacking context */
|
||||
@ -355,24 +345,13 @@ a:hover {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -300px;
|
||||
left: var(--sidebar-width);
|
||||
left: 0;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(ellipse, rgba(232, 133, 26, 0.06) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* === TOP BAR (mobile) === */
|
||||
.app-topbar {
|
||||
display: none; /* hidden on desktop */
|
||||
background: var(--bg-nav);
|
||||
padding: 0.75rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* === BOTTOM TAB BAR (mobile) === */
|
||||
/* === BOTTOM TAB BAR (mobile only) === */
|
||||
.app-bottom-nav {
|
||||
display: none; /* hidden on desktop */
|
||||
position: fixed;
|
||||
@ -436,30 +415,123 @@ a:hover {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* === HAMBURGER BUTTON (mobile only — hidden on desktop via d-lg-none) === */
|
||||
.hamburger-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-on-nav);
|
||||
font-size: 1.2rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.hamburger-btn:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* === MOBILE MENU (slides down from topbar when hamburger is tapped) === */
|
||||
/* Fixed below the topbar so it stays visible regardless of scroll position */
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
top: 52px; /* matches topbar-inner height */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1029; /* just below topbar (1030) */
|
||||
background: var(--bg-sidebar);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 300ms ease, opacity 200ms ease;
|
||||
opacity: 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Open state — toggled by JS */
|
||||
.mobile-menu.open {
|
||||
max-height: calc(100vh - 52px); /* never taller than remaining screen */
|
||||
opacity: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-menu__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.mobile-menu__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-on-nav-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-menu__link:hover {
|
||||
color: var(--text-on-nav);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-menu__link.active {
|
||||
color: var(--accent);
|
||||
background: rgba(232, 133, 26, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-menu__link i {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-menu__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border-default);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Semi-transparent backdrop behind the menu — tapping it closes the menu */
|
||||
.mobile-menu-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 52px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1028; /* below menu (1029) and topbar (1030) */
|
||||
}
|
||||
|
||||
.mobile-menu-backdrop.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === RESPONSIVE: Mobile layout === */
|
||||
@media (max-width: 991.98px) {
|
||||
.app-sidebar {
|
||||
display: none; /* sidebar hidden on mobile */
|
||||
/* Hide desktop nav links — hamburger menu handles navigation on mobile */
|
||||
.topbar-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* Hide user name on mobile — just show avatar */
|
||||
.topbar-user__name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Bottom tab bar hidden — replaced by hamburger menu */
|
||||
.app-bottom-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-left: 0;
|
||||
padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Decorative gradients on mobile are positioned differently */
|
||||
.app-glow::after {
|
||||
left: -100px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -596,14 +668,39 @@ a:hover {
|
||||
box-shadow: 0 1px 4px rgba(232, 133, 26, 0.3);
|
||||
}
|
||||
|
||||
/* btn-primary — dark slate in dark mode, darker slate in light mode */
|
||||
.btn-primary {
|
||||
background-color: var(--text-primary);
|
||||
border-color: var(--text-primary);
|
||||
background-color: #2a2d3a;
|
||||
border-color: #3a3d4a;
|
||||
color: #d8d8d8;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--text-secondary);
|
||||
border-color: var(--text-secondary);
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background-color: #353849;
|
||||
border-color: #4a4d5a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.btn-primary.active {
|
||||
background-color: #1e2130;
|
||||
border-color: #3a3d4a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Light mode btn-primary — dark navy for good contrast */
|
||||
[data-theme="light"] .btn-primary {
|
||||
background-color: #1e293b;
|
||||
border-color: #1e293b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary:hover,
|
||||
[data-theme="light"] .btn-primary:focus {
|
||||
background-color: #334155;
|
||||
border-color: #334155;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark mode outline button fixes */
|
||||
@ -646,13 +743,14 @@ a:hover {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
TABLES
|
||||
TABLES — compact text for data-dense views
|
||||
=================================================================== */
|
||||
|
||||
.table {
|
||||
color: var(--text-primary);
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.table > thead {
|
||||
@ -672,11 +770,13 @@ a:hover {
|
||||
.table-hover > tbody > tr:hover {
|
||||
background-color: var(--bg-card-hover);
|
||||
--bs-table-hover-bg: var(--bg-card-hover);
|
||||
--bs-table-hover-color: var(--text-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
@ -696,6 +796,13 @@ a:hover {
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Placeholder text — visible but subtle */
|
||||
.form-control::placeholder,
|
||||
.form-select::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: var(--bg-input);
|
||||
@ -711,17 +818,79 @@ a:hover {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
/* === NATIVE DATE/MONTH PICKER ICONS (Chromium) ===
|
||||
The browser paints a small calendar icon on the right of
|
||||
<input type="date"> and <input type="month"> via the pseudo-element
|
||||
::-webkit-calendar-picker-indicator. On dark backgrounds the default
|
||||
black icon is nearly invisible. CSS can't set its fill directly, so
|
||||
we use a filter chain to tint it toward our amber accent (#e8851a).
|
||||
Firefox doesn't render this indicator so the rule is a no-op there. */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="month"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
filter: invert(58%) sepia(89%) saturate(862%) hue-rotate(357deg) brightness(93%) contrast(92%);
|
||||
}
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="month"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* === FORMSET ROW — MARKED FOR DELETION ===
|
||||
When a user clicks the trash button on a certification/warning row,
|
||||
JS adds `.row-marked-delete` to that row. These styles fade the row
|
||||
and strike through its inputs so it's visually obvious the row will
|
||||
be removed on save. The "Undo" link restores everything. */
|
||||
.formset-row.row-marked-delete {
|
||||
opacity: 0.55;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||
}
|
||||
.formset-row.row-marked-delete .form-control,
|
||||
.formset-row.row-marked-delete .form-select,
|
||||
.formset-row.row-marked-delete textarea {
|
||||
text-decoration: line-through;
|
||||
pointer-events: none; /* can't edit a row you're removing */
|
||||
background: var(--bg-inset, #f0f0f5);
|
||||
}
|
||||
.formset-row.row-marked-delete .form-label {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background-color: var(--bg-inset);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* === BOOTSTRAP TOOLTIPS — themed for dark/light modes ===
|
||||
Bootstrap 5.3's default tooltip uses `--bs-body-color` as background
|
||||
and `--bs-body-bg` as text colour. In dark mode that produces a light
|
||||
tooltip with dark text, which clashes with the rest of the UI and can
|
||||
be unreadable when the body/bg values are very close.
|
||||
Override the tooltip CSS variables to use our elevated-surface colours
|
||||
(same palette as cards on hover) — readable on both dark and light. */
|
||||
.tooltip {
|
||||
--bs-tooltip-bg: var(--bg-card-hover);
|
||||
--bs-tooltip-color: var(--text-primary);
|
||||
--bs-tooltip-opacity: 1;
|
||||
}
|
||||
.tooltip .tooltip-inner {
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
font-size: 0.8rem;
|
||||
max-width: 280px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* The arrow inherits its color from --bs-tooltip-bg automatically, but
|
||||
we give it a matching border so it stays connected visually. */
|
||||
|
||||
/* ===================================================================
|
||||
MODALS
|
||||
=================================================================== */
|
||||
@ -799,7 +968,7 @@ a:hover {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
THEME TOGGLE BUTTON (in sidebar footer)
|
||||
THEME TOGGLE BUTTON (in topbar)
|
||||
=================================================================== */
|
||||
|
||||
.theme-toggle {
|
||||
@ -807,15 +976,15 @@ a:hover {
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-on-nav-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
@ -882,6 +1051,30 @@ a:hover {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* === PAYROLL ACTION BUTTONS — 2x2 grid on mobile, row on desktop === */
|
||||
@media (max-width: 767.98px) {
|
||||
.payroll-actions {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payroll-actions .btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* On desktop, restore normal button size (undo btn-sm) */
|
||||
@media (min-width: 768px) {
|
||||
.btn-md-normal {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 0.375rem 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
@ -1101,7 +1294,7 @@ a:hover {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Loan badge: yellow bg + white text for contrast (not text-dark) */
|
||||
/* Loan badge: yellow bg + dark text for contrast */
|
||||
.badge.bg-warning {
|
||||
color: #000 !important;
|
||||
}
|
||||
@ -1181,15 +1374,98 @@ a:hover {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
PRINT STYLES
|
||||
PRINT STYLES — ensure payslips print as black text on white page
|
||||
=================================================================== */
|
||||
|
||||
@media print {
|
||||
body { background: white !important; color: black !important; }
|
||||
.app-sidebar, .app-topbar, .app-bottom-nav,
|
||||
.app-footer, .d-print-none { display: none !important; }
|
||||
.app-main { margin-left: 0 !important; }
|
||||
.card { border: 1px solid #ddd !important; box-shadow: none !important; backdrop-filter: none !important; }
|
||||
/* Override ALL CSS variables to light/print-friendly values */
|
||||
:root, [data-theme="dark"], [data-theme="light"] {
|
||||
--bg-body: #ffffff !important;
|
||||
--bg-card: #ffffff !important;
|
||||
--bg-card-hover: #ffffff !important;
|
||||
--bg-elevated: #ffffff !important;
|
||||
--bg-inset: #f5f5f5 !important;
|
||||
--bg-input: #ffffff !important;
|
||||
--text-primary: #000000 !important;
|
||||
--text-secondary: #333333 !important;
|
||||
--text-tertiary: #666666 !important;
|
||||
--text-on-accent: #000000 !important;
|
||||
--text-link: #000000 !important;
|
||||
--border-default: #cccccc !important;
|
||||
--border-subtle: #dddddd !important;
|
||||
--border-strong: #999999 !important;
|
||||
--accent: #d97706 !important;
|
||||
--color-success: #16a34a !important;
|
||||
--color-danger: #dc2626 !important;
|
||||
--color-warning: #d97706 !important;
|
||||
--color-info: #2563eb !important;
|
||||
--color-success-bg: #ecfdf5 !important;
|
||||
--color-danger-bg: #fef2f2 !important;
|
||||
--color-warning-bg: #fffbeb !important;
|
||||
--color-info-bg: #eff6ff !important;
|
||||
--bs-body-color: #000000 !important;
|
||||
--bs-body-bg: #ffffff !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Hide navigation and non-print elements */
|
||||
.app-topbar, .app-bottom-nav,
|
||||
.app-footer, .app-glow, .d-print-none { display: none !important; }
|
||||
|
||||
/* Cards: clean white with thin border, no blur */
|
||||
.card {
|
||||
background: white !important;
|
||||
border: 1px solid #ddd !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* Stat cards: remove glass effect */
|
||||
.stat-card {
|
||||
background: white !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Tables: black text on white */
|
||||
.table, .table th, .table td {
|
||||
color: #000 !important;
|
||||
background: white !important;
|
||||
border-color: #ccc !important;
|
||||
font-size: 11pt !important;
|
||||
}
|
||||
|
||||
.table > thead, .table-light {
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
/* Headings and text: all black */
|
||||
h1, h2, h3, h4, h5, h6, p, span, div, td, th, a {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Badges: print-friendly */
|
||||
.badge {
|
||||
border: 1px solid #999 !important;
|
||||
background: #f5f5f5 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Ensure the stat-label text prints as dark grey */
|
||||
.stat-label {
|
||||
color: #555 !important;
|
||||
}
|
||||
|
||||
/* Links: no colour, no underline */
|
||||
a { color: #000 !important; text-decoration: none !important; }
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
@ -1199,48 +1475,9 @@ a:hover {
|
||||
body, .card, .modal-content, .form-control, .form-select,
|
||||
.table, .btn, .alert, .badge,
|
||||
.input-group-text, .stat-card, .cal-day,
|
||||
.app-sidebar, .app-topbar, .app-bottom-nav {
|
||||
.app-topbar, .app-bottom-nav {
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
NAVBAR — kept for backward compatibility (legacy top navbar)
|
||||
Sidebar replaces this on desktop. Hidden by default.
|
||||
=================================================================== */
|
||||
|
||||
.navbar {
|
||||
background-color: var(--bg-nav) !important;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: 0.6rem 0;
|
||||
display: none !important; /* hidden — sidebar replaces it */
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.navbar .nav-link {
|
||||
color: var(--text-on-nav-muted) !important;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.85rem !important;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.navbar .nav-link:hover,
|
||||
.navbar .nav-link.active {
|
||||
color: var(--text-on-nav) !important;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.navbar .nav-link i {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user