Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "cmd",
|
||||
"runtimeArgs": ["/c", "run_dev.bat"],
|
||||
"port": 8000
|
||||
}
|
||||
]
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,13 +1,3 @@
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
*/build/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.db
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
media/
|
||||
.venv/
|
||||
187
CLAUDE.md
187
CLAUDE.md
@ -1,187 +0,0 @@
|
||||
# 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
|
||||
BIN
config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
config/__pycache__/settings.cpython-311.pyc
Normal file
BIN
config/__pycache__/settings.cpython-311.pyc
Normal file
Binary file not shown.
BIN
config/__pycache__/urls.cpython-311.pyc
Normal file
BIN
config/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
config/__pycache__/wsgi.cpython-311.pyc
Normal file
BIN
config/__pycache__/wsgi.cpython-311.pyc
Normal file
Binary file not shown.
@ -23,13 +23,11 @@ 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
|
||||
@ -137,7 +135,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'Africa/Johannesburg'
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@ -153,36 +151,28 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
|
||||
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
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
||||
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
|
||||
@ -190,31 +180,3 @@ 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'
|
||||
|
||||
@ -1,3 +1,19 @@
|
||||
"""
|
||||
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
|
||||
@ -5,10 +21,9 @@ 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)
|
||||
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/admin.cpython-311.pyc
Normal file
BIN
core/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/apps.cpython-311.pyc
Normal file
BIN
core/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/context_processors.cpython-311.pyc
Normal file
BIN
core/__pycache__/context_processors.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/models.cpython-311.pyc
Normal file
BIN
core/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/urls.cpython-311.pyc
Normal file
BIN
core/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/views.cpython-311.pyc
Normal file
BIN
core/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
@ -1,74 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import (
|
||||
UserProfile, Project, Worker, Team, WorkLog,
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem
|
||||
)
|
||||
|
||||
@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')
|
||||
# Register your models here.
|
||||
|
||||
215
core/forms.py
215
core/forms.py
@ -1,215 +0,0 @@
|
||||
# === 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'
|
||||
}),
|
||||
}
|
||||
)
|
||||
@ -1,405 +0,0 @@
|
||||
# === 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
|
||||
@ -1,74 +0,0 @@
|
||||
# === 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.'))
|
||||
@ -1,180 +0,0 @@
|
||||
# === 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!')
|
||||
@ -1,136 +0,0 @@
|
||||
# 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,65 +0,0 @@
|
||||
# === 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),
|
||||
]
|
||||
@ -1,25 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
199
core/models.py
199
core/models.py
@ -1,200 +1,3 @@
|
||||
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
|
||||
|
||||
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
|
||||
# Create your models here.
|
||||
|
||||
@ -1,124 +1,25 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}FoxFitt{% endblock %}</title>
|
||||
<!-- 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>
|
||||
<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 %}
|
||||
</head>
|
||||
|
||||
<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">© {% 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>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
|
||||
@ -1,326 +0,0 @@
|
||||
{% 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) ×
|
||||
<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 %}
|
||||
@ -1,326 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,93 +0,0 @@
|
||||
<!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>
|
||||
@ -1,70 +0,0 @@
|
||||
<!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>
|
||||
@ -1,472 +1,145 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard | FoxFitt{% endblock %}
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% 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>
|
||||
{# 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>
|
||||
<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>
|
||||
: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);
|
||||
}
|
||||
|
||||
<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>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
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;
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
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;
|
||||
}
|
||||
|
||||
<!-- 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 }} · {{ 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 }} · {{ 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';
|
||||
}
|
||||
});
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter on page load (shows only active by default)
|
||||
applyFilter();
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
// === 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');
|
||||
.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);
|
||||
}
|
||||
|
||||
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');
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
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>
|
||||
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 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>
|
||||
</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>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,239 +0,0 @@
|
||||
{% 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 © 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,171 +0,0 @@
|
||||
<!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>
|
||||
@ -1,142 +0,0 @@
|
||||
<!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>
|
||||
@ -1,671 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,32 +0,0 @@
|
||||
{% 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 %}
|
||||
69
core/urls.py
69
core/urls.py
@ -1,70 +1,7 @@
|
||||
# === URL ROUTING ===
|
||||
# Maps URLs to view functions. Each path() connects a web address to
|
||||
# the Python function that handles it.
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
from .views import home
|
||||
|
||||
urlpatterns = [
|
||||
# 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'),
|
||||
path("", home, name="home"),
|
||||
]
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
# === 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
|
||||
2484
core/views.py
2484
core/views.py
File diff suppressed because it is too large
Load Diff
@ -1,28 +0,0 @@
|
||||
# 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
|
||||
@ -1,23 +0,0 @@
|
||||
# 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
|
||||
@ -1,5 +1,3 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
pillow==12.1.1
|
||||
xhtml2pdf==0.2.16
|
||||
@ -1,81 +1,4 @@
|
||||
:root {
|
||||
--primary-dark: #0f172a;
|
||||
--primary: #1e293b;
|
||||
--accent: #10b981;
|
||||
--background: #f1f5f9;
|
||||
--text-main: #334155;
|
||||
--text-secondary: #64748b;
|
||||
}
|
||||
|
||||
/* Custom styles for the application */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--text-main);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user