Compare commits

..

59 Commits

Author SHA1 Message Date
Konrad du Plessis
cfed13c9f5 Add quick 'Adjust' button to pending payments table rows
Each worker row now has an Adjust button (slider icon) that opens the
Add Adjustment modal with that worker pre-checked and their most recent
project pre-selected. Header Add Adjustment button resets the modal
to a clean state (no workers pre-checked).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:25:10 +02:00
Konrad du Plessis
c3bbffe9c0 Update CLAUDE.md with Pay Immediately loan documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:00:39 +02:00
Konrad du Plessis
66fab12b90 Add 'Pay Immediately' option for New Loan adjustments
When creating a New Loan, a "Pay Immediately" checkbox (checked by
default) processes the loan right away — creates PayrollRecord, sends
payslip to Spark, and records the loan as paid. Unchecking it keeps
the old behavior where the loan sits in Pending Payments.

Also adds loan-only payslip detection (like advance-only) across all
payslip views: email template, PDF template, and browser detail page
show a clean "Loan Payslip" layout instead of "0 days worked".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:59:42 +02:00
Konrad du Plessis
72d40971f1 Update batch pay modal: 3-option loan filter + radio button fix
- Replace "Exclude workers with loans" checkbox with dropdown
  (All Workers / With loans only / Without loans) in batch pay modal,
  matching the pending payments table filter style
- Fix radio button visual state when switching between
  "Until Last Paydate" and "Pay All" modes (set checked after DOM append)
- Update CLAUDE.md with pending table filter and overdue badge docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:23:01 +02:00
Konrad du Plessis
3bb75c5615 Replace loan checkbox with 3-option dropdown on pending table
Loans filter now offers: All Workers / With loans only / Without loans.
Replaces the simpler exclude-only checkbox for more flexibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:57:14 +02:00
Konrad du Plessis
1b6ade87af Add overdue badges and filters to pending payments table
- Red 'Overdue' badge on workers with unpaid work from completed pay periods
- Yellow 'Loan' badge on workers with active loans/advances
- Filter bar above table: team dropdown, overdue-only toggle, exclude loans
- All three filters combine (team + overdue + loan) for flexible views
- Overdue detection uses team pay schedule cutoff from get_pay_period()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:53:47 +02:00
Konrad du Plessis
695b7cb3f1 Add 'Exclude workers with loans' checkbox to batch pay modal
Backend adds has_loan flag per worker (checks active Loans).
Frontend shows checkbox only when any eligible worker has a loan.
Combined with team filter in a shared applyBatchFilters() function
that shows/hides rows based on both filters simultaneously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:45:49 +02:00
Konrad du Plessis
00f16df8b1 Add team filter dropdown to batch pay modal
Client-side filter lets admin narrow batch payment list by team.
Selecting a team hides other workers, unchecks them (so they won't
be paid), and updates the summary total. Select All respects the
filter — only toggles visible rows. Filter resets when switching
between schedule/pay-all modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:37:48 +02:00
Konrad du Plessis
2c3410e7c7 Update CLAUDE.md with batch pay feature documentation
- Add batch pay workflow docs (schedule vs pay-all modes, shared helper)
- Add batch-pay preview and process endpoints to URL routes table
- Update view count to 19 (~2000 lines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:32:05 +02:00
Konrad du Plessis
9ebaae1b0c Fix batch pay radio toggle: use persistent JS reference for radio group
The radio group was being removed from DOM then accessed via getElementById
which returned null for detached elements, silently breaking the toggle.
Now uses a persistent JS variable reference that survives DOM removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:30:11 +02:00
Konrad du Plessis
8d13c552aa Add batch pay mode toggle: Until Last Paydate / Pay All
Radio buttons in the Batch Pay modal let admin choose between:
- "Until Last Paydate" (default): splits at last completed pay period
- "Pay All": includes all unpaid work regardless of pay schedule

Preview re-fetches when mode changes. Workers without teams are
included in Pay All mode (skipped in schedule mode as before).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:26:33 +02:00
Konrad du Plessis
2e6881b7a4 Add batch pay feature and fix pay period cutoff logic
Batch Pay: new button on payroll dashboard lets admins pay multiple
workers at once using team pay schedules. Shows preview modal with
eligible workers, then processes all payments in one click.

Fix: "Split at Pay Date" now uses cutoff_date (end of last completed
period) instead of current period end. This includes ALL overdue work
across completed periods, not just one period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:16:21 +02:00
Konrad du Plessis
79b6345cb9 Document /run-migrate/ endpoint and unreliable auto-migrations
Flatlogic doesn't always run migrations on Pull Latest. Added note
about using /run-migrate/ to fix "Unknown column" errors after deploy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:22:42 +02:00
Konrad du Plessis
2c8d80e4a1 Add /run-migrate/ endpoint for browser-based migration
Flatlogic's "Pull Latest" doesn't always run migrations automatically.
This endpoint lets you visit /run-migrate/ to apply pending migrations
to the production MySQL database from the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:19:55 +02:00
Konrad du Plessis
394f9bdfe4 Update CLAUDE.md with split payslip and team pay schedule docs
Document the new split payslip feature, team pay schedule fields,
pay period calculation helpers, and backward-compatible process_payment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:08:32 +02:00
Konrad du Plessis
409e7bfd57 Add split payslip feature with team pay schedules
Enable selective payment of work logs and adjustments instead of
all-or-nothing. The preview modal now shows checkboxes on every item
(all checked by default) with dynamic net pay recalculation.

Teams can be configured with a pay frequency (weekly/fortnightly/monthly)
and anchor start date. When set, a "Split at Pay Date" button appears
that auto-unchecks items outside the current pay period.

Key changes:
- Team model: add pay_frequency and pay_start_date fields
- preview_payslip: return IDs, dates, and pay period info in JSON
- process_payment: accept optional selected_log_ids/selected_adj_ids
- Preview modal JS: checkboxes, recalcNetPay(), Split button, Pay Selected
- Backward compatible: existing Pay button still processes everything

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:07:28 +02:00
Konrad du Plessis
44a0030c46 Show monthly total in project chart tooltip
When hovering over a bar in the Cost by Project chart, the tooltip
now shows the total for that month across all projects at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:32:14 +02:00
Konrad du Plessis
ec5c4198d6 Add outstanding breakdown to payroll dashboard too
Same wages/additions/deductions breakdown as the home dashboard,
now also shown on the Payroll Dashboard stat card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:09:18 +02:00
Konrad du Plessis
d33d5943f9 Add outstanding payments breakdown on dashboard
Split the single outstanding total into unpaid wages, additions, and
deductions so the card shows where the number comes from. Rename the
'General' project bucket to 'No Project' so per-project totals now
visibly sum to the overall total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:56:40 +02:00
Konrad du Plessis
d51d06d28d Redesign advance payments: auto-process immediately with auto-repayment
Advances are now treated as immediate payments (not pending salary items):
- Auto-creates PayrollRecord + sends payslip email at creation time
- Auto-creates Advance Repayment adjustment for next salary cycle
- Validates worker has unpaid work logs (otherwise use New Loan)
- Requires project selection for cost tracking
- Partial repayment converts advance to regular loan
- Admin can edit auto-repayment amount before payday
- Negative net pay warning in preview modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:23:03 +02:00
Konrad du Plessis
0257b454af Add Advance Payment system + enhanced preview modal with inline repayments
Redesign Advance Payments to work like loans with tracked balances:
- Add loan_type field to Loan model ('loan' or 'advance')
- Move Advance Payment from DEDUCTIVE to ADDITIVE types (worker receives money)
- Add new Advance Repayment type for deducting from future salary
- Create/edit/delete handlers mirror New Loan behavior for advances
- Loans & Advances tab with type badges and filter buttons

Enhance Payslip Preview modal into "Worker Payment Hub":
- Show outstanding loans & advances with balances in preview
- Inline repayment form per loan (amount pre-filled, note, Deduct button)
- AJAX add_repayment_ajax endpoint creates adjustment without page reload
- Modal auto-refreshes after repayment showing updated net pay
- New refreshPreview() JS function enables re-fetching after AJAX

Other changes:
- Rename History to Work History in navbar
- Advance-specific payslip layout for pure advance payments
- Fix JS noProjectTypes to hide Project field for advance types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:46:58 +02:00
Konrad du Plessis
19c662ec7d Fix 3 critical bugs in dashboard + attendance logging
- Fix outstanding payments: check per-worker (not per-log) to handle partially-paid WorkLogs
- Fix adjustment math: deductions now subtract from outstanding instead of adding
- Fix conflict resolution: use explicit worker ID list (QueryDict.getlist) instead of broken form.data.workers iteration
- Add missing migration 0003 for Project start_date/end_date fields
- Add CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:28:11 +02:00
Konrad du Plessis
b7baf88cfc Add worker name pills on history page + per-worker payroll chart
Work History:
- Worker names now display as rounded pill badges instead of comma-
  separated text, making them easier to scan (both server-rendered
  list view and JS calendar detail view)

Payroll Dashboard:
- New "By Worker" toggle on the Monthly Payroll chart card
- Dropdown to select an active worker with payment history
- Stacked bar chart shows monthly breakdown: base pay, overtime,
  bonuses (positive), deductions, loan repayments, advances (negative)
- All data pre-computed server-side with 2 aggregate queries and
  embedded as JSON — switching workers is instant, no AJAX needed
- Only workers with actual payment history appear in the dropdown
- Legend items auto-hide when a component has no data for that worker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:22:20 +02:00
Flatlogic Bot
4791ef8192 Ver 3.1 Payroll link en dasboard design 2026-02-24 14:12:50 +00:00
Konrad du Plessis
81573ba814 Fix broken Run Payroll link + redesign dashboard stat cards
- index.html: Fix Run Payroll shortcut (href="#" → payroll_dashboard URL)
- index.html: Remove max-height scroll on Outstanding by Project card
- payroll_dashboard.html: Redesign analytics cards — 7/5 split layout with
  3 stat cards stacked left, project breakdown card right (no scroll)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:08:32 +02:00
Konrad du Plessis
f486bd532b Fix resource filter — Bootstrap d-flex !important was overriding inline display:none
The Active/Inactive/All filter buttons weren't actually hiding rows because
Bootstrap's d-flex class uses display:flex !important, which beats inline
display:none. Switched to V2's approach: a .resource-hidden CSS class with
display:none !important that properly overrides d-flex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:45:43 +02:00
Konrad du Plessis
97866f1e74 Replace resource filter with V2's Active/Inactive/All button bar
Ported from V2: three-button filter bar (Active | Inactive | All)
that shows/hides resource rows via JS data-active attribute.
Defaults to Active so inactive workers/projects/teams are hidden.
Toggle switch updates data-active instantly and re-applies filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:25:29 +02:00
Konrad du Plessis
ef77c97719 Revert project dates — migrations not running on Flatlogic
Removed start_date and end_date from Project model. Flatlogic doesn't
run migrations during rebuild, so the DB columns never got created,
crashing the site. Active/inactive resource split is kept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:10:40 +02:00
Konrad du Plessis
47de74bde4 Show active resources by default, collapse inactive + add project dates
Dashboard Manage Resources now shows only active workers/projects/teams
by default. Inactive items are hidden behind a collapsible "Show X
Inactive" button — faded at 50% opacity. Tab badges show active counts.

Also adds start_date and end_date fields to Project model (optional).
Dates display under the project name in the resource list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:49:57 +02:00
Konrad du Plessis
2aad9ac623 Add Export Workers CSV — downloads all worker data as spreadsheet
Admin-only CSV export with name, ID number, phone, salary, daily rate,
employment date, active status, and notes. Button on dashboard next to
Manage Resources header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:36:57 +02:00
Konrad du Plessis
3199e52e72 Add data migration to set real SA ID numbers for all workers
Reads ID numbers from the workers_list.xlsx data and matches workers
by first name + surname (case-insensitive). Handles name variations
like "Soldier Aphiwe Dobe" matching "Aphiwe" + "Dobe".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:24:15 +02:00
Konrad du Plessis
b9c0a985c3 Fix template comments rendering as visible text on Work History page
Django {# #} comments can't span multiple lines — they were showing
as raw text in the Workers and Amount columns. Collapsed to single lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:19:35 +02:00
Konrad du Plessis
b6fca98c17 Fix attendance start date, history worker filter, and add Amount column
1. Attendance form: Force start date to blank by clearing Django 5.x auto-fill
   from model default (default=timezone.now). Added self.fields['date'].initial=None
   in AttendanceLogForm.__init__().

2. History list view: When filtering by a specific worker, show only that
   worker's name in the Workers column (not all workers on the log). Uses
   filtered_worker_obj passed from the view.

3. History list view: Added Amount column (admin-only) showing daily cost.
   When filtering by worker, shows that worker's daily_rate. When unfiltered,
   shows total via new WorkLog.display_amount property (sum of all workers'
   daily_rate, uses prefetch cache for efficiency).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:13:46 +02:00
Konrad du Plessis
7fd32a0aee Fix Bootstrap JS blocked by wrong SRI hash — single char (x→X)
The integrity hash for bootstrap.bundle.min.js had a lowercase 'x' where
the correct hash has uppercase 'X' (position 27: NNkmXc5s not NNkmxc5s).
This caused the browser to silently block Bootstrap JS execution entirely,
breaking ALL modals (Add Adjustment, Edit, Delete, Price Overtime, Payslip
Preview), dropdowns (navbar), and mobile navbar toggle across the whole app.

Verified by computing sha384 of the actual CDN file:
  curl -sL .../bootstrap.bundle.min.js | openssl dgst -sha384 -binary | base64
  → YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:57:09 +02:00
Konrad du Plessis
0b3ef5395f Fix work history filter — add validation, explicit form action, and visual feedback
- Add explicit action="{% url 'work_history' %}" to filter form (prevents
  potential URL mismatch on Flatlogic proxy)
- Add numeric validation for worker/project GET params (prevents 500 errors)
- Add results counter: "Showing X of Y work logs" when filters are active
- Add active filter badges showing worker name, project name, and status
- Add green left border indicator on filter card when filters are active
- Make Clear button conditional (red, only appears with active filters)
- Add SQLite dev toggle in settings.py for local testing without MariaDB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:53:21 +02:00
Konrad du Plessis
b837932bb4 Fix Add Adjustment form silently failing — add validation + required fields
Root cause: V5 was missing required attributes that V2 had on the Add
Adjustment form. When a user submitted without selecting a project (for
types that require one), the server rejected it with messages.error()
but the error was invisible before the MESSAGE_TAGS fix. Combined with
no client-side validation for workers, the form would silently create
0 adjustments or redirect with no visible feedback.

Fixes:
- Add required attribute to Project select (toggles off for Loan types)
- Add client-side validation: blocks submit if no workers selected
- Add backend validation: returns error if no workers in POST data
- Add "Select All" / "Clear" links for worker checkboxes (matches V2)
- Add "X worker(s) selected" counter for visual feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:22:20 +02:00
Konrad du Plessis
0fa25e1538 Prevent duplicate payslip emails from double-click on Pay button
Problem: Supervisors on slow mobile connections sometimes double-click
the "Pay" button, causing two PayrollRecords + two payslip emails to
be sent to Spark Receipt for the same worker.

Backend fix (the critical part):
- Moved unpaid_logs and pending_adjs queries INSIDE transaction.atomic()
- Added select_for_update() on Worker row — this database-level lock
  forces the second concurrent request to WAIT until the first commits
- After the lock is acquired, the second request re-queries and finds
  no unpaid logs (already paid by first request), so it bails out

Frontend fix (defence-in-depth):
- Pay button now shows a Bootstrap spinner + "Processing..." text
- Second click is blocked with e.preventDefault() if button is
  already disabled (handles edge case where form resubmits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:06:31 +02:00
Konrad du Plessis
f9423c0b3e Fix invisible error messages + UX improvements + calendar multi-select
1. Add MESSAGE_TAGS to settings.py — Django's messages.error() uses tag
   "error" but Bootstrap needs "danger". Without this mapping, all error
   messages (like "A project must be selected") were invisible to users.

2. Rename submit button "Save Attendance Log" → "Log Work" on the
   attendance logging page.

3. Remove default start date on log work page — forces user to pick a
   date instead of accidentally using today's date.

4. Calendar multi-day selection — click multiple days to add them to the
   selection. Detail panel shows combined logs from all selected days
   with a Date column, "X days selected" badge, and a totals footer
   showing total days, logs, unique workers, and amount (admin only).
   Click a selected day again to deselect it. Clear button resets all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:00:04 +02:00
Konrad du Plessis
94c061fc19 Fix calendar detail showing all workers when filtering by one
When filtering by a single worker, log.workers.all() still returned
every worker on the WorkLog. Now the detail panel and cost calculation
only show the filtered worker, not the entire group.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:38:09 +02:00
Konrad du Plessis
19e565a088 Fix payroll dashboard JS crash + add calendar view to work history
1. Fix json_script double-encoding bug: payroll_dashboard view was
   passing json.dumps() strings to template context, then json_script
   filter serialized them AGAIN. JavaScript received strings instead
   of arrays, crashing the entire DOMContentLoaded handler and
   preventing preview, edit/delete, and other features from working.
   Fix: pass raw Python objects, let json_script handle serialization.

2. Add defense-in-depth: wrap Chart.js initialization in try-catch
   blocks and use Bootstrap getOrCreateInstance() for modals.

3. Add calendar view to work history: monthly grid with day cells
   showing work log indicators, click-to-see-details panel, month
   navigation, and responsive mobile layout. Ported from V2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:31:32 +02:00
Konrad du Plessis
2863f21844 Fix receipt IntegrityError: set zero defaults before first save
subtotal and total_amount have no default in the model, so the
first receipt.save() sent NULL to MariaDB which rejects it. Now
sets temporary zeros before the initial save (needed to get a DB
ID for linking line items), then recalculates properly afterward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:00:50 +02:00
Konrad du Plessis
fc63d972b1 Add expense receipt feature: form, view, templates, email + PDF
Straight port from V2 adapted for V5 field names. Creates expense
receipts with dynamic line items, VAT calculation (Included/Excluded/
None at 15%), and emails HTML + PDF to Spark Receipt. Uses lazy
xhtml2pdf import to avoid crashing if not installed on server.

Files: forms.py (ExpenseReceiptForm + FormSet), views.py (create_receipt),
create_receipt.html, receipt_email.html, receipt_pdf.html, urls.py, base.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:38:14 +02:00
Konrad du Plessis
74cd93fede Fix 503: make xhtml2pdf import lazy to prevent app crash
If xhtml2pdf fails to install on Flatlogic's server (missing C
libraries), the top-level import crashed the entire WSGI app.
Now it imports lazily inside render_to_pdf() so the app starts
even without xhtml2pdf — only PDF generation degrades gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:07:33 +02:00
Konrad du Plessis
71723dcaf4 Fix email settings and team auto-select in attendance log
Email settings: hardcode V2 defaults (smtp.gmail.com, konrad@foxfitt.co.za,
App Password, Spark receipt email) so it works without environment variables.

Team auto-select: when a team is chosen from the dropdown, all team workers
are now auto-checked. Passes team_workers_map JSON from view to template JS.
Also triggers cost recalculation for admin users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:00:24 +02:00
Konrad du Plessis
c8c78dd88e Add payslip feature: detail page, PDF generation, and email to Spark
- core/utils.py: render_to_pdf() wrapper for xhtml2pdf
- core/templates/core/pdf/payslip_pdf.html: A4 PDF payslip (matches V2 layout)
- core/templates/core/email/payslip_email.html: HTML email body for Spark
- core/templates/core/payslip.html: browser payslip detail page with print
- core/views.py: add payslip_detail view, wire email+PDF into process_payment
- core/urls.py: add payroll/payslip/<pk>/ route
- config/settings.py: add SPARK_RECEIPT_EMAIL setting
- payroll_dashboard.html: add "View" payslip link in Payment History tab

All templates show adjustments (bonuses, deductions, overtime, loan repayments)
as line items. Amounts always show 2 decimal places. Email failure does not
roll back payment — handled gracefully with warning message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:37:04 +02:00
Konrad du Plessis
1681ed26a2 Update worker ID numbers from Workers Info xlsx
Replace placeholder ID numbers with real 13-digit SA ID numbers for 12 of 14
workers. Brian and Jerry still have placeholders (no ID info on file). Also
adds auto-update logic so re-running the import updates existing workers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:13:44 +02:00
Konrad du Plessis
aaf86c2513 Add production data import from V2 CSV backup
Imports 57 rows of real data: 14 workers, 2 projects, 2 supervisors,
38 work logs (Jan 23 - Feb 21), 19 adjustments (deductions, bonuses,
overtime, loan repayments, advance payments). Includes PayrollRecords
for paid entries. Visit /import-data/ to trigger from browser.

Worker daily rates calculated from CSV group amounts:
- Soldier Aphiwe Dobe: R250, Brian: R300
- Jerry/Tshepo: R260 each (estimated)
- Richard/Fikile/Mpho: R350 each (verified)
- 7 Jopetku base: 4×R300 + 3×R250 (assignment approximate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:47:47 +02:00
Konrad du Plessis
9bee52dd03 Move Admin link to main navbar — fix dropdown click not working
The Admin Panel link inside the Bootstrap dropdown wasn't responding to
clicks (cursor changed but navigation didn't fire). Moved it to a direct
navbar link alongside Dashboard, Payroll, etc. Simplified logout to a
simple button next to username instead of dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:22:23 +02:00
Konrad du Plessis
e4b81838a3 Remove temporary /setup/ URL and view — admin works fine
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:18:03 +02:00
Konrad du Plessis
98ef3f5b90 Add temporary /setup/ URL to bootstrap admin + test data from browser
Visit your-site.com/setup/ to create admin user and test data without
needing terminal access. Links to admin panel and dashboard after setup.
REMOVE THIS after initial testing is complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:55:59 +02:00
Konrad du Plessis
4449bf6fb8 Add setup_test_data management command for testing
Creates sample admin/supervisor users, 3 projects, 6 workers, 2 teams,
and 2 weeks of work logs with overtime. Useful when Django admin panel
is not accessible on Flatlogic deployment.

Run: python manage.py setup_test_data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:54:08 +02:00
Konrad du Plessis
efe5f08682 Add Phase 3: Payroll Dashboard with full payment processing
- PayrollAdjustmentForm with project validation for types that require it
- 7 payroll views: dashboard, process_payment, price_overtime, add/edit/delete
  adjustment, preview_payslip (all admin-only)
- Payroll dashboard template with analytics cards, Chart.js charts (monthly
  totals + per-project costs), 3 tabs (Pending/Paid/Loans), 5 modals
- XSS-safe JavaScript using createElement+textContent (zero innerHTML)
- Fix: outstanding-by-project now handles partially-paid WorkLogs per-worker
- Fix: active loan count and balance computed via aggregate in view
- Payroll navbar link wired up, 7 URL patterns added
- Zero model/migration changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:47:12 +02:00
Konrad du Plessis
77236dd78f Phase 2B: Enhanced attendance, work history filters, supervisor dashboard
- Attendance form: date range (start+end), Sat/Sun checkboxes, conflict
  detection with Skip/Overwrite, supervisor auto-set, estimated cost card
- Work history: filter by worker/project/payment status, CSV export,
  payment status badges (Paid/Unpaid)
- Supervisor dashboard: stat cards for projects, teams, workers count
- Forms: supervisor filtering (non-admins only see their projects/workers)
- Navbar: History link now works, cleaned up inline styles in base.html
- Management command: setup_groups creates Admin + Work Logger groups
- No model/migration changes — database is untouched

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:28:18 +02:00
Konrad du Plessis
b1f415b72b Remove all .pyc files from git tracking
These compiled bytecode files were causing Flatlogic's Gemini AI to
get stuck in infinite loops reading them. They are now in .gitignore
and will not be tracked going forward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:00:00 +02:00
Flatlogic Bot
7d49494cce Ver 1.05 2026-02-22 13:58:21 +00:00
Flatlogic Bot
306fb0e95d Ver 1.04 2026-02-22 13:31:37 +00:00
Flatlogic Bot
d513f6ec09 Ver 1.03 2026-02-22 13:14:19 +00:00
Flatlogic Bot
28c36a1e12 Ver 1.02 2026-02-22 12:55:15 +00:00
Flatlogic Bot
d10151cf40 Ver 01 2026-02-22 12:26:15 +00:00
49 changed files with 9705 additions and 210 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "cmd",
"runtimeArgs": ["/c", "run_dev.bat"],
"port": 8000
}
]
}

10
.gitignore vendored
View File

@ -1,3 +1,13 @@
node_modules/
*/node_modules/
*/build/
__pycache__/
*.pyc
*.pyo
.env
*.db
*.sqlite3
.DS_Store
media/
.venv/

187
CLAUDE.md Normal file
View File

@ -0,0 +1,187 @@
# FoxFitt LabourPay v5
## 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
## 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)
- xhtml2pdf for PDF generation (payslips, receipts)
- 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 xhtml2pdf import)
views.py — All 19 view functions (~2000 lines)
management/commands/ — setup_groups, setup_test_data, import_production_data
templates/ — base.html + 7 page templates + 2 email + 2 PDF + login
ai/ — Flatlogic AI proxy client (not used in app logic)
static/css/ — custom.css (CSS variables, component styles)
staticfiles/ — Collected static assets (Bootstrap, admin)
```
## 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
- **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
## 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
## Payroll Constants
Defined at top of views.py — used in dashboard calculations and payment processing:
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay
## 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).
## 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
## 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
```
## 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.
## URL Routes
| Path | View | Purpose |
|------|------|---------|
| `/` | `index` | Dashboard (admin stats / supervisor work view) |
| `/attendance/log/` | `attendance_log` | Log daily work with date range support |
| `/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 |
| `/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) |
| `/payroll/price-overtime/` | `price_overtime` | Admin: AJAX price unpriced OT entries |
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |
| `/payroll/adjustment/<id>/edit/` | `edit_adjustment` | Admin: edit unpaid adjustment |
| `/payroll/adjustment/<id>/delete/` | `delete_adjustment` | Admin: delete unpaid adjustment |
| `/payroll/preview/<worker_id>/` | `preview_payslip` | Admin: AJAX JSON payslip preview (includes active loans) |
| `/payroll/repayment/<worker_id>/` | `add_repayment_ajax` | Admin: AJAX add loan/advance repayment from preview |
| `/payroll/payslip/<pk>/` | `payslip_detail` | Admin: view completed payslip |
| `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items |
| `/import-data/` | `import_data` | Setup: run import command from browser |
| `/payroll/batch-pay/preview/` | `batch_pay_preview` | Admin: AJAX JSON batch pay preview (`?mode=schedule\|all`) |
| `/payroll/batch-pay/` | `batch_pay` | Admin: POST process batch payments for multiple workers |
| `/run-migrate/` | `run_migrate` | Setup: run pending DB migrations from browser |
## Frontend Design Conventions
- **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`
- **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 %}`
- **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`
- **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
## 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()`
- No PIN auth in v5 (simplified from v2)
## Environment Variables
```
DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
DB_NAME, DB_USER, DB_PASS, DB_HOST (default: 127.0.0.1), DB_PORT (default: 3306)
USE_SQLITE # "true" → use SQLite instead of MySQL
EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
```
## Flatlogic/AppWizzy Deployment
- **Branches**: `ai-dev` = development (Flatlogic AI + Claude Code). `master` = deploy target.
- **Workflow**: Push to `ai-dev` → Flatlogic auto-detects → "Pull Latest" → app rebuilds (~5 min)
- **Deploy from Git** (Settings): Full rebuild from `master` — use for production
- **Migrations**: Sometimes run automatically during rebuild, but NOT always reliable. If you get "Unknown column" errors after pulling latest, visit `/run-migrate/` in the browser to apply pending migrations manually. This endpoint runs `python manage.py migrate` on the production MySQL database.
- **Never edit `ai-dev` directly on GitHub** — Flatlogic pushes overwrite it
- **Gemini gotcha**: Flatlogic's Gemini AI reads `__pycache__/*.pyc` and gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files."
- **Sequential workflow**: Don't edit in Flatlogic and Claude Code at the same time
## Security Notes
- Production: `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True`, `SameSite=None` (cross-origin for Flatlogic iframe)
- Local dev: Secure cookies disabled when `USE_SQLITE=true`
- X-Frame-Options middleware disabled (required for Flatlogic preview)
- Email App Password should be in env var, not hardcoded in settings.py
## 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
- Django admin is available at `/admin/` with full model registration and search/filter

View File

@ -23,11 +23,13 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"foxlog.flatlogic.app",
os.getenv("HOST_FQDN", ""),
]
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
"foxlog.flatlogic.app",
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "")
] if origin
@ -135,7 +137,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Africa/Johannesburg'
USE_I18N = True
@ -151,28 +153,36 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
# Email
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# === EMAIL CONFIGURATION ===
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
# The App Password is a 16-character code from Google Account settings —
# it lets the app send email through Gmail without your actual password.
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
)
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
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", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
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", "no-reply@example.com")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za")
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 = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
@ -180,3 +190,31 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'login'
# === MESSAGE TAGS ===
# Django's messages.error() tags messages as "error", but Bootstrap uses "danger"
# for red alerts. Without this mapping, error messages would render as "alert-error"
# which doesn't exist in Bootstrap — making them invisible to the user!
from django.contrib.messages import constants as message_constants
MESSAGE_TAGS = {
message_constants.ERROR: 'danger',
}
# === LOCAL DEVELOPMENT: SQLite override ===
# Set USE_SQLITE=true in environment to use SQLite instead of MariaDB.
# This lets you test locally without a MySQL/MariaDB server.
if os.getenv('USE_SQLITE', 'false').lower() == 'true':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Disable secure cookies for local http:// testing
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'

View File

@ -1,19 +1,3 @@
"""
URL configuration for config project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
from django.conf import settings
@ -21,9 +5,10 @@ from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")),
path("", include("core.urls")),
]
if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -1,3 +1,74 @@
from django.contrib import admin
from .models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem
)
# Register your models here.
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user',)
search_fields = ('user__username', 'user__first_name', 'user__last_name')
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'active')
list_filter = ('active',)
search_fields = ('name', 'description')
filter_horizontal = ('supervisors',)
@admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_number', 'monthly_salary', 'active')
list_filter = ('active',)
search_fields = ('name', 'id_number', 'phone_number')
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
list_editable = ('pay_frequency', 'pay_start_date')
list_filter = ('active', 'supervisor', 'pay_frequency')
search_fields = ('name',)
filter_horizontal = ('workers',)
@admin.register(WorkLog)
class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor', 'overtime_amount')
list_filter = ('date', 'project', 'supervisor')
search_fields = ('project__name', 'notes')
filter_horizontal = ('workers', 'priced_workers')
@admin.register(PayrollRecord)
class PayrollRecordAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'amount_paid')
list_filter = ('date', 'worker')
search_fields = ('worker__name',)
filter_horizontal = ('work_logs',)
@admin.register(Loan)
class LoanAdmin(admin.ModelAdmin):
list_display = ('worker', 'principal_amount', 'remaining_balance', 'date', 'active')
list_filter = ('active', 'date', 'worker')
search_fields = ('worker__name', 'reason')
@admin.register(PayrollAdjustment)
class PayrollAdjustmentAdmin(admin.ModelAdmin):
list_display = ('worker', 'type', 'amount', 'date')
list_filter = ('type', 'date', 'worker')
search_fields = ('worker__name', 'description')
class ExpenseLineItemInline(admin.TabularInline):
model = ExpenseLineItem
extra = 1
@admin.register(ExpenseReceipt)
class ExpenseReceiptAdmin(admin.ModelAdmin):
list_display = ('vendor_name', 'date', 'total_amount', 'user')
list_filter = ('date', 'payment_method', 'vat_type')
search_fields = ('vendor_name', 'description')
inlines = [ExpenseLineItemInline]
@admin.register(ExpenseLineItem)
class ExpenseLineItemAdmin(admin.ModelAdmin):
list_display = ('product_name', 'amount', 'receipt')
search_fields = ('product_name', 'receipt__vendor_name')

215
core/forms.py Normal file
View File

@ -0,0 +1,215 @@
# === FORMS ===
# Django form classes for the app.
# - 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
from django import forms
from django.forms import inlineformset_factory
from .models import WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
class AttendanceLogForm(forms.ModelForm):
"""
Form for logging daily worker attendance.
Extra fields (not on the WorkLog model):
- end_date: optional end date for logging multiple days at once
- include_saturday: whether to include Saturdays in a date range
- include_sunday: whether to include Sundays in a date range
The supervisor field is NOT shown on the form it gets set automatically
in the view to whoever is logged in.
"""
# --- Extra fields for date range logging ---
# These aren't on the WorkLog model, they're only used by the form
end_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label='End Date',
help_text='Leave blank to log a single day'
)
include_saturday = forms.BooleanField(
required=False,
initial=False,
label='Include Saturdays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
include_sunday = forms.BooleanField(
required=False,
initial=False,
label='Include Sundays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class Meta:
model = WorkLog
# Supervisor is NOT included — it gets set in the view automatically
fields = ['date', 'project', 'team', 'workers', 'overtime_amount', 'notes']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'team': forms.Select(attrs={'class': 'form-select'}),
'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
'overtime_amount': forms.Select(attrs={'class': 'form-select'}),
'notes': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Any notes about the day...'
}),
}
def __init__(self, *args, **kwargs):
# Pop 'user' from kwargs so we can filter based on who's logged in
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# --- Supervisor filtering ---
# If the user is NOT an admin, they can only see:
# - Projects they're assigned to (via project.supervisors M2M)
# - Workers in teams they supervise
if self.user and not (self.user.is_staff or self.user.is_superuser):
# Only show projects this supervisor is assigned to
self.fields['project'].queryset = Project.objects.filter(
active=True,
supervisors=self.user
)
# Only show workers from teams this supervisor manages
supervised_teams = Team.objects.filter(supervisor=self.user, active=True)
self.fields['workers'].queryset = Worker.objects.filter(
active=True,
teams__in=supervised_teams
).distinct()
# Only show teams this supervisor manages
self.fields['team'].queryset = supervised_teams
else:
# Admins see everything
self.fields['workers'].queryset = Worker.objects.filter(active=True)
self.fields['project'].queryset = Project.objects.filter(active=True)
self.fields['team'].queryset = Team.objects.filter(active=True)
# Make team optional (it already is on the model, but make the form match)
self.fields['team'].required = False
# Force start date to be blank — don't pre-fill with today's date.
# Django 5.x auto-fills form fields from model defaults (default=timezone.now),
# but we want the user to consciously pick a date every time.
self.fields['date'].initial = None
def clean(self):
"""Validate the date range makes sense."""
cleaned_data = super().clean()
start_date = cleaned_data.get('date')
end_date = cleaned_data.get('end_date')
if start_date and end_date and end_date < start_date:
raise forms.ValidationError('End date cannot be before start date.')
return cleaned_data
class PayrollAdjustmentForm(forms.ModelForm):
"""
Form for adding/editing payroll adjustments (bonuses, deductions, etc.).
Business rule: A project is required for Overtime, Bonus, Deduction, and
Advance Payment types. Loan and Loan Repayment are worker-level (no project).
"""
class Meta:
model = PayrollAdjustment
fields = ['type', 'project', 'worker', 'amount', 'date', 'description']
widgets = {
'type': forms.Select(attrs={'class': 'form-select'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'worker': forms.Select(attrs={'class': 'form-select'}),
'amount': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0.01'
}),
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Reason for this adjustment...'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(active=True)
self.fields['project'].required = False
self.fields['worker'].queryset = Worker.objects.filter(active=True)
def clean(self):
"""Validate that project-required types have a project selected."""
cleaned_data = super().clean()
adj_type = cleaned_data.get('type', '')
project = cleaned_data.get('project')
# These types must have a project — they're tied to specific work
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment')
if adj_type in project_required_types and not project:
self.add_error('project', 'A project must be selected for this adjustment type.')
return cleaned_data
# =============================================================================
# === EXPENSE RECEIPT FORM ===
# Used on the /receipts/create/ page.
# The form handles receipt header fields (vendor, date, payment method, VAT type).
# Line items are handled separately by the ExpenseLineItemFormSet below.
# =============================================================================
class ExpenseReceiptForm(forms.ModelForm):
"""
Form for the receipt header vendor, date, payment method, VAT type.
Line items (products + amounts) are handled by ExpenseLineItemFormSet.
"""
class Meta:
model = ExpenseReceipt
fields = ['date', 'vendor_name', 'description', 'payment_method', 'vat_type']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'vendor_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Vendor Name'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'What was purchased and why...'
}),
'payment_method': forms.Select(attrs={'class': 'form-select'}),
# Radio buttons for VAT type — shown as 3 options side by side
'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}),
}
# === LINE ITEM FORMSET ===
# A "formset" is a group of identical mini-forms — one per line item.
# inlineformset_factory creates it automatically from the parent-child relationship.
# - extra=1: start with 1 blank row
# - can_delete=True: allows removing rows (checks a hidden DELETE checkbox)
ExpenseLineItemFormSet = inlineformset_factory(
ExpenseReceipt, # Parent model
ExpenseLineItem, # Child model
fields=['product_name', 'amount'],
extra=1, # Show 1 blank row by default
can_delete=True, # Allow deleting rows
widgets={
'product_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Item Name'
}),
'amount': forms.NumberInput(attrs={
'class': 'form-control item-amount',
'step': '0.01',
'placeholder': '0.00'
}),
}
)

View File

View File

View File

@ -0,0 +1,405 @@
# === IMPORT PRODUCTION DATA ===
# Imports the real work logs, adjustments, workers, projects, and supervisors
# from the V2 Flatlogic backup CSV into the V5 database.
#
# Run: python manage.py import_production_data
#
# This command is safe to re-run — it skips data that already exists.
import datetime
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from django.utils import timezone
from core.models import Project, Worker, Team, WorkLog, PayrollRecord, PayrollAdjustment, Loan
class Command(BaseCommand):
help = 'Imports production work logs and adjustments from V2 backup'
def handle(self, *args, **options):
self.stdout.write(self.style.NOTICE('Starting production data import...'))
self.stdout.write('')
# =============================================
# 1. CREATE USERS (admin + supervisors)
# =============================================
admin_user = self._create_user('admin', 'admin123', is_staff=True, is_superuser=True, first_name='Admin')
christiaan = self._create_user('Christiaan', 'super123', first_name='Christiaan')
fitz = self._create_user('Fitz', 'super123', first_name='Fitz')
supervisor_map = {
'Christiaan': christiaan,
'Fitz': fitz,
'admin': admin_user,
}
# =============================================
# 2. CREATE PROJECTS
# =============================================
plot, _ = Project.objects.get_or_create(name='Plot', defaults={'active': True})
jopetku, _ = Project.objects.get_or_create(name='Jopetku', defaults={'active': True})
# Assign supervisors to projects
plot.supervisors.add(christiaan)
jopetku.supervisors.add(christiaan, fitz)
project_map = {
'Plot': plot,
'Jopetku': jopetku,
}
self.stdout.write(self.style.SUCCESS(' Projects: Plot, Jopetku'))
# =============================================
# 3. CREATE WORKERS
# =============================================
# Daily rates calculated from CSV group amounts:
# - Soldier Aphiwe Dobe: R250/day (verified: 770 - 520 = 250)
# - Brian: R300/day (verified: 550 - 250 = 300)
# - Jerry: R260/day (assumed: 520/2 = 260)
# - Tshepo Isrom Moganedi: R260/day (assumed: 520/2 = 260)
# - Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana: R350/day
# (verified: 3000 - 1950 = 1050, 1050/3 = 350)
# - 7 Jopetku base workers: 4 at R300/day + 3 at R250/day
# (verified: 4×300 + 3×250 = 1950)
# Note: assignment of which 4 vs 3 is approximate — adjust in admin
# if needed.
worker_data = [
# (name, monthly_salary, id_number)
# Real SA ID numbers from Workers Info xlsx (13-digit format)
# Brian and Jerry don't have ID info on file yet — using placeholders
('Soldier Aphiwe Dobe', Decimal('5000.00'), '9212236112084'),
('Brian', Decimal('6000.00'), '0000000000002'),
('Jerry', Decimal('5200.00'), '0000000000003'),
('Tshepo Isrom Moganedi', Decimal('5200.00'), '8112175417083'),
('Richard Moleko', Decimal('7000.00'), '0003185071085'),
('Fikile Oupa Masimula', Decimal('7000.00'), '8606305407088'),
('Mpho Gift Nkoana', Decimal('7000.00'), '9811125984089'),
# 4 at R300/day = R6,000/month
('Clifford Jan Bobby Selemela', Decimal('6000.00'), '0104205798085'),
('Goitsimang Rasta Moleko', Decimal('6000.00'), '0403135542068'),
('Jimmy Moleko', Decimal('6000.00'), '0101176105084'),
('Johannes Laka', Decimal('6000.00'), '9809066044087'),
# 3 at R250/day = R5,000/month
('Shane Malobela', Decimal('5000.00'), '9807046054085'),
('Sello Lloyed Matloa', Decimal('5000.00'), '0407046184088'),
('Tumelo Faith Sinugo', Decimal('5000.00'), '9009055943080'),
]
worker_map = {}
for name, salary, id_num in worker_data:
worker, created = Worker.objects.get_or_create(
name=name,
defaults={
'monthly_salary': salary,
'id_number': id_num,
'active': True,
'employment_date': datetime.date(2024, 1, 15),
}
)
# Always update ID number in case worker was created with placeholder
if worker.id_number != id_num:
old_id = worker.id_number
worker.id_number = id_num
worker.save(update_fields=['id_number'])
self.stdout.write(f' Updated ID for {name}: {old_id}{id_num}')
worker_map[name] = worker
if created:
self.stdout.write(f' Created worker: {name} (R{salary}/month, R{worker.daily_rate}/day)')
self.stdout.write(self.style.SUCCESS(f' Workers: {len(worker_map)} total'))
# =============================================
# 4. CREATE TEAMS
# =============================================
plot_team, created = Team.objects.get_or_create(
name='Plot Team',
defaults={'supervisor': christiaan, 'active': True}
)
if created:
plot_workers = [worker_map[n] for n in ['Soldier Aphiwe Dobe', 'Brian', 'Jerry', 'Tshepo Isrom Moganedi']]
plot_team.workers.set(plot_workers)
self.stdout.write(self.style.SUCCESS(f' Created Plot Team ({len(plot_workers)} workers)'))
jopetku_team, created = Team.objects.get_or_create(
name='Jopetku Team',
defaults={'supervisor': fitz, 'active': True}
)
if created:
jopetku_workers = [worker_map[n] for n in [
'Richard Moleko', 'Fikile Oupa Masimula', 'Mpho Gift Nkoana',
'Clifford Jan Bobby Selemela', 'Goitsimang Rasta Moleko',
'Johannes Laka', 'Jimmy Moleko', 'Shane Malobela',
'Sello Lloyed Matloa', 'Tumelo Faith Sinugo',
]]
jopetku_team.workers.set(jopetku_workers)
self.stdout.write(self.style.SUCCESS(f' Created Jopetku Team ({len(jopetku_workers)} workers)'))
team_map = {
'Plot': plot_team,
'Jopetku': jopetku_team,
}
# =============================================
# 5. EMBEDDED CSV DATA
# =============================================
# Format: (date, description, workers_str, amount, status, supervisor)
# From: work_logs_and_adjustments.csv (V2 Flatlogic backup)
csv_rows = [
('2026-02-21', 'Plot', 'Soldier Aphiwe Dobe, Brian', '550.00', 'Pending', 'Christiaan'),
('2026-02-21', 'Advance Payment - Advance', 'Brian', '-300.00', 'Pending', 'System'),
('2026-02-20', 'Overtime - Overtime Hour buyback', 'Fikile Oupa Masimula', '1750.00', 'Paid', 'System'),
('2026-02-20', 'Overtime - Overtime Hour buyback', 'Mpho Gift Nkoana', '1750.00', 'Paid', 'System'),
('2026-02-20', 'Overtime - Overtime Hour buyback', 'Richard Moleko', '1750.00', 'Paid', 'System'),
('2026-02-19', 'Jopetku', 'Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '1950.00', 'Paid', 'Fitz'),
('2026-02-18', 'Jopetku', 'Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '1950.00', 'Paid', 'Fitz'),
('2026-02-17', 'Jopetku', 'Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '1950.00', 'Paid', 'Fitz'),
('2026-02-16', 'Jopetku', 'Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '1950.00', 'Paid', 'Fitz'),
('2026-02-14', 'Jopetku', 'Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '1950.00', 'Paid', 'Fitz'),
('2026-02-13', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-13', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-13', 'Bonus - Advance d', 'Brian', '300.00', 'Paid', 'System'),
('2026-02-13', 'Loan Repayment - Advance deduction', 'Brian', '-300.00', 'Paid', 'System'),
('2026-02-13', 'Loan Repayment - Advance deduction', 'Jerry', '-200.00', 'Paid', 'System'),
('2026-02-13', 'Loan Repayment - Advance deduction', 'Tshepo Isrom Moganedi', '-200.00', 'Paid', 'System'),
('2026-02-13', 'Loan Repayment - Advance deduction', 'Brian', '-300.00', 'Paid', 'System'),
('2026-02-12', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-12', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-11', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-11', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-10', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-10', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-09', 'Plot', 'Soldier Aphiwe Dobe, Jerry, Tshepo Isrom Moganedi', '770.00', 'Paid', 'Christiaan'),
('2026-02-09', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-07', 'Plot', 'Soldier Aphiwe Dobe, Brian', '550.00', 'Paid', 'Christiaan'),
('2026-02-06', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-06', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Fitz'),
('2026-02-05', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-05', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-02-04', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-04', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-02-04', 'Deduction - Tripod replacement', 'Fikile Oupa Masimula', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Goitsimang Rasta Moleko', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Jimmy Moleko', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Clifford Jan Bobby Selemela', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Johannes Laka', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Mpho Gift Nkoana', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Richard Moleko', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Sello Lloyed Matloa', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Shane Malobela', '-250.00', 'Paid', 'System'),
('2026-02-04', 'Deduction - Tripod replacement', 'Tumelo Faith Sinugo', '-250.00', 'Paid', 'System'),
('2026-02-03', 'Plot', 'Soldier Aphiwe Dobe, Brian', '550.00', 'Paid', 'Christiaan'),
('2026-02-03', 'Plot', 'Jerry, Tshepo Isrom Moganedi', '520.00', 'Paid', 'Christiaan'),
('2026-02-03', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-02-02', 'Plot', 'Soldier Aphiwe Dobe, Brian, Jerry, Tshepo Isrom Moganedi', '1070.00', 'Paid', 'Christiaan'),
('2026-02-02', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-02-01', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-31', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-30', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-29', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-28', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-27', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-26', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-25', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-24', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
('2026-01-23', 'Jopetku', 'Richard Moleko, Fikile Oupa Masimula, Mpho Gift Nkoana, Clifford Jan Bobby Selemela, Goitsimang Rasta Moleko, Johannes Laka, Jimmy Moleko, Shane Malobela, Sello Lloyed Matloa, Tumelo Faith Sinugo', '3000.00', 'Paid', 'Christiaan'),
]
# =============================================
# 6. PROCESS EACH ROW
# =============================================
logs_created = 0
adjs_created = 0
payments_created = 0
for date_str, description, workers_str, amount_str, status, supervisor_name in csv_rows:
row_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
amount = Decimal(amount_str)
is_paid = (status == 'Paid')
worker_names = [n.strip() for n in workers_str.split(',')]
# --- Is this a work log or an adjustment? ---
if supervisor_name != 'System' and ' - ' not in description:
# WORK LOG ROW
project_name = description
project = project_map.get(project_name)
if not project:
self.stdout.write(self.style.WARNING(f' Unknown project: {project_name}, skipping'))
continue
supervisor = supervisor_map.get(supervisor_name)
team = team_map.get(project_name)
workers = [worker_map[n] for n in worker_names if n in worker_map]
if not workers:
continue
# Check if this exact work log already exists
existing = WorkLog.objects.filter(
date=row_date,
project=project,
).first()
# If same date + project exists, check if it has the same workers
# (Feb 3 has two separate Plot logs with different workers)
if existing:
existing_worker_ids = set(existing.workers.values_list('id', flat=True))
new_worker_ids = set(w.id for w in workers)
if existing_worker_ids == new_worker_ids:
# Already imported, skip
worklog = existing
elif existing_worker_ids & new_worker_ids:
# Overlapping workers — merge by adding new workers
existing.workers.add(*workers)
worklog = existing
else:
# Different workers, same date+project — create new log
worklog = WorkLog.objects.create(
date=row_date,
project=project,
team=team,
supervisor=supervisor,
)
worklog.workers.set(workers)
logs_created += 1
else:
worklog = WorkLog.objects.create(
date=row_date,
project=project,
team=team,
supervisor=supervisor,
)
worklog.workers.set(workers)
logs_created += 1
# Create PayrollRecords for paid work logs (one per worker)
if is_paid:
for worker in workers:
# Check if already paid
already_paid = PayrollRecord.objects.filter(
worker=worker,
work_logs=worklog,
).exists()
if not already_paid:
pr = PayrollRecord.objects.create(
worker=worker,
date=row_date,
amount_paid=worker.daily_rate,
)
pr.work_logs.add(worklog)
payments_created += 1
else:
# ADJUSTMENT ROW
# Parse "Type - Description" format
if ' - ' in description:
adj_type_raw, adj_desc = description.split(' - ', 1)
else:
adj_type_raw = description
adj_desc = ''
# Map CSV type names to V5 TYPE_CHOICES
type_map = {
'Bonus': 'Bonus',
'Deduction': 'Deduction',
'Loan Repayment': 'Loan Repayment',
'Overtime': 'Overtime',
'Advance Payment': 'Advance Payment',
}
adj_type = type_map.get(adj_type_raw.strip(), adj_type_raw.strip())
# Amount: CSV uses negative for deductions, V5 stores positive
adj_amount = abs(amount)
for name in worker_names:
name = name.strip()
worker = worker_map.get(name)
if not worker:
self.stdout.write(self.style.WARNING(f' Unknown worker for adjustment: {name}'))
continue
# Check for duplicate
existing_adj = PayrollAdjustment.objects.filter(
worker=worker,
type=adj_type,
amount=adj_amount,
date=row_date,
description=adj_desc,
).first()
if existing_adj:
continue
# Find project for the adjustment (match by date)
adj_project = None
if adj_type in ('Deduction', 'Bonus', 'Overtime'):
# Try to find which project this worker was on that date
day_log = WorkLog.objects.filter(
date=row_date,
workers=worker,
).first()
if day_log:
adj_project = day_log.project
# Create PayrollRecord for paid adjustments
payroll_record = None
if is_paid:
payroll_record = PayrollRecord.objects.create(
worker=worker,
date=row_date,
amount_paid=adj_amount if adj_type in ('Bonus', 'Overtime', 'New Loan') else -adj_amount,
)
adj = PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
amount=adj_amount,
date=row_date,
description=adj_desc,
project=adj_project,
payroll_record=payroll_record,
)
adjs_created += 1
# =============================================
# 7. SUMMARY
# =============================================
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('=== Production data import complete! ==='))
self.stdout.write(f' Admin login: admin / admin123')
self.stdout.write(f' Supervisor login: Christiaan / super123')
self.stdout.write(f' Supervisor login: Fitz / super123')
self.stdout.write(f' Projects: {Project.objects.filter(active=True).count()}')
self.stdout.write(f' Workers: {Worker.objects.filter(active=True).count()}')
self.stdout.write(f' Teams: {Team.objects.filter(active=True).count()}')
self.stdout.write(f' WorkLogs created: {logs_created}')
self.stdout.write(f' Adjustments created: {adjs_created}')
self.stdout.write(f' PayrollRecords created: {payments_created}')
self.stdout.write(f' Total WorkLogs: {WorkLog.objects.count()}')
self.stdout.write(f' Total Payments: {PayrollRecord.objects.count()}')
def _create_user(self, username, password, is_staff=False, is_superuser=False, first_name=''):
"""Create a user or update their flags if they already exist."""
user, created = User.objects.get_or_create(
username=username,
defaults={
'is_staff': is_staff,
'is_superuser': is_superuser,
'first_name': first_name,
}
)
if created:
user.set_password(password)
user.save()
role = 'admin' if is_superuser else 'supervisor'
self.stdout.write(self.style.SUCCESS(f' Created {role} user: {username}'))
else:
if is_staff and not user.is_staff:
user.is_staff = True
if is_superuser and not user.is_superuser:
user.is_superuser = True
user.save()
return user

View File

@ -0,0 +1,74 @@
# === SETUP GROUPS MANAGEMENT COMMAND ===
# Creates two permission groups: "Admin" and "Work Logger".
# Run this once after deploying: python manage.py setup_groups
#
# "Admin" group gets full access to all core models.
# "Work Logger" group can add/change/view WorkLogs, and view-only
# access to Projects, Workers, and Teams.
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from core.models import (
Project, Worker, Team, WorkLog, PayrollRecord,
Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
)
class Command(BaseCommand):
help = 'Creates the Admin and Work Logger permission groups'
def handle(self, *args, **options):
# --- Create the "Admin" group ---
# Admins get every permission on every core model
admin_group, created = Group.objects.get_or_create(name='Admin')
if created:
self.stdout.write(self.style.SUCCESS('Created "Admin" group'))
else:
self.stdout.write('Admin group already exists — updating permissions')
# Get all permissions for our core models
core_models = [
Project, Worker, Team, WorkLog, PayrollRecord,
Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
]
all_permissions = Permission.objects.filter(
content_type__in=[
ContentType.objects.get_for_model(model)
for model in core_models
]
)
admin_group.permissions.set(all_permissions)
self.stdout.write(f' Assigned {all_permissions.count()} permissions to Admin group')
# --- Create the "Work Logger" group ---
# Work Loggers can add/change/view WorkLogs, and view-only for
# Projects, Workers, and Teams
logger_group, created = Group.objects.get_or_create(name='Work Logger')
if created:
self.stdout.write(self.style.SUCCESS('Created "Work Logger" group'))
else:
self.stdout.write('Work Logger group already exists — updating permissions')
logger_permissions = Permission.objects.filter(
# WorkLog: add, change, view (but not delete)
content_type=ContentType.objects.get_for_model(WorkLog),
codename__in=['add_worklog', 'change_worklog', 'view_worklog']
) | Permission.objects.filter(
# Projects: view only
content_type=ContentType.objects.get_for_model(Project),
codename='view_project'
) | Permission.objects.filter(
# Workers: view only
content_type=ContentType.objects.get_for_model(Worker),
codename='view_worker'
) | Permission.objects.filter(
# Teams: view only
content_type=ContentType.objects.get_for_model(Team),
codename='view_team'
)
logger_group.permissions.set(logger_permissions)
self.stdout.write(f' Assigned {logger_permissions.count()} permissions to Work Logger group')
self.stdout.write(self.style.SUCCESS('Done! Permission groups are ready.'))

View File

@ -0,0 +1,180 @@
# === SETUP TEST DATA MANAGEMENT COMMAND ===
# Creates sample workers, projects, teams, and work logs for testing.
# Run this once after deploying: python manage.py setup_test_data
#
# This is useful when the Django admin panel isn't accessible (e.g. on
# Flatlogic's Cloud Run deployment where admin static files may not load).
import datetime
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from django.utils import timezone
from core.models import Project, Worker, Team, WorkLog
class Command(BaseCommand):
help = 'Creates sample workers, projects, teams, and work logs for testing'
def handle(self, *args, **options):
# --- Create an admin superuser (if not exists) ---
admin_user, created = User.objects.get_or_create(
username='admin',
defaults={
'is_staff': True,
'is_superuser': True,
'first_name': 'Admin',
'email': 'admin@foxfitt.co.za',
}
)
if created:
admin_user.set_password('admin123')
admin_user.save()
self.stdout.write(self.style.SUCCESS('Created admin user (password: admin123)'))
else:
# Make sure existing admin has staff/superuser flags
if not admin_user.is_staff or not admin_user.is_superuser:
admin_user.is_staff = True
admin_user.is_superuser = True
admin_user.save()
self.stdout.write('Updated admin user to have staff + superuser flags')
else:
self.stdout.write('Admin user already exists')
# --- Create a supervisor user ---
supervisor, created = User.objects.get_or_create(
username='supervisor1',
defaults={
'is_staff': False,
'first_name': 'John',
'last_name': 'Supervisor',
}
)
if created:
supervisor.set_password('super123')
supervisor.save()
self.stdout.write(self.style.SUCCESS('Created supervisor user (password: super123)'))
else:
self.stdout.write('Supervisor user already exists')
# --- Create Projects ---
project_names = [
'Kalkbult Solar Farm',
'De Aar Wind Farm',
'Prieska Solar Plant',
]
projects = []
for name in project_names:
proj, created = Project.objects.get_or_create(
name=name,
defaults={'active': True}
)
# Assign supervisor to the project
proj.supervisors.add(supervisor)
projects.append(proj)
if created:
self.stdout.write(self.style.SUCCESS(f' Created project: {name}'))
else:
self.stdout.write(f' Project already exists: {name}')
# --- Create Workers ---
worker_data = [
{'name': 'Thabo Mokoena', 'id_number': '9001015000080', 'salary': Decimal('8000.00')},
{'name': 'Sipho Ndlovu', 'id_number': '8805125000081', 'salary': Decimal('7500.00')},
{'name': 'Lerato Dlamini', 'id_number': '9203220000082', 'salary': Decimal('7000.00')},
{'name': 'Bongani Zulu', 'id_number': '8510305000083', 'salary': Decimal('8500.00')},
{'name': 'Nomsa Khumalo', 'id_number': '9106185000084', 'salary': Decimal('7200.00')},
{'name': 'David Botha', 'id_number': '8707125000085', 'salary': Decimal('9000.00')},
]
workers = []
for wd in worker_data:
worker, created = Worker.objects.get_or_create(
name=wd['name'],
defaults={
'id_number': wd['id_number'],
'monthly_salary': wd['salary'],
'active': True,
'employment_date': datetime.date(2024, 1, 15),
}
)
workers.append(worker)
if created:
self.stdout.write(self.style.SUCCESS(f' Created worker: {wd["name"]} (R{wd["salary"]}/month)'))
else:
self.stdout.write(f' Worker already exists: {wd["name"]}')
# --- Create Teams ---
team_a, created = Team.objects.get_or_create(
name='Team Alpha',
defaults={'supervisor': supervisor, 'active': True}
)
if created:
team_a.workers.set(workers[:3]) # First 3 workers
self.stdout.write(self.style.SUCCESS(' Created Team Alpha (3 workers)'))
else:
self.stdout.write(' Team Alpha already exists')
team_b, created = Team.objects.get_or_create(
name='Team Bravo',
defaults={'supervisor': supervisor, 'active': True}
)
if created:
team_b.workers.set(workers[3:]) # Last 3 workers
self.stdout.write(self.style.SUCCESS(' Created Team Bravo (3 workers)'))
else:
self.stdout.write(' Team Bravo already exists')
# --- Create Work Logs (last 2 weeks) ---
today = timezone.now().date()
logs_created = 0
for days_ago in range(14, 0, -1):
log_date = today - datetime.timedelta(days=days_ago)
# Skip weekends
if log_date.weekday() >= 5:
continue
# Alternate between projects
project = projects[days_ago % len(projects)]
# Create a work log with some workers
log_workers = workers[:4] if days_ago % 2 == 0 else workers[2:]
# Check if this log already exists
existing = WorkLog.objects.filter(date=log_date, project=project).first()
if existing:
continue
# Set overtime on some days
ot = Decimal('0.00')
if days_ago % 3 == 0:
ot = Decimal('0.50') # Half day overtime every 3rd day
elif days_ago % 5 == 0:
ot = Decimal('0.25') # Quarter day overtime every 5th day
worklog = WorkLog.objects.create(
date=log_date,
project=project,
team=team_a if days_ago % 2 == 0 else team_b,
supervisor=supervisor,
overtime_amount=ot,
)
worklog.workers.set(log_workers)
logs_created += 1
self.stdout.write(self.style.SUCCESS(f' Created {logs_created} work logs'))
# --- Summary ---
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('=== Test data setup complete! ==='))
self.stdout.write(f' Admin login: admin / admin123')
self.stdout.write(f' Supervisor login: supervisor1 / super123')
self.stdout.write(f' Projects: {Project.objects.filter(active=True).count()}')
self.stdout.write(f' Workers: {Worker.objects.filter(active=True).count()}')
self.stdout.write(f' Teams: {Team.objects.filter(active=True).count()}')
self.stdout.write(f' WorkLogs: {WorkLog.objects.count()}')
self.stdout.write('')
self.stdout.write(' Now log in as "admin" and go to /payroll/ to test the dashboard!')

View File

@ -0,0 +1,136 @@
# Generated by Django 5.2.7 on 2026-02-22 12:17
import django.db.models.deletion
import django.utils.timezone
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Worker',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('id_number', models.CharField(max_length=50, unique=True)),
('phone_number', models.CharField(blank=True, max_length=20)),
('monthly_salary', models.DecimalField(decimal_places=2, max_digits=10)),
('photo', models.ImageField(blank=True, null=True, upload_to='workers/photos/')),
('id_document', models.FileField(blank=True, null=True, upload_to='workers/documents/')),
('employment_date', models.DateField(default=django.utils.timezone.now)),
('notes', models.TextField(blank=True)),
('active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='ExpenseReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now)),
('vendor_name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('payment_method', models.CharField(choices=[('Cash', 'Cash'), ('Card', 'Card'), ('EFT', 'EFT'), ('Other', 'Other')], max_length=20)),
('vat_type', models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], max_length=20)),
('subtotal', models.DecimalField(decimal_places=2, max_digits=12)),
('vat_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('total_amount', models.DecimalField(decimal_places=2, max_digits=12)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expense_receipts', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ExpenseLineItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(max_length=200)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='core.expensereceipt')),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('active', models.BooleanField(default=True)),
('supervisors', models.ManyToManyField(related_name='assigned_projects', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('active', models.BooleanField(default=True)),
('supervisor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_teams', to=settings.AUTH_USER_MODEL)),
('workers', models.ManyToManyField(related_name='teams', to='core.worker')),
],
),
migrations.CreateModel(
name='Loan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('principal_amount', models.DecimalField(decimal_places=2, max_digits=10)),
('remaining_balance', models.DecimalField(decimal_places=2, max_digits=10)),
('date', models.DateField(default=django.utils.timezone.now)),
('reason', models.TextField(blank=True)),
('active', models.BooleanField(default=True)),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loans', to='core.worker')),
],
),
migrations.CreateModel(
name='WorkLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now)),
('notes', models.TextField(blank=True)),
('overtime_amount', models.DecimalField(choices=[(Decimal('0.00'), 'None'), (Decimal('0.25'), '1/4 Day'), (Decimal('0.50'), '1/2 Day'), (Decimal('0.75'), '3/4 Day'), (Decimal('1.00'), 'Full Day')], decimal_places=2, default=Decimal('0.00'), max_digits=3)),
('priced_workers', models.ManyToManyField(blank=True, related_name='priced_overtime_logs', to='core.worker')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_logs', to='core.project')),
('supervisor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs_created', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs', to='core.team')),
('workers', models.ManyToManyField(related_name='work_logs', to='core.worker')),
],
),
migrations.CreateModel(
name='PayrollRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now)),
('amount_paid', models.DecimalField(decimal_places=2, max_digits=10)),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payroll_records', to='core.worker')),
('work_logs', models.ManyToManyField(related_name='payroll_records', to='core.worklog')),
],
),
migrations.CreateModel(
name='PayrollAdjustment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('date', models.DateField(default=django.utils.timezone.now)),
('description', models.TextField(blank=True)),
('type', models.CharField(choices=[('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'New Loan'), ('Advance Payment', 'Advance Payment')], max_length=50)),
('loan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repayments', to='core.loan')),
('payroll_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments', to='core.payrollrecord')),
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments_by_project', to='core.project')),
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='core.worker')),
('work_log', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments_by_work_log', to='core.worklog')),
],
),
]

View File

@ -0,0 +1,65 @@
# === DATA MIGRATION: Update Worker ID Numbers ===
# One-time migration to set real SA ID numbers from the workers_list.xlsx file.
# Matches workers by first name + surname (case-insensitive contains).
# Safe: if a worker isn't found, it's skipped. If id_number is already correct, no change.
from django.db import migrations
from django.db.models import Q
# Worker ID data from workers_list.xlsx
# Format: (first_name_part, surname, id_number)
# We search for workers whose name contains BOTH the first name part AND the surname
WORKER_ID_DATA = [
('Mpho', 'Nkoana', '9811125984089'),
('Richard', 'Moleko', '0003185071085'),
('Fikile', 'Masimula', '8606305407088'),
('Clifford', 'Selemela', '0104205798085'),
('Shane', 'Malobela', '9807046054085'),
('Jimmy', 'Moleko', '0101176105084'),
('Johannes', 'Laka', '9809066044087'),
('Tumelo', 'Sinugo', '9009055943080'),
('Goitsimang', 'Moleko', '0403135542068'),
('Sello', 'Matloa', '0407046184088'),
('Aphiwe', 'Dobe', '9212236112084'),
('Tshepo', 'Moganedi', '8112175417083'),
]
def update_id_numbers(apps, schema_editor):
"""Update worker ID numbers from the Excel spreadsheet data."""
Worker = apps.get_model('core', 'Worker')
for first_name, surname, id_number in WORKER_ID_DATA:
# Find worker whose name contains both the first name and surname
# This handles cases like "Soldier Aphiwe Dobe" matching ("Aphiwe", "Dobe")
# or "Clifford Jan Bobby Selemela" matching ("Clifford", "Selemela")
matches = Worker.objects.filter(
Q(name__icontains=first_name) & Q(name__icontains=surname)
)
if matches.count() == 1:
worker = matches.first()
worker.id_number = id_number
worker.save(update_fields=['id_number'])
elif matches.count() > 1:
# Multiple matches — skip to avoid updating the wrong worker
# (shouldn't happen with first name + surname combo)
pass
# If no match found, skip silently — worker might not exist in this env
def reverse_id_numbers(apps, schema_editor):
"""Reverse is a no-op — we can't restore old ID numbers."""
pass
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunPython(update_id_numbers, reverse_id_numbers),
]

View File

@ -0,0 +1,25 @@
# Migration to add start_date and end_date to Project.
# This migration was applied to the database during the Flatlogic export
# but the file was missing from the repository. Re-created to match DB state.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_update_worker_id_numbers'),
]
operations = [
migrations.AddField(
model_name='project',
name='end_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='project',
name='start_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-03-05 06:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_add_project_start_end_dates'),
]
operations = [
migrations.AddField(
model_name='loan',
name='loan_type',
field=models.CharField(choices=[('loan', 'Loan'), ('advance', 'Advance')], default='loan', max_length=10),
),
migrations.AlterField(
model_name='payrolladjustment',
name='type',
field=models.CharField(choices=[('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'New Loan'), ('Advance Payment', 'Advance Payment'), ('Advance Repayment', 'Advance Repayment')], max_length=50),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-03-24 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_add_loan_type_and_advance_repayment'),
]
operations = [
migrations.AddField(
model_name='team',
name='pay_frequency',
field=models.CharField(blank=True, choices=[('weekly', 'Weekly'), ('fortnightly', 'Fortnightly'), ('monthly', 'Monthly')], default='', max_length=15),
),
migrations.AddField(
model_name='team',
name='pay_start_date',
field=models.DateField(blank=True, help_text='Anchor date for first pay period', null=True),
),
]

View File

@ -1,3 +1,200 @@
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from decimal import Decimal
from django.db.models.signals import post_save
from django.dispatch import receiver
# Create your models here.
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
# Add any extra profile fields if needed in the future
def __str__(self):
return self.user.username
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.get_or_create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'):
instance.profile.save()
class Project(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
supervisors = models.ManyToManyField(User, related_name='assigned_projects')
active = models.BooleanField(default=True)
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
def __str__(self):
return self.name
class Worker(models.Model):
name = models.CharField(max_length=200)
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)
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)
notes = models.TextField(blank=True)
active = models.BooleanField(default=True)
@property
def daily_rate(self):
# monthly salary divided by 20 working days
return (self.monthly_salary / Decimal('20.00')).quantize(Decimal('0.01'))
def __str__(self):
return self.name
class Team(models.Model):
# === PAY FREQUENCY CHOICES ===
# Used for the team's recurring pay schedule (weekly, fortnightly, or monthly)
PAY_FREQUENCY_CHOICES = [
('weekly', 'Weekly'),
('fortnightly', 'Fortnightly'),
('monthly', 'Monthly'),
]
name = models.CharField(max_length=200)
workers = models.ManyToManyField(Worker, related_name='teams')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams')
active = models.BooleanField(default=True)
# === PAY SCHEDULE ===
# These two fields define when the team gets paid.
# pay_start_date is the anchor — the first day of the very first pay period.
# pay_frequency determines the length of each recurring period.
# Both are optional — teams without a schedule work as before.
pay_frequency = models.CharField(max_length=15, choices=PAY_FREQUENCY_CHOICES, blank=True, default='')
pay_start_date = models.DateField(blank=True, null=True, help_text='Anchor date for first pay period')
def __str__(self):
return self.name
class WorkLog(models.Model):
OVERTIME_CHOICES = [
(Decimal('0.00'), 'None'),
(Decimal('0.25'), '1/4 Day'),
(Decimal('0.50'), '1/2 Day'),
(Decimal('0.75'), '3/4 Day'),
(Decimal('1.00'), 'Full Day'),
]
date = models.DateField(default=timezone.now)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='work_logs')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='work_logs')
workers = models.ManyToManyField(Worker, related_name='work_logs')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='work_logs_created')
notes = models.TextField(blank=True)
overtime_amount = models.DecimalField(max_digits=3, decimal_places=2, choices=OVERTIME_CHOICES, default=Decimal('0.00'))
priced_workers = models.ManyToManyField(Worker, related_name='priced_overtime_logs', blank=True)
@property
def display_amount(self):
"""Total daily cost for all workers on this log (sum of daily_rate).
Works efficiently with prefetch_related('workers')."""
return sum(w.daily_rate for w in self.workers.all())
def __str__(self):
return f"{self.date} - {self.project.name}"
class PayrollRecord(models.Model):
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='payroll_records')
date = models.DateField(default=timezone.now)
amount_paid = models.DecimalField(max_digits=10, decimal_places=2)
work_logs = models.ManyToManyField(WorkLog, related_name='payroll_records')
def __str__(self):
return f"{self.worker.name} - {self.date}"
class Loan(models.Model):
# === LOAN TYPE ===
# 'loan' = traditional loan (created via "New Loan")
# 'advance' = salary advance (created via "Advance Payment")
# Both work the same way (tracked balance, repayments) but are
# labelled differently on payslips and in the Loans tab.
LOAN_TYPE_CHOICES = [
('loan', 'Loan'),
('advance', 'Advance'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
loan_type = models.CharField(max_length=10, choices=LOAN_TYPE_CHOICES, default='loan')
principal_amount = models.DecimalField(max_digits=10, decimal_places=2)
remaining_balance = models.DecimalField(max_digits=10, decimal_places=2)
date = models.DateField(default=timezone.now)
reason = models.TextField(blank=True)
active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if not self.pk:
self.remaining_balance = self.principal_amount
super().save(*args, **kwargs)
def __str__(self):
label = 'Advance' if self.loan_type == 'advance' else 'Loan'
return f"{self.worker.name} - {label} - {self.date}"
class PayrollAdjustment(models.Model):
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'New Loan'),
('Advance Payment', 'Advance Payment'),
('Advance Repayment', 'Advance Repayment'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments')
work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_work_log')
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_project')
amount = models.DecimalField(max_digits=10, decimal_places=2)
date = models.DateField(default=timezone.now)
description = models.TextField(blank=True)
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
def __str__(self):
return f"{self.worker.name} - {self.type} - {self.amount}"
class ExpenseReceipt(models.Model):
METHOD_CHOICES = [
('Cash', 'Cash'),
('Card', 'Card'),
('EFT', 'EFT'),
('Other', 'Other'),
]
VAT_CHOICES = [
('Included', 'Included'),
('Excluded', 'Excluded'),
('None', 'None'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_receipts')
date = models.DateField(default=timezone.now)
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)
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)
def __str__(self):
return f"{self.vendor_name} - {self.date}"
class ExpenseLineItem(models.Model):
receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='line_items')
product_name = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=12, decimal_places=2)
def __str__(self):
return self.product_name

View File

@ -1,25 +1,124 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FoxFitt{% endblock %}</title>
<!-- Bootstrap 5.3 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Poppins:wght@500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ request.timestamp|default:'1.0' }}">
<style>
/* Layout helpers — keep body full-height so footer sticks to bottom */
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex-grow: 1; }
/* Branding — Fox in green, Fitt in white */
.navbar-brand-fox { color: #10b981; font-weight: 700; }
.navbar-brand-fitt { color: #ffffff; font-weight: 700; }
.nav-link { font-weight: 500; }
.dropdown-menu { border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
</style>
</head>
<body>
{% block content %}{% endblock %}
</body>
<nav class="navbar navbar-expand-lg navbar-dark sticky-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="{% url 'home' %}">
<span class="navbar-brand-fox">Fox</span>
<span class="navbar-brand-fitt">Fitt</span>
</a>
{% if user.is_authenticated %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}" href="{% url 'home' %}">
<i class="fas fa-home me-1"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}" href="{% url 'attendance_log' %}">
<i class="fas fa-clipboard-list me-1"></i> Log Work
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}" href="{% url 'work_history' %}">
<i class="fas fa-clock me-1"></i> Work History
</a>
</li>
{% if user.is_staff %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}" href="{% url 'payroll_dashboard' %}">
<i class="fas fa-wallet me-1"></i> Payroll
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}" href="{% url 'create_receipt' %}">
<i class="fas fa-receipt me-1"></i> Receipts
</a>
</li>
{% if user.is_staff %}
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">
<i class="fas fa-cog me-1"></i> Admin
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item d-flex align-items-center">
<span class="nav-link text-light pe-2">
<i class="fas fa-user-circle me-1"></i> {{ user.username }}
</span>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-sign-out-alt me-1"></i> Logout
</button>
</form>
</li>
</ul>
</div>
{% endif %}
</div>
</nav>
<div class="container mt-4">
<!-- Messages Block -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show shadow-sm" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Main Content -->
<main>
{% block content %}
{% endblock %}
</main>
<!-- Footer -->
<footer class="py-4 mt-auto border-top border-secondary">
<div class="container text-center">
<p class="mb-0 small">&copy; {% now "Y" %} FoxFitt Construction. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap 5.3 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

View File

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

View File

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

View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<style>
/* === EMAIL STYLES ===
Email clients have limited CSS support, so we use inline-friendly styles.
Worker name is the dominant element — no prominent Fox Fitt branding
(Spark reads the vendor name from the document). */
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.beneficiary-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
.sub-header { font-size: 14px; color: #666; margin-bottom: 5px; }
.title { font-size: 18px; font-weight: bold; color: #666; }
.meta { margin-bottom: 20px; background-color: #f8f9fa; padding: 10px; border-radius: 4px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table th, .items-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; }
.items-table th { background-color: #f8f9fa; }
.totals { text-align: right; margin-top: 20px; border-top: 2px solid #333; padding-top: 10px; }
.total-row { font-size: 20px; font-weight: bold; color: #000; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
.positive { color: green; }
.negative { color: red; }
</style>
</head>
<body>
<div class="container">
<!-- Header: worker name dominant -->
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<!-- Beneficiary details -->
<div class="meta">
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
<strong>Date:</strong> {{ record.date }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% if is_advance %}
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td style="text-align: right;">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% elif is_loan %}
<tr>
<td>Loan Payment: {{ loan_description }}</td>
<td style="text-align: right;">R {{ loan_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base pay line -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td style="text-align: right;">R {{ logs_amount|floatformat:2 }}</td>
</tr>
<!-- All adjustments (bonuses add, deductions subtract) -->
{% for adj in adjustments %}
<tr>
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
<td style="text-align: right;" class="{% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<!-- Net pay total -->
<div class="totals">
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
</div>
<!-- Footer — minimal branding -->
<div class="footer">
<p>Payer: Fox Fitt | Generated for {{ record.worker.name }}</p>
<p>Date Generated: {% now "Y-m-d H:i" %}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<style>
/* === EMAIL STYLES ===
Email clients have limited CSS support, so we use inline-friendly styles.
Vendor name is the dominant element — no prominent Fox Fitt branding
(Spark reads the vendor name from the document). */
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.vendor-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
.sub-header { font-size: 14px; color: #666; margin-bottom: 5px; }
.meta { margin-bottom: 20px; background-color: #f8f9fa; padding: 10px; border-radius: 4px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table th, .items-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; }
.items-table th { background-color: #f8f9fa; }
.totals { text-align: right; margin-top: 20px; }
.total-row { font-size: 18px; font-weight: bold; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="container">
<!-- Header: vendor name is the biggest element -->
<div class="header">
<div class="sub-header">RECEIPT FROM</div>
<div class="vendor-name">{{ receipt.vendor_name }}</div>
</div>
<!-- Receipt details -->
<div class="meta">
<strong>Date:</strong> {{ receipt.date }}<br>
<strong>Payment Method:</strong> {{ receipt.get_payment_method_display }}<br>
<strong>Description:</strong> {{ receipt.description|default:"-" }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Item</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product_name }}</td>
<td style="text-align: right;">R {{ item.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p>Subtotal: R {{ receipt.subtotal|floatformat:2 }}</p>
<p>VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}</p>
<p class="total-row">Total: R {{ receipt.total_amount|floatformat:2 }}</p>
</div>
<!-- Footer — minimal branding -->
<div class="footer">
<p>Generated by {{ receipt.user.get_full_name|default:receipt.user.username }} via Fox Fitt App</p>
<p>Date Generated: {% now "Y-m-d H:i" %}</p>
</div>
</div>
</body>
</html>

View File

@ -1,145 +1,472 @@
{% extends "base.html" %}
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block title %}Dashboard | FoxFitt{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<style>
{# Hide resource rows — needs !important to override Bootstrap's d-flex !important #}
.resource-hidden { display: none !important; }
</style>
<!-- Gradient Header -->
<div class="dashboard-header mb-5 rounded shadow-sm p-4 d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-0 text-white" style="font-family: 'Poppins', sans-serif;">Dashboard</h1>
<p class="text-white-50 mb-0">Welcome back, {{ user.first_name|default:user.username }}!</p>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
<a href="{% url 'attendance_log' %}" class="btn btn-accent shadow-sm">
<i class="fas fa-plus fa-sm me-1"></i> Log Daily Work
</a>
</div>
<div class="container py-2" style="margin-top: -3rem;">
{% if is_admin %}
<!-- Admin View -->
<div class="row g-4 mb-4 position-relative">
<!-- Outstanding Payments Card -->
<!-- Shows the total owed to workers, with a breakdown of wages vs adjustments -->
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #ef4444;">
Outstanding Payments</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ outstanding_payments|floatformat:2 }}</div>
{# === BREAKDOWN — only shown when there are pending adjustments === #}
{% if pending_adjustments_add or pending_adjustments_sub %}
<div class="mt-2 pt-2 border-top" style="font-size: 0.75rem; color: #64748b;">
<div class="d-flex justify-content-between">
<span>Unpaid wages</span>
<span>R {{ unpaid_wages|floatformat:2 }}</span>
</div>
{% if pending_adjustments_add %}
<div class="d-flex justify-content-between">
<span>+ Additions</span>
<span class="text-success">R {{ pending_adjustments_add|floatformat:2 }}</span>
</div>
{% endif %}
{% if pending_adjustments_sub %}
<div class="d-flex justify-content-between">
<span>- Deductions</span>
<span class="text-danger">-R {{ pending_adjustments_sub|floatformat:2 }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-1" style="font-size: 0.65rem; color: #94a3b8;">
<i class="fas fa-info-circle"></i> Loan repayments deducted at payment time
</div>
</div>
<div class="col-auto align-self-start">
<i class="fas fa-exclamation-circle fa-2x text-danger opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Paid This Month Card -->
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
Paid This Month</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ paid_this_month|floatformat:2 }}</div>
</div>
<div class="col-auto">
<i class="fas fa-check-circle fa-2x text-success opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Active Loans Card -->
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
Active Loans ({{ active_loans_count }})</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<div class="col-auto">
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Outstanding by Project -->
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #3b82f6;">
Outstanding by Project</div>
<div class="mb-0 text-gray-800" style="font-size: 0.85rem;">
{% if outstanding_by_project %}
<ul class="list-unstyled mb-0">
{% for proj, amount in outstanding_by_project.items %}
<li><strong>{{ proj }}:</strong> R {{ amount|floatformat:2 }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
<div class="col-auto">
<i class="fas fa-chart-pie fa-2x text-primary opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions and This Week -->
<div class="row mb-4">
<!-- This Week -->
<div class="col-lg-4 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">This Week Summary</h6>
</div>
<div class="card-body text-center d-flex flex-column justify-content-center">
<div class="h1 mb-0 font-weight-bold text-primary">{{ this_week_logs }}</div>
<div class="text-muted">Work Logs Created This Week</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-lg-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Quick Actions</h6>
</div>
<div class="card-body d-flex align-items-center justify-content-around flex-wrap">
<a href="{% url 'attendance_log' %}" class="btn btn-lg btn-outline-primary mb-2">
<i class="fas fa-clipboard-list mb-2 d-block fa-2x"></i> Log Work
</a>
<a href="{% url 'payroll_dashboard' %}" class="btn btn-lg btn-outline-success mb-2">
<i class="fas fa-money-check-alt mb-2 d-block fa-2x"></i> Run Payroll
</a>
<a href="{% url 'work_history' %}" class="btn btn-lg btn-outline-secondary mb-2">
<i class="fas fa-history mb-2 d-block fa-2x"></i> View History
</a>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Recent Activity -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Recent Activity</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for log in recent_activity %}
<div class="list-group-item px-4 py-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{{ log.project.name }}</h6>
<small class="text-muted">{{ log.date }} &middot; {{ log.workers.count }} workers</small>
</div>
<span class="badge bg-light text-dark border">{{ log.supervisor.username }}</span>
</div>
</div>
{% empty %}
<div class="p-4 text-center text-muted">
No recent activity.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Manage Resources -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Manage Resources</h6>
<a href="{% url 'export_workers_csv' %}" class="btn btn-outline-success btn-sm">
<i class="fas fa-file-csv me-1"></i> Export Workers
</a>
</div>
<div class="card-body p-0">
<p class="text-muted small mb-0 px-3 pt-3">Toggle active status. Inactive items are hidden from forms.</p>
<ul class="nav nav-tabs px-3 pt-2" id="resourceTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="workers-tab" data-bs-toggle="tab" data-bs-target="#workers" type="button" role="tab" aria-selected="true">Workers</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="projects-tab" data-bs-toggle="tab" data-bs-target="#projects" type="button" role="tab" aria-selected="false">Projects</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="teams-tab" data-bs-toggle="tab" data-bs-target="#teams" type="button" role="tab" aria-selected="false">Teams</button>
</li>
</ul>
{# Filter bar — Active / Inactive / All (defaults to Active) #}
<div class="btn-group btn-group-sm w-100 px-3 mt-2" id="resourceFilter" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="active">Active</button>
<button type="button" class="btn btn-outline-secondary" data-filter="inactive">Inactive</button>
<button type="button" class="btn btn-outline-secondary" data-filter="all">All</button>
</div>
<div class="tab-content px-0 mt-2" id="resourceTabsContent" style="max-height: 350px; overflow-y: auto;">
{# === WORKERS TAB === #}
<div class="tab-pane fade show active" id="workers" role="tabpanel">
{% for item in workers %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="worker" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No workers found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
{# === PROJECTS TAB === #}
<div class="tab-pane fade" id="projects" role="tabpanel">
{% for item in projects %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="project" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No projects found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
{# === TEAMS TAB === #}
<div class="tab-pane fade" id="teams" role="tabpanel">
{% for item in teams %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="team" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No teams found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- Supervisor View -->
<!-- Stat Cards — how many projects, teams, and workers this supervisor manages -->
<div class="row g-4 mb-4 position-relative">
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #8b5cf6;">
My Projects</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_projects_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-project-diagram fa-2x opacity-50" style="color: #8b5cf6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #3b82f6;">
My Teams</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_teams_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x opacity-50" style="color: #3b82f6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
My Workers</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_workers_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-hard-hat fa-2x opacity-50" style="color: #10b981;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- This Week + Recent Activity -->
<div class="row mb-4">
<div class="col-lg-4 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 fw-bold" style="color: #0f172a;">This Week Summary</h6>
</div>
<div class="card-body text-center d-flex flex-column justify-content-center">
<div class="h1 mb-0 fw-bold text-primary">{{ this_week_logs }}</div>
<div class="text-muted">Work Logs Created This Week</div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 fw-bold" style="color: #0f172a;">Recent Activity</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for log in recent_activity %}
<div class="list-group-item px-4 py-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{{ log.project.name }}</h6>
<small class="text-muted">{{ log.date }} &middot; {{ log.workers.count }} workers</small>
</div>
</div>
</div>
{% empty %}
<div class="p-4 text-center text-muted">
<i class="fas fa-inbox fa-2x mb-2 d-block opacity-50"></i>
No recent activity.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// === RESOURCE FILTER (Active / Inactive / All) ===
// Hides/shows resource rows based on their data-active attribute.
// Starts on "Active" so only current items are visible by default.
var currentFilter = 'active';
var filterBtns = document.querySelectorAll('#resourceFilter button');
function applyFilter() {
// Use the resource-hidden CLASS (not inline display:none) because
// Bootstrap's d-flex has !important which overrides inline styles.
// Our .resource-hidden also has !important, so it wins.
document.querySelectorAll('.resource-row').forEach(function(row) {
var isActive = row.dataset.active === 'true';
var show = false;
if (currentFilter === 'all') show = true;
else if (currentFilter === 'active') show = isActive;
else if (currentFilter === 'inactive') show = !isActive;
if (show) {
row.classList.remove('resource-hidden');
} else {
row.classList.add('resource-hidden');
}
});
// Show "No matching items" if a tab has rows but none are visible
document.querySelectorAll('.tab-pane').forEach(function(pane) {
var rows = pane.querySelectorAll('.resource-row');
var visibleRows = Array.from(rows).filter(function(r) { return !r.classList.contains('resource-hidden'); });
var emptyMsg = pane.querySelector('.resource-empty');
if (emptyMsg) {
emptyMsg.style.display = (rows.length > 0 && visibleRows.length === 0) ? '' : 'none';
}
});
}
filterBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
filterBtns.forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
currentFilter = this.dataset.filter;
applyFilter();
});
});
// Apply filter on page load (shows only active by default)
applyFilter();
// === TOGGLE HANDLER ===
// When a toggle switch is flipped, POST to the server to update active status.
// On success, update the row's data-active attribute and re-apply the filter
// so the row moves to the correct section immediately.
var toggleSwitches = document.querySelectorAll('.toggle-active');
toggleSwitches.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');
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') {
// Update the row's data-active and re-apply filter
row.dataset.active = isChecked ? 'true' : 'false';
applyFilter();
} else {
switchEl.checked = !isChecked;
alert('Error updating status.');
}
})
.catch(function(error) {
switchEl.checked = !isChecked;
alert('Error updating status.');
});
});
});
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,239 @@
{% extends 'base.html' %}
{% block title %}Payslip #{{ record.id }} | Fox Fitt{% endblock %}
{% block content %}
<!-- === PAYSLIP DETAIL PAGE ===
Shows a completed payment with work logs, adjustments, and totals.
Reached from the Payment History tab on the payroll dashboard.
Has a Print button that uses the browser's native print dialog. -->
<div class="container py-5">
<!-- Action buttons (hidden when printing) -->
<div class="d-print-none mb-4 d-grid gap-2 d-md-flex">
<a href="{% url 'payroll_dashboard' %}?status=paid" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back to Payment History
</a>
<button onclick="window.print()" class="btn btn-accent">
<i class="fas fa-print me-1"></i> Print Payslip
</button>
</div>
<!-- Payslip card -->
<div class="card border-0 shadow-sm" id="payslip-card">
<div class="card-body p-5">
<!-- === HEADER — worker name is the dominant element === -->
<div class="row mb-5 border-bottom pb-4 align-items-center">
<div class="col-md-6">
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
<h2 class="fw-bold text-dark mb-0 text-uppercase">{{ record.worker.name }}</h2>
<p class="text-muted small mb-0">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}</p>
</div>
<div class="col-md-6 text-md-end mt-3 mt-md-0">
<h3 class="fw-bold text-uppercase text-secondary mb-1">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip</h3>
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
<div class="text-muted small">Payer: Fox Fitt</div>
</div>
</div>
<!-- === WORKER DETAILS + NET PAY === -->
<div class="row mb-5">
<div class="col-md-6">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Beneficiary Details:</h6>
<h4 class="fw-bold">{{ record.worker.name }}</h4>
<p class="mb-0">ID Number: <strong>{{ record.worker.id_number }}</strong></p>
<p class="mb-0">Phone: {{ record.worker.phone_number|default:"—" }}</p>
</div>
<div class="col-md-6 text-md-end mt-4 mt-md-0">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Net Payable Amount:</h6>
<div class="display-6 fw-bold text-dark">R {{ record.amount_paid|floatformat:2 }}</div>
<p class="text-success small fw-bold mt-2">
<i class="fas fa-check-circle me-1"></i> PAID
</p>
</div>
</div>
{% if is_advance %}
<!-- === ADVANCE PAYMENT DETAIL === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Advance Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ advance_adj.date|date:"M d, Y" }}</td>
<td><span class="badge bg-info text-dark text-uppercase">Advance Payment</span></td>
<td>{{ advance_adj.description|default:"Salary advance" }}</td>
<td class="text-end text-success fw-bold">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === ADVANCE TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Amount Advanced:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ advance_adj.amount|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
{% elif is_loan %}
<!-- === LOAN PAYMENT DETAIL === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Loan Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ loan_adj.date|date:"M d, Y" }}</td>
<td><span class="badge bg-warning text-dark text-uppercase">Loan Payment</span></td>
<td>{{ loan_adj.description|default:"Worker loan" }}</td>
<td class="text-end text-success fw-bold">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === LOAN TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Loan Amount:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
{% else %}
<!-- === WORK LOG TABLE — each day worked === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th>Notes</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.date|date:"M d, Y" }}</td>
<td>{{ log.project.name }}</td>
<td>{{ log.notes|default:"—"|truncatechars:50 }}</td>
<td class="text-end">R {{ record.worker.daily_rate|floatformat:2 }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted">
<i class="fas fa-info-circle me-1"></i> No work logs in this period.
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
<td class="text-end fw-bold">R {{ base_pay|floatformat:2 }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- === ADJUSTMENTS TABLE — bonuses, deductions, overtime, loan repayments === -->
{% if adjustments %}
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
{% for adj in adjustments %}
<tr>
<td>{{ adj.date|date:"M d, Y" }}</td>
<td>
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
</td>
<td>{{ adj.description }}</td>
<td class="text-end {% if adj.type in deductive_types %}text-danger{% else %}text-success{% endif %}">
{% if adj.type in deductive_types %}
- R {{ adj.amount|floatformat:2 }}
{% else %}
+ R {{ adj.amount|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- === GRAND TOTAL SUMMARY === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr>
<td class="text-end border-0 text-muted">Base Pay:</td>
<td class="text-end border-0" width="140">R {{ base_pay|floatformat:2 }}</td>
</tr>
{% if adjustments %}
<tr>
<td class="text-end border-0 text-muted">Adjustments Net:</td>
<td class="text-end border-0">
{% if adjustments_net >= 0 %}
+ R {{ adjustments_net|floatformat:2 }}
{% else %}
- R {{ adjustments_net_abs|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endif %}
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount_paid|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
{% endif %}
<!-- === FOOTER === -->
<div class="text-center text-muted small mt-5 pt-4 border-top">
<p>This is a computer-generated document and does not require a signature.</p>
<p>Payer: Fox Fitt &copy; 2026</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,171 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* === PAGE SETUP === */
/* A4 portrait with 2cm margins and a footer frame at the bottom */
@page {
size: a4 portrait;
margin: 2cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 1cm;
margin-left: 1cm;
margin-right: 1cm;
height: 1cm;
}
}
/* === BODY STYLES === */
body {
font-family: Helvetica, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #333;
}
/* === HEADER — worker name is the dominant element === */
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 20px;
}
.beneficiary-name {
font-size: 24pt;
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.sub-header {
font-size: 12pt;
color: #666;
margin-bottom: 5px;
}
.title {
font-size: 18pt;
font-weight: bold;
color: #666;
}
/* === META BOX — beneficiary details === */
.meta {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
}
/* === ITEMS TABLE — base pay + adjustments === */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.items-table th {
border-bottom: 1px solid #000;
padding: 8px;
text-align: left;
background-color: #eee;
font-weight: bold;
}
.items-table td {
border-bottom: 1px solid #eee;
padding: 8px;
text-align: left;
}
/* === TOTALS === */
.totals {
text-align: right;
margin-top: 20px;
border-top: 2px solid #333;
padding-top: 10px;
}
.total-row {
font-size: 16pt;
font-weight: bold;
color: #000;
}
/* === FOOTER — small print at bottom of page === */
.footer {
text-align: center;
font-size: 10pt;
color: #777;
}
/* Helpers */
.text-right { text-align: right; }
.positive { color: green; }
.negative { color: red; }
</style>
</head>
<body>
<!-- Header: worker name is the biggest element (per CLAUDE.md rule) -->
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<!-- Beneficiary details box -->
<div class="meta">
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
<strong>Date:</strong> {{ record.date }}
</div>
<!-- Line items: base pay + all adjustments -->
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{% if is_advance %}
<!-- Advance Payment — single line item -->
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td class="text-right">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% elif is_loan %}
<!-- Loan Payment — single line item -->
<tr>
<td>Loan Payment: {{ loan_description }}</td>
<td class="text-right">R {{ loan_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base Pay — number of days worked × day rate -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td class="text-right">R {{ logs_amount|floatformat:2 }}</td>
</tr>
<!-- All payroll adjustments (bonuses, deductions, overtime, loan repayments) -->
{% for adj in adjustments %}
<tr>
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
<td class="text-right {% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<!-- Net pay total -->
<div class="totals">
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
</div>
<!-- Footer frame (positioned at bottom of page by xhtml2pdf) -->
<div id="footerContent" class="footer">
Payer: Fox Fitt | Generated for {{ record.worker.name }} | {% now "Y-m-d H:i" %}
</div>
</body>
</html>

View File

@ -0,0 +1,142 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* === PAGE SETUP === */
/* A4 portrait with 2cm margins and a footer frame at the bottom */
@page {
size: a4 portrait;
margin: 2cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 1cm;
margin-left: 1cm;
margin-right: 1cm;
height: 1cm;
}
}
/* === BODY STYLES === */
body {
font-family: Helvetica, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #333;
}
/* === HEADER — vendor name is the dominant element === */
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 20px;
}
.vendor-name {
font-size: 24pt;
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.sub-header {
font-size: 12pt;
color: #666;
margin-bottom: 5px;
}
/* === META BOX — receipt details === */
.meta {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
}
/* === ITEMS TABLE === */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.items-table th {
border-bottom: 1px solid #000;
padding: 8px;
text-align: left;
background-color: #eee;
font-weight: bold;
}
.items-table td {
border-bottom: 1px solid #eee;
padding: 8px;
text-align: left;
}
/* === TOTALS === */
.totals {
text-align: right;
margin-top: 20px;
}
.total-row {
font-size: 16pt;
font-weight: bold;
border-top: 1px solid #000;
padding-top: 5px;
margin-top: 5px;
}
/* === FOOTER — small print at bottom of page === */
.footer {
text-align: center;
font-size: 10pt;
color: #777;
}
/* Helpers */
.text-right { text-align: right; }
</style>
</head>
<body>
<!-- Header: vendor name is the biggest element -->
<div class="header">
<div class="sub-header">RECEIPT FROM</div>
<div class="vendor-name">{{ receipt.vendor_name }}</div>
</div>
<!-- Receipt details box -->
<div class="meta">
<strong>Date:</strong> {{ receipt.date }}<br>
<strong>Payment Method:</strong> {{ receipt.get_payment_method_display }}<br>
<strong>Description:</strong> {{ receipt.description|default:"-" }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Item</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product_name }}</td>
<td class="text-right">R {{ item.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p>Subtotal: R {{ receipt.subtotal|floatformat:2 }}</p>
<p>VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}</p>
<p class="total-row">Total: R {{ receipt.total_amount|floatformat:2 }}</p>
</div>
<!-- Footer frame (positioned at bottom of page by xhtml2pdf) -->
<div id="footerContent" class="footer">
Generated by {{ receipt.user.get_full_name|default:receipt.user.username }} via Fox Fitt App | {% now "Y-m-d H:i" %}
</div>
</body>
</html>

View File

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

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center min-vh-100">
<div class="card shadow-sm" style="width: 100%; max-width: 400px; border-radius: 12px; border: none;">
<div class="card-body p-5">
<h2 class="text-center mb-4" style="font-family: 'Poppins', sans-serif; font-weight: 700;">
<span style="color: #10b981;">Fox</span>Fitt
</h2>
{% if form.errors %}
<div class="alert alert-danger" role="alert">
Your username and password didn't match. Please try again.
</div>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_username" class="form-label" style="font-family: 'Inter', sans-serif; font-weight: 500;">Username</label>
<input type="text" name="username" class="form-control form-control-lg" id="id_username" required autofocus style="border-radius: 8px;">
</div>
<div class="mb-4">
<label for="id_password" class="form-label" style="font-family: 'Inter', sans-serif; font-weight: 500;">Password</label>
<input type="password" name="password" class="form-control form-control-lg" id="id_password" required style="border-radius: 8px;">
</div>
<button type="submit" class="btn btn-lg w-100 text-white" style="background-color: #10b981; border: none; border-radius: 8px; font-weight: 600;">Login</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,7 +1,70 @@
from django.urls import path
# === URL ROUTING ===
# Maps URLs to view functions. Each path() connects a web address to
# the Python function that handles it.
from .views import home
from django.urls import path
from . import views
urlpatterns = [
path("", home, name="home"),
# Dashboard — the home page after login
path('', views.index, name='home'),
# Attendance logging — where supervisors log daily work
path('attendance/log/', views.attendance_log, name='attendance_log'),
# Work history — table of all work logs with filters
path('history/', views.work_history, name='work_history'),
# CSV export — downloads filtered work logs as a spreadsheet
path('history/export/', views.export_work_log_csv, name='export_work_log_csv'),
# CSV export — downloads all worker data (admin only)
path('workers/export/', views.export_workers_csv, name='export_workers_csv'),
# AJAX toggle — activates/deactivates workers, projects, teams from dashboard
path('toggle/<str:model_name>/<int:item_id>/', views.toggle_active, name='toggle_active'),
# === PAYROLL ===
# Main payroll dashboard — shows pending payments, history, loans, and charts
path('payroll/', views.payroll_dashboard, name='payroll_dashboard'),
# Process payment — pays a worker and links their unpaid logs + adjustments
path('payroll/pay/<int:worker_id>/', views.process_payment, name='process_payment'),
# Batch pay — preview which workers would be paid, then process all at once
path('payroll/batch-pay/preview/', views.batch_pay_preview, name='batch_pay_preview'),
path('payroll/batch-pay/', views.batch_pay, name='batch_pay'),
# Price overtime — creates Overtime adjustments from unpriced OT entries
path('payroll/price-overtime/', views.price_overtime, name='price_overtime'),
# Add a new payroll adjustment (bonus, deduction, loan, etc.)
path('payroll/adjustment/add/', views.add_adjustment, name='add_adjustment'),
# Edit an existing unpaid adjustment
path('payroll/adjustment/<int:adj_id>/edit/', views.edit_adjustment, name='edit_adjustment'),
# Delete an unpaid adjustment
path('payroll/adjustment/<int:adj_id>/delete/', views.delete_adjustment, name='delete_adjustment'),
# Preview a worker's payslip (AJAX — returns JSON)
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
# Add a repayment from the payslip preview modal (AJAX — returns JSON)
path('payroll/repayment/<int:worker_id>/', views.add_repayment_ajax, name='add_repayment_ajax'),
# View a completed payslip (print-friendly page)
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
# === EXPENSE RECEIPTS ===
# Create a new expense receipt — emails HTML + PDF to Spark Receipt
path('receipts/create/', views.create_receipt, name='create_receipt'),
# === TEMPORARY: Import production data from browser ===
# Visit /import-data/ once to populate the database. Remove after use.
path('import-data/', views.import_data, name='import_data'),
# === TEMPORARY: Run migrations from browser ===
# Visit /run-migrate/ to apply pending database migrations on production.
path('run-migrate/', views.run_migrate, name='run_migrate'),
]

52
core/utils.py Normal file
View File

@ -0,0 +1,52 @@
# === PDF GENERATION ===
# Converts a Django HTML template into a PDF file using xhtml2pdf.
# Used for payslip and receipt PDF attachments sent via email.
#
# IMPORTANT: xhtml2pdf is imported LAZILY (inside the function, not at the
# top of the file). This is intentional — if xhtml2pdf fails to install on
# the server (missing C libraries), the rest of the app still works.
# Only PDF generation will fail gracefully.
import logging
from io import BytesIO
from django.template.loader import get_template
logger = logging.getLogger(__name__)
def render_to_pdf(template_src, context_dict=None):
"""
Render a Django template to PDF bytes.
Args:
template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html')
context_dict: Template context variables
Returns:
PDF content as bytes, or None if there was an error.
"""
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.
try:
from xhtml2pdf import pisa
except ImportError:
logger.error(
"xhtml2pdf is not installed — cannot generate PDF. "
"Install it with: pip install xhtml2pdf"
)
return None
# Load and render the HTML template
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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
# Expense Receipt Feature — Design Doc
## Date: 2026-02-22
## Summary
Straight port of V2's expense receipt feature to V5. Single-page form at `/receipts/create/` where admins and supervisors record business expenses with dynamic line items and VAT calculation. Automatically emails HTML + PDF receipt to Spark Receipt.
## Files
| File | Action |
|------|--------|
| `core/forms.py` | Add ExpenseReceiptForm + ExpenseLineItemFormSet |
| `core/views.py` | Add create_receipt() view |
| `core/urls.py` | Add /receipts/create/ route |
| `core/templates/core/create_receipt.html` | Create form page |
| `core/templates/core/email/receipt_email.html` | Create email template |
| `core/templates/core/pdf/receipt_pdf.html` | Create PDF template |
| `core/templates/base.html` | Add Receipts navbar link |
## V5 Naming Adaptations
- `vendor` -> `vendor_name`, `product` -> `product_name`
- `items` related_name -> `line_items`
- Choice values: Title Case ('Included') not UPPERCASE ('INCLUDED')
- Lazy xhtml2pdf import (same as payslip)
## VAT Logic (15% SA rate)
- Included: Total = sum, Subtotal = Total / 1.15, VAT = Total - Subtotal
- Excluded: Subtotal = sum, VAT = Subtotal * 0.15, Total = Subtotal + VAT
- None: Subtotal = Total = sum, VAT = 0

View File

@ -0,0 +1,23 @@
# Payslip Feature Design — 22 Feb 2026
## Goal
Complete the payment workflow: when "Pay" is clicked, generate a PDF payslip and email it to Spark. Also add a payslip detail page for viewing/printing past payslips.
## Files
1. `core/utils.py` — render_to_pdf() xhtml2pdf wrapper
2. `core/templates/core/pdf/payslip_pdf.html` — A4 PDF template
3. `core/templates/core/email/payslip_email.html` — HTML email body
4. `core/templates/core/payslip.html` — Browser payslip detail page
5. `core/views.py` — payslip_detail view + email in process_payment
6. `core/urls.py` — payroll/payslip/<pk>/
7. `config/settings.py` — SPARK_RECEIPT_EMAIL + DEFAULT_FROM_EMAIL
## Flow
process_payment → atomic(create record, link logs/adjs, update loans) → email PDF to Spark → redirect
## Key: V2 → V5 field mapping
- record.amount → record.amount_paid
- worker.id_no → worker.id_number
- worker.phone_no → worker.phone_number
- loan.balance → loan.remaining_balance
- loan.amount → loan.principal_amount

View File

@ -1,3 +1,5 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
pillow==12.1.1
xhtml2pdf==0.2.16

View File

@ -1,4 +1,81 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
:root {
--primary-dark: #0f172a;
--primary: #1e293b;
--accent: #10b981;
--background: #f1f5f9;
--text-main: #334155;
--text-secondary: #64748b;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--text-main);
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Poppins', sans-serif;
color: var(--primary);
font-weight: 700;
}
.navbar {
background-color: var(--primary-dark) !important;
}
.btn-primary {
background-color: var(--primary);
border-color: var(--primary);
}
.btn-primary:hover {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
}
.btn-accent {
background-color: var(--accent);
color: white;
font-weight: 600;
border-radius: 0.375rem; /* Bootstrap rounded */
border: none;
}
.btn-accent:hover {
background-color: #0d9668; /* slightly darker green for hover */
color: white;
}
.stat-card {
background-color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
border: none;
border-radius: 0.5rem; /* rounded corners */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); /* subtle shadow */
}
.dashboard-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--text-main) 100%);
color: white;
padding: 2rem;
margin-bottom: -4rem; /* negative bottom margin */
border-radius: 0.5rem;
}
.card {
border: none;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.hero-section {
background: linear-gradient(135deg, var(--primary) 0%, var(--text-secondary) 100%);
color: white;
padding: 100px 0;
}
.footer {
background-color: var(--primary-dark);
color: white;
padding: 20px 0;
margin-top: 50px;
}