# Worker Management Expansion — Design & Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. REQUIRED SUB-SKILL: Use superpowers:test-driven-development where tests are called for. **Goal:** Extend the Worker model with certifications + disciplinary records, add a friendly in-app worker-edit form (replacing the need to use Django admin for common edits), and build a batch worker report showing projects, teams, days worked, and first/last payslip dates. **Architecture:** - Two new models (`WorkerCertificate`, `WorkerWarning`) with ForeignKey to existing `Worker`. - A worker list page + worker edit page, both admin-only, replacing most everyday admin needs. - A batch-report page (HTML + CSV + PDF) summarising each worker's full work history. - All new work lives inside the existing `core/` app — no new apps. Reuses existing patterns: `is_admin()` gating, `@login_required`, Bootstrap modals, `render_to_pdf()`, inline formsets. **Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod), Bootstrap 5, WeasyPrint for PDFs, existing `format_tags.money` filter. --- ## Context **Why this is being built:** The owner (Konrad) currently manages workers via `/admin/core/worker/` — which is functional but lacks: - Certification tracking (Skills, PDP, First Aid, Medical, Work at Height) with expiry dates and document uploads - A disciplinary/warnings record per worker - A friendlier edit UI than Django admin's default form - A consolidated worker-history report (projects worked, days, payslips) for review or regulatory/auditor purposes This plan closes all four gaps in one coherent feature. **Outcomes when complete:** 1. Per worker, the admin can see and maintain: all 5 cert types, all warnings, in one page. 2. The admin has a worker list and worker edit page that are easier than Django admin. 3. The admin can generate a batch report of all workers' project/team/day/payslip history — viewable, CSV-exportable, PDF-exportable. --- ## Scope (explicit) ### In scope - New models: `WorkerCertificate`, `WorkerWarning` - Django admin registrations for both (inline on Worker) - Worker list page (admin-only): `/workers/` - Worker edit page (admin-only): `/workers//edit/` and `/workers/new/` - Worker detail page (admin-only, read-only view with history): `/workers//` - Batch worker report: `/workers/report/` (HTML), `/workers/report/csv/`, `/workers/report/pdf/` - Nav links added to base.html - Migrations for the two new models - Updates to `CLAUDE.md` documenting the new pieces ### Out of scope (not in this plan — can be follow-ups) - Cert expiry email alerts - Worker self-service portal (only admins use this) - Photo cropping / file optimisation - Bulk cert upload (edit one worker at a time) --- ## Files to Create / Modify | File | Action | Purpose | |------|--------|---------| | `core/models.py` | Modify | Add `WorkerCertificate` and `WorkerWarning` model classes | | `core/migrations/0009_worker_certificates_warnings.py` | Create (auto) | Schema changes | | `core/admin.py` | Modify | Register new models; add inlines to WorkerAdmin | | `core/forms.py` | Modify | `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` | | `core/views.py` | Modify | 6 new views: worker_list, worker_detail, worker_edit, worker_batch_report, worker_batch_report_csv, worker_batch_report_pdf | | `core/urls.py` | Modify | 6 new routes | | `core/templates/core/workers/list.html` | Create | Worker list with search + filters + "Add Worker" button | | `core/templates/core/workers/edit.html` | Create | Section-based edit form with inline certs + warnings | | `core/templates/core/workers/detail.html` | Create | Read-only worker profile with history tabs | | `core/templates/core/workers/batch_report.html` | Create | HTML batch report (extends base.html) | | `core/templates/core/pdf/workers_report_pdf.html` | Create | PDF version of batch report (uses WeasyPrint) | | `core/templates/base.html` | Modify | Add "Workers" nav link (admin only) | | `CLAUDE.md` | Modify | Document new models, URLs, admin patterns | --- ## Data Model ### `WorkerCertificate` ```python class WorkerCertificate(models.Model): """A certification held by a worker (Skills, PDP, First Aid, etc.). One row per (worker, cert_type) — existence of the row means the worker currently holds this certification. Delete the row to record that they no longer hold it. Use `valid_until` to track expiry. """ CERT_TYPES = [ ('skills', 'Skills Certificate'), ('pdp', 'PDP (Professional Driving Permit)'), ('first_aid', 'First Aid'), ('medical', 'Medical'), ('work_at_height', 'Work at Height'), ] worker = models.ForeignKey( Worker, related_name='certificates', on_delete=models.CASCADE, ) cert_type = models.CharField(max_length=30, choices=CERT_TYPES) document = models.FileField( upload_to='workers/certificates/', blank=True, null=True, help_text='Scan or photo of the certificate', ) issued_date = models.DateField(blank=True, null=True) valid_until = models.DateField( blank=True, null=True, help_text='Expiry date — leave blank if the cert does not expire', ) notes = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = [('worker', 'cert_type')] ordering = ['worker', 'cert_type'] def __str__(self): return f'{self.worker.name} — {self.get_cert_type_display()}' @property def is_expired(self): if not self.valid_until: return False return self.valid_until < timezone.now().date() @property def expires_soon(self): """True if the cert expires within the next 30 days.""" if not self.valid_until: return False today = timezone.now().date() return today <= self.valid_until <= today + datetime.timedelta(days=30) ``` ### `WorkerWarning` ```python class WorkerWarning(models.Model): """A disciplinary warning issued to a worker.""" SEVERITY_CHOICES = [ ('verbal', 'Verbal Warning'), ('written', 'Written Warning'), ('final', 'Final Warning'), ] worker = models.ForeignKey( Worker, related_name='warnings', on_delete=models.CASCADE, ) date = models.DateField(default=timezone.now) severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES) reason = models.CharField( max_length=200, help_text='Short summary — e.g. "Repeated lateness"', ) description = models.TextField( blank=True, help_text='Full context of what happened', ) issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name='warnings_issued', ) document = models.FileField( upload_to='workers/warnings/', blank=True, null=True, help_text='Signed warning form (optional)', ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-date'] def __str__(self): return f'{self.worker.name} — {self.get_severity_display()} ({self.date})' ``` ### Migration `python manage.py makemigrations core` → produces `0009_worker_certificates_warnings.py`. Safe migration: two new tables, no changes to existing ones. Reversible. --- ## URL Routes (new) Add to `core/urls.py` before the `# === EXPENSE RECEIPTS ===` section: ```python # === WORKERS === # Admin-friendly worker management UI (alternative to /admin/core/worker/) path('workers/', views.worker_list, name='worker_list'), path('workers/new/', views.worker_edit, name='worker_new'), path('workers//', views.worker_detail, name='worker_detail'), path('workers//edit/', views.worker_edit, name='worker_edit'), # Batch report (table of all workers with aggregated history) path('workers/report/', views.worker_batch_report, name='worker_batch_report'), path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'), path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'), ``` --- ## Views (new) All admin-gated via `@login_required` + `is_admin(request.user)` → 403 if not admin. ### `worker_list(request)` - Fetches all Workers (not just active). - Filters: `?q=search_term`, `?status=active|inactive|all`. - Template: `core/workers/list.html`. - Shows table: name, ID, phone, salary, days worked, active toggle. ### `worker_detail(request, worker_id)` - Worker profile (read-only). - Shows: personal info, PPE, license, certs (with expiry highlights), warnings, and a "History" tab with projects/teams/days/payslips. - Template: `core/workers/detail.html`. ### `worker_edit(request, worker_id=None)` - GET: renders form pre-filled (or blank if `worker_id is None`). - POST: validates + saves Worker + inline formsets for certs + warnings. - Redirect on success: → `worker_detail`. - Template: `core/workers/edit.html`. ### `worker_batch_report(request)` - Builds per-worker aggregates using a shared `_build_worker_report_context()` helper (parallel to `_build_report_context`). - Filters: `?status=`, `?project=`, `?team=`. - Template: `core/workers/batch_report.html`. ### `worker_batch_report_csv(request)` - Same context builder; streams a CSV with all columns. ### `worker_batch_report_pdf(request)` - Same context builder; uses `render_to_pdf('core/pdf/workers_report_pdf.html', context)`. ### Shared helper `_build_worker_report_context(status=None, project_id=None, team_id=None)` Returns a list of dicts, one per worker: ```python { 'worker': worker_obj, 'projects': ['Solar Farm Alpha', 'Solar Farm Beta'], # distinct project names 'teams': ['Team Alpha'], # distinct team names 'days_worked': 47, # distinct WorkLog dates 'first_payslip_date': date(2025, 3, 14) or None, 'last_payslip_date': date(2026, 4, 5) or None, 'total_paid_lifetime': Decimal('127450.00'), 'payslip_count': 12, 'active_certs': 3, 'expiring_certs': 1, # expires within 30 days 'expired_certs': 0, 'active_warnings_count': 1, # warnings issued in last 12 months } ``` Aggregation approach (efficient — one query per aggregate, not per worker): ```python qs = Worker.objects.annotate( days_worked=Count('work_logs__date', distinct=True), first_payslip_date=Min('payroll_records__date'), last_payslip_date=Max('payroll_records__date'), total_paid_lifetime=Sum('payroll_records__amount_paid'), payslip_count=Count('payroll_records', distinct=True), ) # then a separate prefetch for projects/teams ``` --- ## Forms (new) ```python # core/forms.py class WorkerForm(forms.ModelForm): """Main worker edit form — covers the flat fields on Worker.""" class Meta: model = Worker fields = [ 'name', 'id_number', 'phone_number', 'monthly_salary', 'employment_date', 'active', 'notes', 'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size', 'photo', 'id_document', 'has_drivers_license', 'drivers_license', ] widgets = { 'employment_date': forms.DateInput(attrs={'type': 'date'}), 'notes': forms.Textarea(attrs={'rows': 3}), } WorkerCertificateFormSet = inlineformset_factory( Worker, WorkerCertificate, fields=['cert_type', 'document', 'issued_date', 'valid_until', 'notes'], extra=0, # no blank rows by default can_delete=True, widgets={ 'issued_date': forms.DateInput(attrs={'type': 'date'}), 'valid_until': forms.DateInput(attrs={'type': 'date'}), 'notes': forms.Textarea(attrs={'rows': 2}), }, ) WorkerWarningFormSet = inlineformset_factory( Worker, WorkerWarning, fields=['date', 'severity', 'reason', 'description', 'document'], extra=0, can_delete=True, widgets={ 'date': forms.DateInput(attrs={'type': 'date'}), 'description': forms.Textarea(attrs={'rows': 3}), }, ) ``` In the edit view, `request.POST`/`request.FILES` flow through all three (form + two formsets); all must be valid before saving. --- ## Templates (new) ### `core/workers/list.html` - Search box (name/ID) + status filter dropdown - Table columns: Name, ID, Phone, Salary, Days Worked, Active, Actions (View / Edit / Toggle) - Buttons: "Add Worker", "Batch Report", "Export CSV" - Styled with existing stat-card / resource-row patterns from index.html ### `core/workers/edit.html` - Section-based layout (no tabs — long-form scroll for easier visual review): 1. **Personal & Pay** — name, id_number, phone, salary, employment_date, active, notes 2. **PPE Sizing** — shoe, overall top, pants, t-shirt 3. **Documents** — photo, id_document 4. **Driver's License** — has_drivers_license, drivers_license file 5. **Certifications** (formset with + add button, × delete) 6. **Warnings & Disciplinary** (formset with + add button, × delete) - Client-side JS: "Add Certification" / "Add Warning" buttons clone a hidden blank formset row and bump the TOTAL_FORMS counter (standard Django formset pattern) - Submit button at the bottom; Cancel goes back to `worker_detail` ### `core/workers/detail.html` - Header: worker photo, name, ID, active badge - Tabs: 1. **Profile** — personal, PPE, license info 2. **Certifications** — list with colored badges: green (valid > 30 days), amber (expires within 30), red (expired) 3. **Warnings** — chronological list 4. **History** — projects worked, teams, days, last 10 payslips - "Edit" button links to `worker_edit` ### `core/workers/batch_report.html` - Report header + filter bar (status / project / team) - Table with columns: - Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (n/m) | Warnings - "Export CSV" + "Download PDF" buttons at top-right - Row click → `worker_detail` ### `core/pdf/workers_report_pdf.html` - Print-optimized A4 layout using WeasyPrint - Header: "FoxFitt Construction — Worker Roster Report" - Filter summary subhead - Table (narrower columns, landscape orientation may be needed for many fields) - Uses the same amber accent and typography as `report_pdf.html` --- ## Navigation `base.html` desktop topbar: add a "Workers" link after "Receipts" and before "Admin" (admin-only): ```html {% if user.is_staff %} Workers {% endif %} ``` Also add matching entries to the mobile menu (the `.mobile-menu__nav` block) and the bottom tab bar (if room). --- ## Django Admin Enhancements Register the new models: ```python # core/admin.py class WorkerCertificateInline(admin.TabularInline): model = WorkerCertificate extra = 0 class WorkerWarningInline(admin.TabularInline): model = WorkerWarning extra = 0 readonly_fields = ['created_at'] @admin.register(WorkerCertificate) class WorkerCertificateAdmin(admin.ModelAdmin): list_display = ('worker', 'cert_type', 'valid_until', 'is_expired') list_filter = ('cert_type',) search_fields = ('worker__name',) @admin.register(WorkerWarning) class WorkerWarningAdmin(admin.ModelAdmin): list_display = ('worker', 'date', 'severity', 'reason') list_filter = ('severity',) search_fields = ('worker__name', 'reason') ``` Then update `WorkerAdmin` to include the inlines: ```python class WorkerAdmin(admin.ModelAdmin): # ...existing config... inlines = [WorkerCertificateInline, WorkerWarningInline] ``` This means the Django admin ALSO gets the new sections — the in-app edit page is a better UX, but admin remains fully functional. --- ## Task-by-Task Execution Plan Each task is 5–15 minutes. Execute in order. ### Task 1: Add the two new models + migration **Files:** - Modify: `core/models.py` (append `WorkerCertificate`, `WorkerWarning` classes at end of file) - Create: `core/migrations/0009_worker_certificates_warnings.py` (auto-generated) **Steps:** 1. Add the two model classes (code above) 2. `USE_SQLITE=true python manage.py makemigrations core` 3. `USE_SQLITE=true python manage.py migrate` 4. Verify: `python manage.py check` → no issues 5. Commit ### Task 2: Register models in Django admin **Files:** - Modify: `core/admin.py` **Steps:** 1. Add `WorkerCertificateInline`, `WorkerWarningInline`, `WorkerCertificateAdmin`, `WorkerWarningAdmin` classes 2. Add `inlines = [...]` to `WorkerAdmin` 3. Verify in browser: `/admin/core/worker//change/` shows certs + warnings sections 4. Commit ### Task 3: Add WorkerForm and formsets **Files:** - Modify: `core/forms.py` **Steps:** 1. Add `WorkerForm`, `WorkerCertificateFormSet`, `WorkerWarningFormSet` 2. Verify: `python manage.py shell` → import forms → no errors 3. Commit ### Task 4: Add worker_list view and template **Files:** - Modify: `core/views.py` (+ `import` updates) - Modify: `core/urls.py` - Create: `core/templates/core/workers/list.html` **Steps:** 1. Add URL route `workers/` → `views.worker_list` 2. Add view `worker_list(request)` with search + status filter 3. Create template with search bar, table, action buttons 4. Verify: visit `/workers/` as admin → list shows workers 5. Commit ### Task 5: Add worker_edit view + template (the big one) **Files:** - Modify: `core/views.py` - Modify: `core/urls.py` - Create: `core/templates/core/workers/edit.html` **Steps:** 1. Add two URL routes: `workers/new/`, `workers//edit/` 2. Add view `worker_edit(request, worker_id=None)` handling both create and update 3. Create section-based template with formsets for certs + warnings 4. Add JS for "+ Add Certification" / "+ Add Warning" buttons (formset clone pattern) 5. Verify: add a worker, edit a worker, add cert, add warning → all persist 6. Commit ### Task 6: Add worker_detail view + template **Files:** - Modify: `core/views.py` - Modify: `core/urls.py` - Create: `core/templates/core/workers/detail.html` **Steps:** 1. Add URL `workers//` 2. Add view with tab-context (profile, certs, warnings, history) 3. Template with Bootstrap tabs; cert badges styled by expiry 4. Link "Edit" button to edit view 5. Verify 6. Commit ### Task 7: Add batch report (HTML, CSV, PDF) **Files:** - Modify: `core/views.py` (add `_build_worker_report_context`, 3 views) - Modify: `core/urls.py` - Create: `core/templates/core/workers/batch_report.html` - Create: `core/templates/core/pdf/workers_report_pdf.html` **Steps:** 1. Write `_build_worker_report_context()` helper with the annotate/prefetch pattern 2. Add 3 URLs + 3 views 3. Create HTML template with filter bar + table 4. Create PDF template (derive from existing `report_pdf.html` structure — cover, section headings, ledger-style table) 5. Verify HTML, CSV, PDF all render correctly 6. Commit ### Task 8: Add nav links **Files:** - Modify: `core/templates/base.html` **Steps:** 1. Add desktop nav link (admin-only) 2. Add mobile menu link 3. Verify on desktop and mobile layouts 4. Commit ### Task 9: Update CLAUDE.md **Files:** - Modify: `CLAUDE.md` **Steps:** 1. Add `WorkerCertificate` and `WorkerWarning` to Key Models section 2. Add 7 new URL routes to URL Routes table 3. Add "Worker Management" subsection under Development Workflow 4. Commit ### Task 10: Verification pass - Visit every new page; click every button; upload a test file to each upload field - Try edge cases: blank forms, duplicate cert_type for same worker (should fail unique constraint), expired certs - Regenerate PDF; open to verify layout - Run `python manage.py check` - Smoke-test existing features (payroll report, payment, receipt) still work --- ## Open Questions for User The following decisions need your input before I start executing. Defaults in brackets are what I'll use if you don't answer. ### Q1: Certification types list I'm proposing these 5 types: - Skills Certificate - PDP (Professional Driving Permit) - First Aid - Medical - Work at Height **Any additions, removals, or renames?** [Default: use exactly these 5] ### Q2: Warning severity levels I'm proposing: Verbal → Written → Final. **Accept, or add a fourth (e.g. "Informal"), or use different names?** [Default: Verbal/Written/Final] ### Q3: Navigation placement **Where does the "Workers" link go?** - (a) Top desktop nav, admin-only, after Receipts — prominent, 1-click access - (b) Only accessible from a button on the Dashboard — less cluttered nav - (c) Inside the Admin dropdown / submenu [Default: (a) — matches how Payroll is currently linked] ### Q4: Worker list — default sort? - (a) Alphabetical by name - (b) By employment date (newest first) - (c) By active status, then name [Default: (a) alphabetical] ### Q5: Coexist with Django admin? The new worker list/edit pages would be more user-friendly, but Django admin at `/admin/core/worker/` remains fully functional. **Keep Django admin working as a fallback for power-user edits?** [Default: yes — keep both] ### Q6: Cert expiry alerts on dashboard Would you like the Dashboard to show a stat card like "3 certs expiring in 30 days"? - (a) Yes, show it — helpful operationally - (b) No, keep dashboard unchanged for now - (c) Show, but only if count > 0 (hide the card when everything's fine) [Default: (c) conditional display] ### Q7: File upload size limit Certificates and warnings support file uploads. Currently no limit. Should I add a max size to prevent someone accidentally uploading a 50MB scan? - (a) No limit - (b) 5 MB max - (c) 10 MB max [Default: (b) 5 MB] ### Q8: Batch report columns I'm proposing this column set: Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (active/total) | Warnings **Anything to add or remove?** Common additions could be: employment_date, phone, has_drivers_license, last-active-date. [Default: use the list above] ### Q9: Scope bailout If I discover during execution that any task is significantly more complex than the estimate (e.g. Task 5 turns out to need 2 hours instead of 30 minutes), should I: - (a) Keep going, add a new task, push complete - (b) Stop, flag the issue, let you decide - (c) Deliver a smaller version (e.g. certs-only, no warnings) and flag warnings as follow-up [Default: (b) stop and flag] --- ## Verification (end-to-end) Run after all tasks complete: ``` USE_SQLITE=true python manage.py check USE_SQLITE=true python manage.py migrate --plan # confirm no pending migrations ``` Browser smoke tests (as admin): 1. Visit `/workers/` — list renders, search works 2. Click "Add Worker" — blank form loads 3. Fill name/ID/salary, click Save → redirected to detail view 4. Click "Edit" on detail — form pre-filled 5. Click "+ Add Certification" → blank cert row appears 6. Fill cert (Medical, valid_until=next month), upload a test PDF, Save 7. Verify cert appears on detail page with green "valid" badge 8. Click "+ Add Warning", fill, Save — verify it appears on Warnings tab 9. Visit `/workers/report/` — table shows all workers with aggregates 10. Click "Export CSV" — downloads, opens in Excel/LibreOffice cleanly 11. Click "Download PDF" — renders with correct layout 12. Visit `/admin/core/worker//change/` — Django admin still works, inlines show 13. As a non-admin user, visit `/workers/` — should get 403 ## Rollback The change touches two new models with ForeignKey to Worker. If we need to undo: 1. `git reset --hard` to the last commit before this plan started 2. `python manage.py migrate core 0008` (the pre-existing migration) — drops the two new tables 3. Branches stay isolated until merged; worst case the whole thing sits on a local branch forever --- ## Estimated Timeline - Task 1 (models + migration): 10 min - Task 2 (admin): 5 min - Task 3 (forms): 10 min - Task 4 (list view): 15 min - Task 5 (edit view + template): 40 min — biggest task; has formset JS - Task 6 (detail view): 20 min - Task 7 (batch report): 30 min - Task 8 (nav): 5 min - Task 9 (CLAUDE.md): 5 min - Task 10 (verification): 15 min **Total: ~2.5 hours of supervised execution**, or ~90 minutes if I can auto-execute with good test coverage.