Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

49 changed files with 198 additions and 9693 deletions

View File

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

10
.gitignore vendored
View File

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

187
CLAUDE.md
View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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'

View File

@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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.

View File

@ -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'
}),
}
)

View File

@ -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

View File

@ -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.'))

View File

@ -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!')

View File

@ -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')),
],
),
]

View File

@ -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),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

Binary file not shown.

View File

@ -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.

View File

@ -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">&copy; {% now "Y" %} FoxFitt Construction. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap 5.3 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{% block content %}{% endblock %}
</body>
</html>
</html>

View File

@ -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) &times;
<span id="selectedDayCount">1</span> day(s)
</small>
</div>
<hr>
<small class="text-muted">
This estimate is based on each worker's daily rate multiplied by the
number of working days selected. Overtime is not included.
</small>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{# --- JavaScript for dynamic features --- #}
<script>
document.addEventListener('DOMContentLoaded', function() {
// === TEAM AUTO-SELECT ===
// When a team is chosen from the dropdown, automatically check all workers
// that belong to that team. Uses team_workers_json passed from the view.
var teamWorkersMap = JSON.parse('{{ team_workers_json|escapejs }}');
var teamSelect = document.querySelector('[name="team"]');
if (teamSelect) {
teamSelect.addEventListener('change', function() {
var teamId = this.value;
// First, uncheck ALL worker checkboxes
var allBoxes = document.querySelectorAll('input[name="workers"]');
allBoxes.forEach(function(cb) {
cb.checked = false;
});
// Then check workers that belong to the selected team
if (teamId && teamWorkersMap[teamId]) {
var workerIds = teamWorkersMap[teamId];
workerIds.forEach(function(id) {
var checkbox = document.querySelector('input[name="workers"][value="' + id + '"]');
if (checkbox) {
checkbox.checked = true;
}
});
}
// Recalculate estimated cost if the admin cost calculator exists
if (typeof updateEstimatedCost === 'function') {
updateEstimatedCost();
}
});
}
{% if is_admin %}
// === ESTIMATED COST CALCULATOR (Admin Only) ===
// Updates the cost card in real-time as workers and dates are selected.
// Worker daily rates passed from the view
const workerRates = {{ worker_rates_json|safe }};
const startDateInput = document.querySelector('[name="date"]');
const endDateInput = document.querySelector('[name="end_date"]');
const satCheckbox = document.querySelector('[name="include_saturday"]');
const sunCheckbox = document.querySelector('[name="include_sunday"]');
const workerCheckboxes = document.querySelectorAll('[name="workers"]');
const costDisplay = document.getElementById('estimatedCost');
const workerCountDisplay = document.getElementById('selectedWorkerCount');
const dayCountDisplay = document.getElementById('selectedDayCount');
function countWorkingDays() {
// Count how many working days are in the selected date range
const startDate = startDateInput ? new Date(startDateInput.value) : null;
const endDateVal = endDateInput ? endDateInput.value : '';
const endDate = endDateVal ? new Date(endDateVal) : startDate;
if (!startDate || isNaN(startDate)) return 1;
if (!endDate || isNaN(endDate)) return 1;
let count = 0;
let current = new Date(startDate);
while (current <= endDate) {
const day = current.getDay(); // 0=Sun, 6=Sat
if (day === 6 && !(satCheckbox && satCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
if (day === 0 && !(sunCheckbox && sunCheckbox.checked)) {
current.setDate(current.getDate() + 1);
continue;
}
count++;
current.setDate(current.getDate() + 1);
}
return Math.max(count, 1);
}
function updateEstimatedCost() {
// Add up daily rates of all checked workers, multiply by number of days
let totalDailyRate = 0;
let selectedCount = 0;
workerCheckboxes.forEach(function(cb) {
if (cb.checked) {
const workerId = cb.value;
if (workerRates[workerId]) {
totalDailyRate += parseFloat(workerRates[workerId]);
}
selectedCount++;
}
});
const days = countWorkingDays();
const totalCost = totalDailyRate * days;
// Update the display
if (costDisplay) costDisplay.textContent = 'R ' + totalCost.toLocaleString('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (workerCountDisplay) workerCountDisplay.textContent = selectedCount;
if (dayCountDisplay) dayCountDisplay.textContent = days;
}
// Listen for changes on all relevant inputs
workerCheckboxes.forEach(function(cb) {
cb.addEventListener('change', updateEstimatedCost);
});
if (startDateInput) startDateInput.addEventListener('change', updateEstimatedCost);
if (endDateInput) endDateInput.addEventListener('change', updateEstimatedCost);
if (satCheckbox) satCheckbox.addEventListener('change', updateEstimatedCost);
if (sunCheckbox) sunCheckbox.addEventListener('change', updateEstimatedCost);
// Run once on page load in case of pre-selected values
updateEstimatedCost();
{% endif %}
});
</script>
{% endblock %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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 }} &middot; {{ log.workers.count }} workers</small>
</div>
<span class="badge bg-light text-dark border">{{ log.supervisor.username }}</span>
</div>
</div>
{% empty %}
<div class="p-4 text-center text-muted">
No recent activity.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Manage Resources -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Manage Resources</h6>
<a href="{% url 'export_workers_csv' %}" class="btn btn-outline-success btn-sm">
<i class="fas fa-file-csv me-1"></i> Export Workers
</a>
</div>
<div class="card-body p-0">
<p class="text-muted small mb-0 px-3 pt-3">Toggle active status. Inactive items are hidden from forms.</p>
<ul class="nav nav-tabs px-3 pt-2" id="resourceTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="workers-tab" data-bs-toggle="tab" data-bs-target="#workers" type="button" role="tab" aria-selected="true">Workers</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="projects-tab" data-bs-toggle="tab" data-bs-target="#projects" type="button" role="tab" aria-selected="false">Projects</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="teams-tab" data-bs-toggle="tab" data-bs-target="#teams" type="button" role="tab" aria-selected="false">Teams</button>
</li>
</ul>
{# Filter bar — Active / Inactive / All (defaults to Active) #}
<div class="btn-group btn-group-sm w-100 px-3 mt-2" id="resourceFilter" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="active">Active</button>
<button type="button" class="btn btn-outline-secondary" data-filter="inactive">Inactive</button>
<button type="button" class="btn btn-outline-secondary" data-filter="all">All</button>
</div>
<div class="tab-content px-0 mt-2" id="resourceTabsContent" style="max-height: 350px; overflow-y: auto;">
{# === WORKERS TAB === #}
<div class="tab-pane fade show active" id="workers" role="tabpanel">
{% for item in workers %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="worker" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No workers found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
{# === PROJECTS TAB === #}
<div class="tab-pane fade" id="projects" role="tabpanel">
{% for item in projects %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="project" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No projects found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
{# === TEAMS TAB === #}
<div class="tab-pane fade" id="teams" role="tabpanel">
{% for item in teams %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 px-3 border-bottom {% if not item.active %}resource-hidden{% endif %}" data-active="{% if item.active %}true{% else %}false{% endif %}">
<strong class="small">{{ item.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input toggle-active" type="checkbox" role="switch" data-type="team" data-id="{{ item.id }}" {% if item.active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small px-3 py-2">No teams found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- Supervisor View -->
<!-- Stat Cards — how many projects, teams, and workers this supervisor manages -->
<div class="row g-4 mb-4 position-relative">
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #8b5cf6;">
My Projects</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_projects_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-project-diagram fa-2x opacity-50" style="color: #8b5cf6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #3b82f6;">
My Teams</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_teams_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x opacity-50" style="color: #3b82f6;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
My Workers</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ my_workers_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-hard-hat fa-2x opacity-50" style="color: #10b981;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- This Week + Recent Activity -->
<div class="row mb-4">
<div class="col-lg-4 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 fw-bold" style="color: #0f172a;">This Week Summary</h6>
</div>
<div class="card-body text-center d-flex flex-column justify-content-center">
<div class="h1 mb-0 fw-bold text-primary">{{ this_week_logs }}</div>
<div class="text-muted">Work Logs Created This Week</div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 fw-bold" style="color: #0f172a;">Recent Activity</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for log in recent_activity %}
<div class="list-group-item px-4 py-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{{ log.project.name }}</h6>
<small class="text-muted">{{ log.date }} &middot; {{ log.workers.count }} workers</small>
</div>
</div>
</div>
{% empty %}
<div class="p-4 text-center text-muted">
<i class="fas fa-inbox fa-2x mb-2 d-block opacity-50"></i>
No recent activity.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// === RESOURCE FILTER (Active / Inactive / All) ===
// Hides/shows resource rows based on their data-active attribute.
// Starts on "Active" so only current items are visible by default.
var currentFilter = 'active';
var filterBtns = document.querySelectorAll('#resourceFilter button');
function applyFilter() {
// Use the resource-hidden CLASS (not inline display:none) because
// Bootstrap's d-flex has !important which overrides inline styles.
// Our .resource-hidden also has !important, so it wins.
document.querySelectorAll('.resource-row').forEach(function(row) {
var isActive = row.dataset.active === 'true';
var show = false;
if (currentFilter === 'all') show = true;
else if (currentFilter === 'active') show = isActive;
else if (currentFilter === 'inactive') show = !isActive;
if (show) {
row.classList.remove('resource-hidden');
} else {
row.classList.add('resource-hidden');
}
});
// Show "No matching items" if a tab has rows but none are visible
document.querySelectorAll('.tab-pane').forEach(function(pane) {
var rows = pane.querySelectorAll('.resource-row');
var visibleRows = Array.from(rows).filter(function(r) { return !r.classList.contains('resource-hidden'); });
var emptyMsg = pane.querySelector('.resource-empty');
if (emptyMsg) {
emptyMsg.style.display = (rows.length > 0 && visibleRows.length === 0) ? '' : 'none';
}
});
@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

View File

@ -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 &copy; 2026</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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"),
]

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;
}