# FoxFitt LabourPay v5 ## What's mid-flight β€” read this first **Parked / deferred work:** see `docs/plans/parked-work.md`. **Production status (17 May 2026):** βœ… **fully caught up & verified.** The 36-commit bundle (Manager/Salaried Pay + pay-type filter + Salary auto-scope picker + Pay Salary dashboard quick action) is **deployed and confirmed working on production** (`https://foxlog.flatlogic.app/`, Konrad verified 17 May 2026). `origin/ai-dev` HEAD `80d96d7` == prod (the only delta over the functional tip `4c25011` is doc breadcrumbs). Migrations `0016`/`0017` applied; `static/css/custom.css` collected. **πŸ”§ In progress β€” local only, NOT pushed (HARD STOP):** removal of the "Log Today's Work" / **SiteReport** feature (Konrad wants to rethink it from scratch separately β€” work mix is shifting). **Implemented locally β€” Tasks 1-3 complete:** model/table/UI/routes deleted, migration `0018_delete_sitereport` drops `core_sitereport`, post-attendance flow now returns to the dashboard, suite **193 OK**. Still un-pushed and under a HARD STOP β€” nothing reaches origin until Konrad verifies locally (destructive migration on the daily-use attendance path). Design knowledge preserved for a future rebuild in the capture doc `docs/plans/2026-05-17-site-report-removed-capture.md`; see also the parked rebuild entry in `docs/plans/parked-work.md`. **🧊 Backburner β€” do NOT start in `ai-dev`:** Phase A.2 (manual JournalEntry UI) and Phase B (Letterly inbound webhook) are deliberately deferred to a separate offline build/test track (Konrad's decision, 15 May 2026 β€” they're complex and will be proven offline first). They are NOT "blocked waiting on an answer" and should not be picked up as normal feature work. **Nothing journal/voice-related exists in the working app** β€” verified zero `JournalEntry`/webhook/`@csrf_exempt` code on `ai-dev`; there was never anything to remove. See the "Backburner" section in `docs/plans/parked-work.md`. ## Coding Style - Always add clear section header comments using the format: # === SECTION NAME === - Add plain English comments explaining what complex logic does - The project owner is not a programmer β€” comments should be understandable by a non-technical person - When creating or editing code, maintain the existing comment structure - **Django template comments `{# ... #}` are SINGLE-LINE only.** Multi-line blocks need `{% comment %}...{% endcomment %}`. A `{#` on line N with no closing `#}` on the same line renders the whole block as literal text onto the page (and silently β€” no error). This bit us 4Γ— during the Adjustments feature, 5Γ— during the Absences feature, and 7Γ— during the 15 May dashboard-audit pass. **Sanity check after any template edit:** `grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'` β€” every match is a multi-line broken comment. Also: the literal tokens `{#` and `#}` cannot appear inside a `{% comment %}` block β€” they'll be parsed as a nested comment marker. Rephrase meta-notes about comment syntax OUTSIDE the block. - **Duplicate `id=""` attributes cause silent bugs.** `document.getElementById()` returns only the FIRST match in DOM order, so adding a second element with an existing id silently steals the handler from the original. Grep the template before assigning any new id (caught `adjSelectAll` collision in Task 6 β€” header checkbox stole the Add-Adjustment modal's Select-All handler). - **Bootstrap dropdowns inside `.card` elements get clipped by sibling cards.** A `.dropdown-menu` with `z-index: 1050` rendered inside a filter `.card` will STILL appear behind a sibling table `.card` that follows in document order. Bootstrap's `transform: translate(...)` Popper positioning creates a new stacking context β€” the z-index is measured INSIDE the parent card, not globally. The fix: lift the wrapping element (e.g. the filter `
`) with `style="position: relative; z-index: 10;"` so the entire card sits above its siblings. The dropdown's local z-index then resolves correctly. Bit us on the Absences filter dropdown (May 2026). - **JS reading from `data-worker-id` was unreliable; read from `[value]` directly.** Round A's first absence-form team filter rendered `data-worker-id="{{ worker.choice_value }}"` on the row `
` and read it via `row.dataset.workerId`. On production this hid ALL workers when a team was selected β€” likely a stale-template / template-render mismatch. The proven pattern (used by `attendance_log.html` for years) is to read `row.querySelector('input[name="workers"]').value`. The form widget's `` is the source of truth; data attributes are an unnecessary indirection. ## Project Overview Django payroll management system for FoxFitt Construction, a civil works contractor specializing in solar farm foundation installations. Manages field worker attendance, payroll processing, employee loans, and business expenses for solar farm projects. This is v5 β€” a fresh export from Flatlogic/AppWizzy, rebuilt from the v2 codebase with simplified models and cleaner structure. ## Tech Stack - Django 5.2.7, Python 3.13, MySQL (production on Flatlogic Cloud Run) / SQLite (local dev) - Bootstrap 5.3.3 (CDN), Font Awesome 6.5.1 (CDN), Google Fonts (Inter + Poppins) - WeasyPrint for PDF generation (payroll report, payslips, receipts) β€” migrated from xhtml2pdf; browser-grade HTML/CSS rendering with flexbox, grid, @font-face, shadows, and proper CSS cascade - Gmail SMTP for automated document delivery - Hosted on Flatlogic/AppWizzy platform (Apache on Debian, e2-micro VM) ## Project Structure ``` config/ β€” Django project settings, URLs, WSGI/ASGI core/ β€” Single main app: ALL business logic, models, views, forms, templates context_processors.py β€” Injects deployment_timestamp (cache-busting), Flatlogic branding vars forms.py β€” AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm + formset models.py β€” All 10 database models utils.py β€” render_to_pdf() helper (lazy WeasyPrint import + Windows GTK3 DLL registration) views.py β€” All view functions (~52 functions, ~3,800 lines) β€” dashboard, attendance, payroll, reports, worker/team/project CRUD forms.py β€” All form classes + validators (WorkerForm, TeamForm, ProjectForm, AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, WorkerCertificate/WarningFormSet, 5MB file validator) admin.py β€” Django admin registrations for all core models + WorkerCertificate/Warning inlines on Worker templatetags/ β€” format_tags.py: `money` (ZAR), `money_abs` (signed callers), `type_slug` (typeβ†’CSS class), `url_replace` (swap one query-param), `dictlookup` management/commands/ β€” setup_groups, setup_test_data, import_production_data templates/ base.html β€” App shell (topbar + mobile menu + bottom tab bar) core/ β€” Page templates: index, attendance_log, work_history, payroll_dashboard, report, create_receipt, payslip, login Partials: _adjustment_row.html (shared row for flat + grouped Adjustments tab) 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, tooltip overrides) staticfiles/ β€” Collected static assets (Bootstrap, admin) β€” NOT in git (build artifact, regenerated by collectstatic) ``` ## Key Models - **UserProfile** β€” extends Django User (OneToOne); minimal, no extra fields in v5 - **Project** β€” work sites with supervisor assignments (M2M User), start/end dates, active flag - **Worker** β€” profiles with salary, `daily_rate` property (monthly_salary / 20), photo, ID doc, PPE sizing (shoe, overall top, pants, tshirt), drivers license (boolean + file upload). `pay_type` CharField (`'daily'` default | `'fixed'` = manager/salaried) + `is_salaried` property (True when `pay_type='fixed'`). Migration `0016_worker_pay_type` (defaults all existing workers to `'daily'`). - **Team** β€” groups of workers under a supervisor, with optional pay schedule (`pay_frequency`: weekly/fortnightly/monthly, `pay_start_date`: anchor date) - **WorkLog** β€” daily attendance: date, project, team, workers (M2M), supervisor, overtime, `priced_workers` (M2M) - **PayrollRecord** β€” completed payments linked to WorkLogs (M2M) and Worker (FK) - **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. - **Absence** β€” per-worker dated record of a day not worked. 8 reason choices (Sick, Family Responsibility, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Absconded, Other). Optional `project` FK (SET_NULL). `is_paid` boolean (default False) β€” when ticked, the save flow auto-creates a Bonus PayrollAdjustment via `_sync_absence_payroll_adjustment(absence)` helper, inheriting the absence's project for cost-attribution. Linked via OneToOneField (`payroll_adjustment`). Unique per (worker, date) at DB layer. Permission scoping: admin (all) or supervisor (workers in their teams). ### Schema name-drifts to remember Fields / accessors that differ from what you'd guess. Each has bitten multiple sessions; grep `core/models.py` before using any field you haven't used before: - `PayrollAdjustment.description` β€” NOT `reason` - `log.adjustments_by_work_log` (reverse accessor for PayrollAdjustment.work_log FK) β€” NOT `payrolladjustment_set` (the FK has `related_name` set) - `log.overtime_amount` (DecimalField, default 0.00) β€” NOT `log.overtime` - `PayrollRecord.amount_paid` (DecimalField) + `PayrollRecord.work_logs` (M2M reverse) β€” NOT `total_amount` / `days_worked` (easy to guess wrong when writing test fixtures) - `Loan.principal_amount` β€” NOT `principal`. `Loan.save()` auto-sets `remaining_balance = principal_amount` on create, so tests rarely need to pass both. ## UI-vs-DB naming drift (Apr 2026) β€” READ BEFORE WRITING FORMULAS `PayrollAdjustment.type` is DISPLAYED to users with short labels, but the raw string stored in the database is always the long legacy value: | What the user SEES | What the DATABASE stores | |---|---| | Bonus | `'Bonus'` | | Overtime | `'Overtime'` | | Deduction | `'Deduction'` | | Loan Repayment | `'Loan Repayment'` | | Loan | `'New Loan'` ← mismatch | | Advance | `'Advance Payment'` ← mismatch | | Advance Repaid | `'Advance Repayment'` ← mismatch | When writing ANY formula, filter, comparison, ORM query, test fixture, CSS class name, or `data-type=` attribute: use the DATABASE value (left column of the model). - `ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment', 'Salary']` in `views.py` uses DB values. - `if adj.type == 'New Loan':` checks the DB value. - `` produces `.badge-type-new-loan` from the DB value. - `` emits the DB value. - Tests use `PayrollAdjustment.objects.create(type='New Loan', ...)`. Only user-facing template TEXT uses the short label β€” via `{{ adj.get_type_display }}`, Django's built-in choices lookup. The label mapping lives in `PayrollAdjustment.TYPE_CHOICES` (`core/models.py`). **How this happened:** originally the adjustment-creation dropdown said "New Loan" because that's what the action meant (_"log a new loan"_). That label then propagated into every other view β€” tables, badges, reports. On 24 Apr 2026 we renamed the user-visible labels to be shorter and cleaner BUT deliberately kept the database values untouched β€” to avoid breaking historic rows, tests, and hardcoded string comparisons across ~30 source locations. **Symptom of getting this wrong:** code that filters for `type='Loan'` returns zero rows. Fix: use `type='New Loan'`. **"Manager / Salaried" is a Path-A display label** (same pattern as "New Loan"β†’"Loan"): the model stays `Worker`, the discriminator is `Worker.pay_type` (`'daily'` | `'fixed'`). No `Manager` model/table. **Adding a value to `PayrollAdjustment.TYPE_CHOICES` DOES generate an `AlterField` migration in this codebase** β€” Django tracks `choices` in migration state, so `makemigrations --check` flags it; always commit the generated migration. Precedent `0012`; this feature added `0017` (no-op AlterField for the new `'Salary'` choice). ## Key Business Rules - All business logic lives in the `core/` app β€” do not create additional Django apps - Workers have a `daily_rate` property: `monthly_salary / Decimal('20.00')` - Admin = `is_staff` or `is_superuser` (checked via `is_admin(user)` helper in views.py) - Supervisors see only their assigned projects, teams, and workers - Admins have full access to payroll, adjustments, and resource management - WorkLog is the central attendance record β€” links workers to projects on specific dates - Attendance logging includes conflict detection (prevents double-logging same worker+date+project) - Loans have automated repayment deductions during payroll processing - Cascading deletes use SET_NULL for supervisors/teams to preserve historical data ## SiteReport / "Log Today's Work" β€” REMOVED 17 May 2026 **SiteReport / "Log Today's Work" β€” REMOVED 17 May 2026.** Feature deleted (model/table/UI/routes; migration `0018_delete_sitereport`). To be rebuilt from scratch later β€” see `docs/plans/2026-05-17-site-report-removed-capture.md`, which preserves the schema-as-Python design (flexible `metrics` JSONField, single Python source of truth, no-migration metric adds) for the future rebuild. Nothing site-report-related exists in the working app; the post-attendance flow now simply returns to the dashboard. ## Absence-to-PayrollAdjustment cascade (May 2026) `Absence.is_paid=True` auto-creates a Bonus PayrollAdjustment at `worker.daily_rate`, inheriting `absence.project` for cost attribution. Linked via `Absence.payroll_adjustment` OneToOneField. Logic lives in `_sync_absence_payroll_adjustment(absence)` in `core/views.py` β€” called from `absence_log`, `absence_log_confirm`, `absence_edit`, and any future quick-action save path. Wrapped in `transaction.atomic()` to prevent orphaned adjustments on partial failure. Edit / delete cascades: - Toggle `is_paid` True β†’ False β†’ adjustment is deleted; refuses (raises ValueError) if adjustment is already paid (`payroll_record` is set). Caller surfaces this as a messages.error to admin. - Toggle `is_paid` False β†’ True β†’ fresh Bonus adjustment created. - Toggle `is_paid` True β†’ True (re-save while paid) β†’ adjustment is LEFT ALONE (admin may have manually edited the amount; we don't second-guess). See `test_paid_with_existing_adj_is_idempotent`. - Delete of Absence cascades to delete the unpaid linked adjustment. If the adjustment is already paid, the delete is refused with a messages.error. The "Submit + Log Absences" button on `/attendance/log/` lets admins jump from logging attendance straight to `/absences/log/` pre-filled with the same date, team, and project. Uses `next_action=log_absences` POST param; default Submit returns to the dashboard. Permission scoping helper: `_absence_user_queryset(user)` in `core/views.py` is the single authority for "which absences can this user see/touch". Admin sees all; supervisor sees absences for workers in any team they supervise (`worker__teams__supervisor=user`). ## Manager / Salaried pay (May 2026) Managers are just `Worker(pay_type='fixed')` (Path-A β€” no new model; "Manager / Salaried" is a display-only label, discriminator is `pay_type`). They are excluded from **attendance + absence pickers ONLY** (`AttendanceLogForm`, `_build_team_workers_map`, attendance cost-estimate loop; `AbsenceLogForm`, `AbsenceEditForm`). **CRITICAL invariant:** managers MUST stay selectable in payroll modals (`PayrollAdjustmentForm`, the Add-Adjustment modal) and `TeamForm`. Safety rationale: a manager can never reach a `WorkLog`, so all WorkLog-derived money math (daily-wage / outstanding / per-project labour cost) is provably unchanged β€” managers are paid purely through the `Salary` adjustment. Paid via the `Salary` adjustment type through `add_adjustment`: project-required; "Pay Immediately" β†’ isolated `PayrollRecord` (exact New Loan pattern); unpaid β†’ generic pending row, netted later by `_process_single_payment` (NOT modified). Report `_build_report_context` exposes `salaried_cost_by_project` shown as a separate per-project "Management / Salaried Cost" card β€” NEVER merged into WorkLog-derived daily labour cost. Clean payslip layout via an `is_salary` flag that mirrors `is_advance`/`is_loan` across `pdf/payslip_pdf.html`, `email/payslip_email.html`, `core/payslip.html`. `--badge-salary-*` CSS (dark+light) + `.badge-type-salary`. **Finding managers:** `/workers/?pay_type=fixed` (display-only filter, mirrors the status/team filters) + a "Managers only" client-side toggle on the Add-Adjustment modal picker. Both display-only β€” the modal's `all_workers` queryset is NOT narrowed server-side (preserves the must-stay-payable invariant). DB value `fixed`/`daily` (Path-A); labels "Managers (Salaried)" / "Daily workers". **Salary picker safety:** in the Add-Adjustment modal, choosing type=`Salary` auto-sets the pay-type filter to Managers-only, hides daily rows, and **unticks** any already-selected daily worker (so a `Salary` can never silently be created for a `pay_type='daily'` worker). Switching to any other type resets the filter to "All" and re-shows everyone (no auto-re-tick). Pure JS in `toggleProjectField()` (`payroll_dashboard.html`); not a hard lock β€” manually switching the filter back to "All" is still allowed (deliberate override, not the silent footgun). **Pay Salary quick action:** the home dashboard's admin Quick Actions row has a "Pay Salary" tile linking `/payroll/?action=pay-salary`; `payroll_dashboard.html` JS auto-clicks the existing `paySalaryBtn` on load when that param is present, then strips it via `history.replaceState` (no re-pop on refresh). `?action=` is inert server-side β€” no view/URL change. ## Payroll Constants Defined at top of views.py β€” used in dashboard calculations and payment processing: - **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment', 'Salary']` β€” increase worker's net pay - **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` β€” decrease net pay ## Django ORM gotcha β€” M2M filter + aggregate inflation Chained `.filter(m2m__field=X).filter(m2m__other=Y)` creates **separate JOIN aliases**, producing a cartesian product of rows. `.aggregate(Sum(...))` dedupes via subquery when `distinct()` is present; `.values().annotate(Sum(...))` does NOT β€” it `GROUP BY`s the inflated rows and multiplies sums by NΓ—M (where N and M are the counts of matching related rows). Fix pattern: use `.filter(id__in=Model.objects.filter(m2m__field=X).values('id'))` to keep the outer queryset JOIN-free. See `_build_report_context` in `core/views.py` and `ReportContextFilterInflationTests` in `core/tests.py` for the reference implementation (commit f1e246c, Apr 2026). ## Django ORM gotcha β€” PayrollAdjustment project double-attribution `PayrollAdjustment` has TWO project FKs: a direct `adj.project` and an indirect `adj.work_log.project`. For every **Overtime** adjustment these always point at the same project (see `price_overtime()` β€” it sets BOTH). When rolling up "costs per project" you typically want the OR-union β€” "adjustments where either FK points to project P". - **Correct**: `Q(project_id__in=ids) | Q(work_log__project_id__in=ids)` filter + `.annotate(effective_project_id=Coalesce('project_id', 'work_log__project_id'))` + `.values('effective_project_id', ...).annotate(total=Sum('amount'))`. Each row contributes to exactly ONE project. - **WRONG**: two separate filtered querysets (one per FK) summed in Python. Any row with BOTH FKs set (every Overtime) gets counted twice. Bit us during the Apr 2026 perf pass β€” Coalesce fix is commit `167c821`. Regression test: `PayrollDashboardAdjustmentAggregationTests` in `core/tests.py`. See `payroll_dashboard()` in `core/views.py` for the reference implementation on both the unpaid-outstanding card and the paid-monthly stacked chart. ## PayrollAdjustment Type Handling - **Bonus / Deduction** β€” standalone, require a linked Project - **New Loan** β€” creates a `Loan` record (`loan_type='loan'`); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments - **Advance Payment** β€” **auto-processed immediately** (never sits in Pending): creates `Loan` (`loan_type='advance'`), creates PayrollRecord, sends payslip email, and auto-creates an "Advance Repayment" for the next salary. Requires a Project (for cost tracking) and at least one unpaid work log (otherwise use New Loan). - **Overtime** β€” links to `WorkLog` via `adj.work_log` FK; managed by `price_overtime()` view - **Loan Repayment** β€” links to `Loan` (loan_type='loan') via `adj.loan` FK; loan balance changes during payment processing - **Advance Repayment** β€” auto-created when an advance is paid; deducts from advance balance during `process_payment()`. If partial repayment, remaining balance converts advance to regular loan (`loan_type` changes from 'advance' to 'loan'). Editable by admin (amount can be reduced before payday). - **Salary** β€” manager / fixed monthly pay (additive, project-required). Has a "Pay Immediately" path mirroring New Loan (isolated `PayrollRecord`); unpaid nets via `_process_single_payment`. Only for `Worker(pay_type='fixed')` β€” see "Manager / Salaried pay" section. ## Outstanding Payments Logic (Dashboard) The dashboard's outstanding amount uses **per-worker** checking, not per-log: - For each WorkLog, get the set of `paid_worker_ids` from linked PayrollRecords - A worker is "unpaid for this log" only if their ID is NOT in that set - This correctly handles partially-paid logs (e.g., one worker paid, another not) - Unpaid adjustments: additive types increase outstanding, deductive types decrease it ## Payroll dashboard query-count baselines (post Apr 2026 perf pass) Target ranges after `payroll_dashboard()` was optimized with batched aggregates + `Prefetch(to_attr='active_workers_cached')` + Coalesce-based project attribution (commits `61c485f` + `167c821`): - `/` (admin dashboard) β€” ~15 queries - `/payroll/?status=pending` β€” ~24 - `/payroll/?status=history` β€” ~24 - `/payroll/?status=loans` β€” ~25 - `/payroll/?status=adjustments` β€” ~32 If any of these jumps meaningfully (>50%) after a future change, an N+1 was reintroduced. Profile with Django Debug Toolbar (see Profiling section below) to find it. The test suite does NOT have `assertNumQueries` guards on these views β€” deliberate YAGNI for now, worth adding if regressions become a pattern. ## Commands ```bash # Local development (SQLite) set USE_SQLITE=true && python manage.py runserver 0.0.0.0:8000 # Or use run_dev.bat which sets the env var python manage.py migrate # Apply database migrations python manage.py setup_groups # Create Admin + Work Logger permission groups python manage.py setup_test_data # Populate sample workers, projects, logs python manage.py import_production_data # Import real production data (14 workers) python manage.py collectstatic # Collect static files for production python manage.py check # System check # Run the test suite (sets env vars inline β€” works in Git Bash; on cmd.exe use `set` first) USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2 ``` ## Profiling locally β€” Django Debug Toolbar Installed as a dev-only dependency in `requirements.txt` since Apr 2026. Triple-gated in `config/settings.py`: only loads when **DEBUG=true AND USE_SQLITE=true AND NOT running tests**. Never loads in production β€” prod has neither flag, and the test-run gate exists because the toolbar emits an E001 system-check error + breaks template rendering when DEBUG=false (which Django forces during `manage.py test`). To profile a page: start the dev server normally (`run_dev.bat` or inline `USE_SQLITE=true DJANGO_DEBUG=true python manage.py runserver`), log in as admin, navigate to any URL, click the toolbar tab on the right edge. The **SQL panel** shows query count + highlights any duplicate-query groups β€” the go-to tool for N+1 hunting. See the "Payroll dashboard query-count baselines" section for expected numbers on hot pages. ## Development Workflow - Active development branch: `ai-dev` (PR target: `master`) - Local dev uses SQLite: set `USE_SQLITE=true` environment variable - Preview server config: `.claude/launch.json` β†’ runs `run_dev.bat` - Admin check in views: `is_admin(request.user)` helper (top of views.py) - "Unpaid" adjustment = `payroll_record__isnull=True` (no linked PayrollRecord) - POST-only mutation views pattern: check `request.method != 'POST'` β†’ redirect - Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements - Atomic transactions: `process_payment()` uses `select_for_update()` to prevent duplicate payments - Payslip Preview modal ("Worker Payment Hub"): shows earnings, adjustments, net pay, **plus** active loans/advances with inline repayment forms. Uses `refreshPreview()` JS function that re-fetches after AJAX repayment submission. Repayment POSTs to `add_repayment_ajax` which creates a PayrollAdjustment (balance deduction only happens during `process_payment()`) - Advance Payment auto-processing: `add_adjustment` immediately creates PayrollRecord + sends payslip when an advance is created. Also auto-creates an "Advance Repayment" adjustment for the next salary cycle. Uses `_send_payslip_email()` helper (shared with `process_payment`) - Advance-to-loan conversion: When an Advance Repayment is only partially paid, `process_payment` changes the Loan's `loan_type` from 'advance' to 'loan' so the remainder is tracked as a regular loan - Split Payslip: Preview modal has checkboxes on work logs and adjustments (all checked by default). `process_payment()` accepts optional `selected_log_ids` / `selected_adj_ids` POST params to pay only selected items. Falls back to "pay all" if no IDs provided (backward compatible with the quick Pay button). - Team Pay Schedules: Teams have optional `pay_frequency` + `pay_start_date` fields. `get_pay_period(team)` calculates current period boundaries by stepping forward from the anchor date. The preview modal shows a "Split at Pay Date" button that auto-unchecks items after the `cutoff_date` (end of last completed period β€” includes ALL overdue work, not just one period). `get_worker_active_team(worker)` returns the worker's first active team. - Pay period calculation: `pay_start_date` is an anchor (never needs updating). Weekly=7 days, Fortnightly=14 days, Monthly=calendar month stepping. Uses `calendar.monthrange()` for month-length edge cases (no `dateutil` dependency). - Batch Pay: "Batch Pay" button on payroll dashboard opens a modal with two radio modes β€” **"Until Last Paydate"** (default, splits at last completed pay period per team schedule) and **"Pay All"** (includes all unpaid items regardless of date). Preview fetches from `batch_pay_preview` with `?mode=schedule|all`. Workers without team pay schedules are skipped in schedule mode but included in Pay All mode. `batch_pay` POST endpoint processes each worker in independent atomic transactions; emails are sent after all payments complete. Uses `_process_single_payment()` shared helper (same logic as individual `process_payment`). Modal includes team filter dropdown and 3-option loan filter (All / With loans only / Without loans). - 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//` (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//edit/` (single-page form for name, supervisor, pay schedule, and workers M2M). **Project pages**: `/projects/`, `/projects//` (tabs: Profile/Supervisors/Teams/Workers/History), `/projects//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 + team filter β€” team filter uses Team.workers M2M membership, special value `none` matches workers not assigned to any team), `/workers//` (detail with Profile/Certifications/Warnings/**Absences**/History tabs β€” Absences tab shows YTD totals chip row + 50 most-recent absence rows), `/workers//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 `