Fix 3 critical bugs in dashboard + attendance logging

- Fix outstanding payments: check per-worker (not per-log) to handle partially-paid WorkLogs
- Fix adjustment math: deductions now subtract from outstanding instead of adding
- Fix conflict resolution: use explicit worker ID list (QueryDict.getlist) instead of broken form.data.workers iteration
- Add missing migration 0003 for Project start_date/end_date fields
- Add CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-27 18:28:11 +02:00
parent b7baf88cfc
commit 19c662ec7d
5 changed files with 247 additions and 29 deletions

174
CLAUDE.md Normal file
View File

@ -0,0 +1,174 @@
# 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 17 view functions (~1700 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
- **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 advances with principal and remaining_balance tracking
- **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']` — increase worker's net pay
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Payment']` — decrease net pay
## PayrollAdjustment Type Handling
- **Bonus / Deduction** — standalone, require a linked Project
- **New Loan** — creates a `Loan` record; editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
- **Overtime** — links to `WorkLog` via `adj.work_log` FK; managed by `price_overtime()` view
- **Loan Repayment** — links to `Loan` via `adj.loan` FK; loan balance changes during payment processing
- **Advance Payment** — requires a linked Project; reduces net pay
## 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
## 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 |
| `/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 |
## 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**: Run automatically during Flatlogic rebuild
- **Never edit `ai-dev` directly on GitHub** — Flatlogic pushes overwrite it
- **Gemini gotcha**: Flatlogic's Gemini AI reads `__pycache__/*.pyc` and gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files."
- **Sequential workflow**: Don't edit in Flatlogic and Claude Code at the same time
## Security Notes
- Production: `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True`, `SameSite=None` (cross-origin for Flatlogic iframe)
- Local dev: Secure cookies disabled when `USE_SQLITE=true`
- X-Frame-Options middleware disabled (required for Flatlogic preview)
- Email App Password should be in env var, not hardcoded in settings.py
## Important Context
- The owner (Konrad) is not a developer — explain changes clearly and avoid unnecessary complexity
- This system handles real payroll for field workers — accuracy is critical
- `render_to_pdf()` uses lazy import of xhtml2pdf to prevent app crash if library missing
- Django admin is available at `/admin/` with full model registration and search/filter

View File

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

View File

@ -27,6 +27,8 @@ class Project(models.Model):
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

View File

@ -35,18 +35,17 @@
<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' %}
{% if key == 'workers' %}
{# Workers is a multi-value field — need each value separately #}
{% for worker_val in form.data.workers %}
<input type="hidden" name="workers" value="{{ worker_val }}">
{% endfor %}
{% else %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% 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
@ -55,16 +54,13 @@
<form method="POST" class="d-inline">
{% csrf_token %}
{% for key, value in form.data.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'conflict_action' %}
{% if key == 'workers' %}
{% for worker_val in form.data.workers %}
<input type="hidden" name="workers" value="{{ worker_val }}">
{% endfor %}
{% else %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% 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

View File

@ -72,34 +72,47 @@ def index(request):
# --- ADMIN DASHBOARD ---
# Calculate total value of unpaid work and break it down by project.
# A WorkLog is "unpaid" if it has no linked PayrollRecord entries.
unpaid_worklogs = WorkLog.objects.filter(
payroll_records__isnull=True
).select_related('project').prefetch_related('workers')
# A WorkLog is "unpaid for worker X" if no PayrollRecord links BOTH
# that log AND that worker. This handles partially-paid logs where
# some workers have been paid but others haven't.
all_worklogs = WorkLog.objects.select_related(
'project'
).prefetch_related('workers', 'payroll_records')
outstanding_payments = Decimal('0.00')
outstanding_by_project = {}
for wl in unpaid_worklogs:
for wl in all_worklogs:
# Get the set of worker IDs that have been paid for this log
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
project_name = wl.project.name
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
for worker in wl.workers.all():
cost = worker.daily_rate
outstanding_payments += cost
outstanding_by_project[project_name] += cost
if worker.id not in paid_worker_ids:
cost = worker.daily_rate
outstanding_payments += cost
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
outstanding_by_project[project_name] += cost
# Also include unpaid payroll adjustments (bonuses, deductions, etc.)
# Additive types (Bonus, Overtime, New Loan) increase outstanding.
# Deductive types (Deduction, Loan Repayment, Advance Payment) decrease it.
unpaid_adjustments = PayrollAdjustment.objects.filter(
payroll_record__isnull=True
).select_related('project')
for adj in unpaid_adjustments:
outstanding_payments += adj.amount
project_name = adj.project.name if adj.project else 'General'
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
outstanding_by_project[project_name] += adj.amount
if adj.type in ADDITIVE_TYPES:
outstanding_payments += adj.amount
outstanding_by_project[project_name] += adj.amount
elif adj.type in DEDUCTIVE_TYPES:
outstanding_payments -= adj.amount
outstanding_by_project[project_name] -= adj.amount
# Sum total paid out in the last 60 days
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
@ -264,11 +277,19 @@ def attendance_log(request):
tw_map = {}
for t in Team.objects.filter(active=True).prefetch_related('workers'):
tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True))
# Pass the selected worker IDs explicitly for the conflict
# re-submission forms. We can't use form.data.workers in the
# template because QueryDict.__getitem__ returns only the last
# value, losing all other selections for multi-value fields.
selected_worker_ids = request.POST.getlist('workers')
return render(request, 'core/attendance_log.html', {
'form': form,
'conflicts': conflicts,
'is_admin': is_admin(user),
'team_workers_json': json.dumps(tw_map),
'selected_worker_ids': selected_worker_ids,
})
# --- Create work logs ---