diff --git a/.claude/plans/2026-03-28-buchhaltung-design.md b/.claude/plans/2026-03-28-buchhaltung-design.md new file mode 100644 index 0000000..d51672b --- /dev/null +++ b/.claude/plans/2026-03-28-buchhaltung-design.md @@ -0,0 +1,376 @@ +# Buchhaltung System — Design + +**Date:** 2026-03-28 +**Status:** Approved + +--- + +## Overview + +A budget and transaction tracking system for the Feuerwehr. Tracks money flows against budget pots and real bank accounts, scoped by fiscal year. Integrates with the existing Bestellungen system and permission matrix. + +--- + +## Full Cycle + +``` +Budget Plan → Fiscal Year → Freigaben → Bestellungen → Pending Transactions → Booked Transactions +``` + +--- + +## 1. Data Model + +### `buchhaltung_konto_typen` +Dynamic list of bank account types (Girokonto, Tagesgeldkonto, Handkasse, PayPal, …). Managed in settings by users with `manage_settings`. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| name | varchar | | +| beschreibung | text | nullable | +| created_at | timestamptz | | + +--- + +### `buchhaltung_bankkonten` +Real bank accounts (where money physically is). + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| name | varchar | | +| typ_id | FK konto_typen | | +| iban | varchar | nullable | +| opening_balance | decimal(12,2) | starting balance when first added | +| aktiv | bool | default true | +| created_at | timestamptz | | + +Balance = `opening_balance + SUM(booked transactions)`. + +--- + +### `buchhaltung_haushaltsjahre` +Fiscal years. Bank accounts are not year-scoped; budget accounts are. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| jahr | int | unique (e.g. 2026) | +| start_datum | date | | +| end_datum | date | | +| abgeschlossen | bool | default false; manually closed by manage_settings user | + +Closing a year locks all its transactions (no edits, no new bookings). + +--- + +### `buchhaltung_konten` +Budget accounts / pots. Scoped per fiscal year. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| haushaltsjahr_id | FK haushaltsjahre | | +| name | varchar | | +| beschreibung | text | nullable | +| budget | decimal(12,2) | | +| alert_threshold | int | % (0-100); falls back to global setting if null | +| aktiv | bool | default true | +| created_at | timestamptz | | + +--- + +### `buchhaltung_transaktionen` +Core transaction table. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | internal DB key | +| haushaltsjahr_id | FK haushaltsjahre | | +| laufende_nummer | int | sequential per fiscal year; display as `2026/0001` | +| datum | date | | +| beschreibung | text | | +| betrag | decimal(12,2) | nullable for variable recurring transactions | +| typ | enum | `einnahme`, `ausgabe`, `transfer` | +| konto_id | FK konten | nullable (null for transfers) | +| bankkonto_id | FK bankkonten | source bank account | +| transfer_ziel_bankkonto_id | FK bankkonten | nullable; only for typ=transfer | +| bestellung_id | FK bestellungen | nullable; set when auto-created from order | +| split_gruppe_id | uuid | nullable; groups split transactions from same Bestellung | +| gebucht | bool | false = pending, true = booked | +| erstellt_von | FK users | | +| gebucht_von | FK users | nullable | +| gebucht_am | timestamptz | nullable | +| created_at | timestamptz | | + +Display ID: `${jahr}/${laufende_nummer.toString().padStart(4, '0')}` e.g. `2026/0001`. +`laufende_nummer` is unique per `haushaltsjahr_id`, incremented per new transaction. + +--- + +### `buchhaltung_belege` +Receipt/invoice attachments per transaction. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| transaktion_id | FK transaktionen | | +| dateiname | varchar | | +| pfad | varchar | disk path | +| mime_type | varchar | | +| erstellt_von | FK users | | +| created_at | timestamptz | | + +Uses same multer disk storage pattern as Bestellungen (`uploads/buchhaltung/`). + +--- + +### `buchhaltung_wiederkehrend` +Recurring transaction templates. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| beschreibung | varchar | | +| betrag | decimal(12,2) | nullable (e.g. variable energy bills) | +| typ | enum | `einnahme`, `ausgabe` | +| konto_id | FK konten | nullable | +| bankkonto_id | FK bankkonten | nullable | +| frequenz | enum | `monatlich`, `vierteljaehrlich`, `jaehrlich` | +| naechste_faelligkeit | date | advanced by job after each creation | +| aktiv | bool | | +| erstellt_von | FK users | | + +Job runs daily → creates pending transaction → advances `naechste_faelligkeit`. +Betrag null → pending transaction has null betrag → Kassenwart fills it in before booking. + +--- + +### `buchhaltung_freigaben` +Budget delegation — authorizes a user to spend against a budget account. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| konto_id | FK konten | budget account | +| user_id | FK users | authorized user | +| beschreibung | text | what this covers | +| betrag_limit | decimal(12,2) | nullable; max spending allowed | +| planposition_id | FK planpositionen | nullable; source plan item | +| aktiv | bool | | +| erstellt_von | FK users | | +| created_at | timestamptz | | + +Amount is editable after creation (plan may change mid-year). +Users with a Freigabe can create Bestellungen against that account even without `buchhaltung:create`. + +--- + +### `buchhaltung_audit` +Append-only audit log. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| transaktion_id | FK transaktionen | | +| aktion | enum | `erstellt`, `gebucht`, `bearbeitet`, `geloescht` | +| benutzer_id | FK users | | +| details | jsonb | before/after snapshot | +| created_at | timestamptz | | + +--- + +### `buchhaltung_einstellungen` +Key/value store for global settings. + +| Key | Default | Notes | +|-----|---------|-------| +| `default_alert_threshold` | `80` | % budget used before notification | +| `pdf_footer` | `''` | reuses PDF settings pattern | + +--- + +### Budget Planning Tables + +#### `buchhaltung_planung` +A plan for a future fiscal year. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| ziel_jahr | int | the year being planned | +| name | varchar | e.g. "Haushaltsplan 2027" | +| status | enum | `entwurf`, `genehmigt` | +| erstellt_von | FK users | | +| created_at | timestamptz | | + +Starts pre-populated from current open budget accounts. New accounts can be added. +Approving locks the plan. Only approved plans can be used for fiscal year rollover. + +#### `buchhaltung_plankonten` +Planned budget pots within a plan. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| planung_id | FK planung | | +| konto_referenz_id | FK konten | nullable; links to existing account if carried over | +| name | varchar | | +| beschreibung | text | nullable | +| sort_order | int | | + +#### `buchhaltung_planpositionen` +Line items within a planned pot. Sum = pot total. + +| Column | Type | Notes | +|--------|------|-------| +| id | serial PK | | +| plankonto_id | FK plankonten | | +| beschreibung | text | what it's for | +| betrag | decimal(12,2) | | +| sort_order | int | | + +From approved plan items → manual Freigaben creation flow (UI, not automatic). Amount editable on Freigabe creation and after. + +--- + +## 2. Permissions + +New `buchhaltung` feature group in the existing permission matrix: + +| Permission | Description | +|------------|-------------| +| `buchhaltung:view` | See transactions, accounts, bank accounts | +| `buchhaltung:create` | Add transactions manually | +| `buchhaltung:edit` | Edit unbooked transactions | +| `buchhaltung:delete` | Delete unbooked transactions | +| `buchhaltung:manage_accounts` | Create/edit/archive budget accounts, bank accounts, Freigaben | +| `buchhaltung:manage_settings` | Global settings, account types, fiscal year management, recurring templates | +| `buchhaltung:export` | PDF and CSV export | +| `buchhaltung:widget` | See the dashboard widget | + +`manage_settings` ≠ `dashboard_admin` — a dedicated Kassenwart can have this without being a global admin. + +--- + +## 3. Backend Architecture + +Follows the existing project pattern (service → controller → routes → app.ts). + +**Routes** mounted at `/api/buchhaltung/`: + +| Route | Description | +|-------|-------------| +| `bankkonten` | CRUD bank accounts | +| `konten` | CRUD budget accounts (scoped by fiscal year) | +| `transaktionen` | list, create, book, edit, delete | +| `transfers` | create bank-to-bank transfers | +| `freigaben` | CRUD budget delegations | +| `wiederkehrend` | CRUD recurring templates | +| `haushaltsjahre` | create new year, close year, list | +| `planung` | CRUD budget plans and plan items | +| `einstellungen` | key/value settings | +| `export/pdf` | annual report PDF | +| `export/csv` | transaction CSV export | +| `audit/:transaktionId` | audit trail for one transaction | + +**Jobs:** + +- `buchhaltung-recurring.job.ts` — runs daily; creates pending transactions from due recurring templates; advances `naechste_faelligkeit` +- Budget alert fires **inline** after each booking: recalculates account total → if crosses threshold → `notificationService.createNotification()` for `manage_accounts` users (deduped by DB constraint) + +--- + +## 4. Bestellungen Integration + +### Costs on positions +- `bestellpositionen` gets an `einzelpreis` / `gesamtpreis` field (if not already present) +- Status change to `vollstaendig`/`abgeschlossen` is **blocked** until all positions have costs entered +- Attempting the status change without costs → `showWarning` snackbar: *"Bestellung kann erst abgeschlossen werden, wenn Kosten für alle Positionen eingetragen wurden."* +- Backend enforces the same rule (400 response) +- The **order creator/manager** enters costs — not the Kassenwart + +### Pending transaction creation +When a Bestellung reaches `abgeschlossen`: +1. `buchhaltungService.createPendingFromBestellung(bestellung)` is called +2. Creates a pending `ausgabe` transaction: betrag = sum of position costs, beschreibung = order title, bestellung_id = FK +3. Notifies users with `buchhaltung:create` or `buchhaltung:manage_accounts` + +### Split booking +When booking a pending transaction from a Bestellung, the Kassenwart can split it into N parts: +- Each part: betrag, budget konto, bankkonto, Beleg (receipt) +- Parts sum to ≤ total (partial booking allowed; remainder stays pending) +- Each part becomes its own booked transaction with its own `/` +- All parts share the same `bestellung_id` and `split_gruppe_id` (visually grouped in audit trail) + +--- + +## 5. Frontend Pages + +### Navigation +New "Buchhaltung" sidebar group: +- Übersicht +- Transaktionen +- Bankkonten +- Konten & Haushaltsjahre +- Freigaben *(only with `manage_accounts`)* +- Haushaltsplan *(only with `manage_settings`)* + +### Pages + +**`Buchhaltung.tsx`** — overview. Year selector at top. Budget account cards: name, budget, spent, remaining, % bar, alert indicator. Pending transactions count with link. + +**`Transaktionen.tsx`** — full list. Filter by year / account / bank account / typ. Columns: ID (`2026/0001`), Datum, Beschreibung, Konto, Bankkonto, Betrag, Status. Click → booking dialog. + +**`BankkontoDetail.tsx`** — statement view. Running balance column per row. Date range filter. CSV/PDF export. + +**`BuchhaltungKonten.tsx`** — tabs: Konten (current year) | Bankkonten | Haushaltsjahre. Year-end actions: "Jahr abschließen" (manual, locks year) + "Neues Haushaltsjahr" (from approved plan or copy previous). + +**`BuchhaltungEinstellungen.tsx`** (or AdminDashboard tab) — Kontentypen list, default alert threshold, recurring templates. + +**`Haushaltsplan.tsx`** — plan management. Pre-populated from current accounts. Add/edit/remove plankonten and planpositionen. Approve plan. From approved items → create Freigaben (manual flow, amounts editable). + +### Booking Dialog +Used from pending transaction list: +- Pre-filled from Bestellung (amount, description, reference) +- "Split" button adds a row +- Each row: betrag, budget konto, bankkonto, Beleg upload +- Sum validation +- Confirm → books all rows at once + +### Widget +`BuchhaltungWidget.tsx` — compact card in Status group. Shows pending transaction count. Tap → filtered pending list. Only shown with `buchhaltung:widget`. + +--- + +## 6. PDF Export + +Uses existing `addPdfHeader` / `addPdfFooter` from `frontend/src/utils/pdfExport` (same red header, logo, org name as Bestellungen and Kalender). + +Content of annual report TBD (separate discussion). + +--- + +## 7. Fiscal Year Rollover + +1. **Close current year** — manual action by `manage_settings` user → sets `abgeschlossen = true` → all transactions locked +2. **Create new year** — choose source: + - **From approved plan** — plankonten become new konten, plankonto totals (sum of planpositionen) become budgets + - **Copy previous year** — all active konten copied with same budget amounts (editable before confirming) +3. Bank accounts carry over automatically (not year-scoped) +4. Old year remains readable/exportable forever + +--- + +## 8. Future: Sub-Budget Flow from Plan Items + +*(Planned feature — not in initial implementation)* + +From an approved plan's items, a manual UI flow to create Freigaben: +- User selects plan items +- Sets/adjusts amount per Freigabe (editable at creation and later — plan may change mid-year) +- Assigns a user +- Creates the Freigabe linked to `planposition_id` + +This closes the full loop: Plan item → Freigabe → Bestellung → Pending transaction → Booked transaction. diff --git a/.claude/plans/2026-03-30-buchhaltung-implementation.md b/.claude/plans/2026-03-30-buchhaltung-implementation.md new file mode 100644 index 0000000..a38f040 --- /dev/null +++ b/.claude/plans/2026-03-30-buchhaltung-implementation.md @@ -0,0 +1,101 @@ +# Buchhaltung Implementation Plan — 2026-03-30 + +## Priority Order +1. P1: Transfers (bank → bank) — needs migration first +2. P2: BankkontoDetail page — depends on P1 migration for transfer direction +3. P3: Konto-Typen + Einstellungen UI — pure frontend, self-contained +4. P4: Bestellungen integration — einzelpreis validation + auto-pending +5. P5: Haushaltsplan pages — large feature +6. P6: PDF Export — last, all data endpoints stable + +## Key Findings +- `typ='transfer'` is NOT in the current DB CHECK constraint — migration required +- `einzelpreis` on `bestellpositionen` already exists (migration 038) — no schema change for P4 +- `buchhaltung_planung` and `buchhaltung_planpositionen` tables exist (migrations 075/077), 2-level hierarchy (not 3-level as in original design sketch) +- All konto-typen and einstellungen backend routes exist — P3 is frontend-only +- Split-booking dialog and Freigaben-from-plan flow (design sections 4/8) are deferred + +--- + +## P1: Transfers [x] + +### Migration `082_buchhaltung_transfer.sql` +- Extend CHECK: add `'transfer'` to `typ` enum +- Add column `transfer_ziel_bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL` + +### Backend +- `buchhaltung.service.ts` — add `createTransfer(data, userId)`: DB transaction creating two rows (debit on source, credit on target) +- `buchhaltung.controller.ts` — add `createTransfer` handler +- `buchhaltung.routes.ts` — `POST /transfers` +- Update `listTransaktionen` JOIN to include `transfer_ziel_bezeichnung` + +### Frontend +- `buchhaltung.types.ts` — add `'transfer'` to `TransaktionTyp`; add `transfer_ziel_bankkonto_id?`, `transfer_ziel_bezeichnung?` to `Transaktion`; add `TransferFormData` +- `buchhaltung.ts` (service) — add `createTransfer` API method +- `Buchhaltung.tsx` — add `TransferDialog` component + "Transfer" button in TransaktionenTab toolbar; show Transfer chip in typ column + +--- + +## P2: BankkontoDetail Page [x] + +### Backend +- `buchhaltung.service.ts` — add `getBankkontoStatement(id, filters)`: queries transactions for one bank account, computes running balance per row (einnahme=+, ausgabe=−, transfer direction from `transfer_ziel_bankkonto_id`) +- `buchhaltung.controller.ts` — add `getBankkontoStatement` handler +- `buchhaltung.routes.ts` — `GET /bankkonten/:id/transaktionen` + +### Frontend +- `buchhaltung.types.ts` — add `BankkontoStatement` interface +- `buchhaltung.ts` — add `getBankkontoStatement(id, filters?)` API method +- New file `BuchhaltungBankkontoDetail.tsx` — date range filter, summary cards, table with running balance column +- `App.tsx` — add route `/buchhaltung/bankkonto/:id` +- `Buchhaltung.tsx` (KontenTab, Bankkonten sub-tab) — make table rows clickable → navigate to detail + +--- + +## P3: Einstellungen UI [x] + +### Frontend only +- `buchhaltung.ts` — add `getEinstellungen` + `setEinstellungen` API methods +- `Buchhaltung.tsx` — add 4th sub-tab "Einstellungen" in KontenTab: + - KontoTypen CRUD table + add/edit dialog + - Default alert threshold TextField + save button + - Gate with `hasPermission('buchhaltung:manage_settings')` + +--- + +## P4: Bestellungen Integration [x] + +### Backend +- `bestellung.service.ts` — block `vollstaendig`/`abgeschlossen` transition if any position has null/zero `einzelpreis` +- `buchhaltung.service.ts` — add `createPendingFromBestellung(bestellungId, userId)`: non-fatal, finds open Haushaltsjahr, inserts pending ausgabe transaction +- `bestellung.service.ts` — call `createPendingFromBestellung` after status → `abgeschlossen` + +### Frontend +- `BestellungDetail.tsx` — show warning Alert when advancing to `abgeschlossen` but prices missing + +--- + +## P5: Haushaltsplan [x] + +### Backend +- `buchhaltung.service.ts` — Planung CRUD + Planposition CRUD + `createHaushaltsjahrFromPlan` +- `buchhaltung.controller.ts` — handlers for all +- `buchhaltung.routes.ts` — full set of Planung + Planposition routes + `POST /planung/:id/create-haushaltsjahr` + +### Frontend +- `buchhaltung.types.ts` — add `Planung`, `Planposition`, `PlanungDetail`, form data interfaces +- `buchhaltung.ts` — add all Planung API methods +- New file `Haushaltsplan.tsx` — list view + detail view, position inline editing, "Haushaltsjahr erstellen" action +- `App.tsx` — routes `/haushaltsplan` and `/haushaltsplan/:id` +- `Sidebar.tsx` — add Haushaltsplan sub-item under Buchhaltung group + +--- + +## P6: PDF Export (client-side jsPDF) [x] + +### Frontend +- `Buchhaltung.tsx` (UebersichtTab) — add `generatePdf()` using jsPDF + `fetchPdfSettings()` pattern + - Cover: year name + totals summary + - Page 2+: konten tree table with budget/spent/utilization + - Page 3+: full transaction list sorted by date +- "PDF exportieren" button gated by `buchhaltung:export` diff --git a/.claude/plans/feature-extensions.md b/.claude/plans/feature-extensions.md new file mode 100644 index 0000000..4bb1f97 --- /dev/null +++ b/.claude/plans/feature-extensions.md @@ -0,0 +1,170 @@ +# Feature Extensions Plan — Feuerwehr Dashboard + +> Generated 2026-03-16. Features that extend existing functionality. + +## Selected for Implementation (current sprint) +- #3 Inspection Schedule Export (PDF/CSV) +- #4 Vehicle Availability Calendar +- #8 Atemschutz Recertification Alert +- #19 Events Conflict Detection +- #21 Bulk Member Communication +- #22 Audit Log Alerts + +--- + +## Backlog — Vehicles & Equipment + +### Maintenance Cost Trend Reports [Medium] +- Aggregate costs from `wartungslog` by vehicle/type, with YoY trends +- Add new tab or section to `FahrzeugDetail.tsx` maintenance tab +- Backend: new `GET /api/vehicles/:id/wartung/stats` endpoint +- Frontend: bar/line chart using existing chart pattern from `IncidentStatsChart` +- Data available: `wartungslog.kosten`, `wartungslog.datum`, `wartungslog.art` + +### Equipment Move History [Quick Win] +- Track when equipment moves between vehicles/storage via audit_log entries +- Show timeline in `AusruestungDetail.tsx` with location change history +- Backend: query audit_log for equipment UPDATE actions where fahrzeug_id changed +- Frontend: simple timeline list component + +### Equipment Inventory by Category Report [Quick Win] +- Dashboard card showing equipment count by category/status with drill-down +- Use existing `equipmentService.getEquipmentStats()` data +- Frontend: new `EquipmentCategoryCard` in dashboard Status group +- Chip counts per category, color-coded by status + +--- + +## Backlog — Personnel & Atemschutz + +### Member Qualification Matrix Export [Medium] +- CSV/PDF showing all members × qualifications (licenses, certifications, training status) +- Backend: new `GET /api/members/qualifications/export` joining ausbildung, atemschutz_traeger, fahrgenehmigungen, untersuchungen +- Frontend: export button on Mitglieder page +- PDF template: table with member rows × qualification columns + +### Rank Promotion Timeline [Quick Win] +- Visual timeline of rank changes per member +- Data: `dienstgrad_verlauf` table already has full history +- Frontend: timeline component in MitgliedDetail "Beförderungen" tab +- Use MUI Timeline component or simple vertical list + +### Qualifications Expiring Soon Widget [Medium] +- Dashboard widget showing members with soon-to-expire qualifications +- Query: JOIN ausbildung.ablaufdatum, atemschutz.gueltig_bis, untersuchungen.gueltig_bis +- WHERE expiry date BETWEEN now AND now + 90 days +- Backend: new `GET /api/members/expiring-qualifications?days=90` +- Frontend: new dashboard card in Status group + +### Member On-Duty Roster Widget [Quick Win] +- Quick view showing who is on active duty (status=aktiv + specific function) +- Backend: filter members by status and funktion +- Frontend: simple card widget showing avatar + name list + +--- + +## Backlog — Incidents & Operations + +### Response Time Analytics [Medium] +- Dashboard showing hilfsfrist trends, median times by incident type +- Data: `einsatz_statistik` materialized view has `hilfsfrist_min` +- Backend: new `GET /api/incidents/response-times?groupBy=einsatz_art` +- Frontend: line chart or box plot in Einsaetze page analytics section + +### Personnel Availability During Incident [Medium] +- Incident detail view showing who was on-duty vs. who responded +- Cross-reference einsatz_personal with member status at time of incident +- Backend: extend `getIncidentById` to include non-responding active members +- Frontend: new section in EinsatzDetail with available/responded columns + +### Vehicle Usage by Incident Type [Quick Win] +- Report: which vehicles deployed per incident type +- Backend: aggregate einsatz_fahrzeuge grouped by einsatz_art +- Simple query on existing data, return as stats +- Frontend: table or chart in Einsaetze page + +### Incident Debriefing Template [Medium] +- Structured post-incident notes (what went well, improvements, training needs) +- Extend `bericht_text` or add new JSON column `debrief` with structured fields +- Backend: migration for new column, extend PATCH endpoint +- Frontend: collapsible section in EinsatzDetail with structured fields + +### Callout Distribution Report [Quick Win] +- Personnel workload analysis: how many callouts per member +- Backend: `SELECT user_id, COUNT(*) FROM einsatz_personal GROUP BY user_id` +- Frontend: table or chart in admin dashboard or Mitglieder page + +--- + +## Backlog — Training & Events + +### Training Attendance Statistics [Medium] +- Member participation trends (% events attended, absences, by type) +- Backend: aggregate uebung_teilnahmen by user_id with status counts +- Frontend: new admin tab or member profile tab with charts +- Could show per-member attendance rate badge + +### Training Requirement Tracking [Significant] +- Mark Übungen as mandatory for certain roles/ranks +- Schema: new `training_requirements` table (role, training_type, frequency) +- Backend: CRUD for requirements, validation check for members +- Frontend: admin config page + dashboard alerts for non-compliant members +- Notifications for members missing required training + +### QR Code Check-In [Medium] +- Mobile-friendly quick check-in for training events +- Generate QR code containing `POST /api/training/:id/attendance` URL +- Frontend: QR code display on UebungDetail page +- Scanner: simple camera component or link to URL + +--- + +## Backlog — Notifications & Admin + +### Customizable Notification Preferences [Medium] +- Per-user opt-in/out of notification types (training, incidents, roster) +- Extend user preferences schema with notification_preferences object +- Backend: check preferences before creating notifications +- Frontend: new section in Settings page with toggle switches per type + +--- + +## Backlog — Reporting & Analytics + +### Annual Department Report (PDF) [Significant] +- Compile incident stats, training participation, member roster, equipment inventory +- Backend: new `GET /api/reports/annual?year=2025` generating comprehensive PDF +- Use existing PDF generation pattern from Kalender +- Sections: executive summary, incident stats, training stats, personnel changes, equipment inventory + +### Member Hours Tracking [Medium] +- Calculate firefighter hours from training events + incidents + on-duty shifts +- Backend: aggregate event duration (datum_von to datum_bis) + incident time (alarm to einrueck) +- Frontend: member profile analytics tab + dashboard card + +### Equipment Utilization Report [Quick Win] +- Usage by category/vehicle/incident type +- Backend: join ausruestung with einsatz_fahrzeuge to correlate +- Frontend: table in admin dashboard or Ausruestung page + +### Member Skills Matrix [Medium] +- Export showing member name × all certifications +- Backend: JOIN across ausbildung, atemschutz_traeger, fahrgenehmigungen +- Frontend: exportable table with color-coded validity indicators +- PDF export using existing PDF generation pattern + +--- + +## Backlog — System + +### Incident Photo/Document Gallery [Medium] +- Upload photos/PDFs to incidents +- Schema: new `einsatz_dokumente` table (einsatz_id, filename, mime_type, storage_path) +- Backend: multipart upload endpoint, file storage (local or Nextcloud) +- Frontend: gallery component in EinsatzDetail with drag-and-drop + +### Incident Stats Year Selector [Quick Win] +- View historical stats for previous years +- Backend: already supports `year` query parameter +- Frontend: add year selector dropdown to IncidentStatsChart +- Simple UI change, no backend work needed diff --git a/.claude/plans/tingly-cooking-locket.md b/.claude/plans/tingly-cooking-locket.md new file mode 100644 index 0000000..59efa04 --- /dev/null +++ b/.claude/plans/tingly-cooking-locket.md @@ -0,0 +1,186 @@ +# Buchhaltung — Remaining Features Implementation Plan + +## Context + +The Buchhaltung system has core CRUD, transfers, bank statements, Haushaltsplan, and PDF export implemented. Three features from the original design doc remain: + +1. **Budget Delegation Freigaben** — authorizes a user to spend against a budget account (distinct from the existing transaction-approval freigaben) +2. **Split Booking Dialog** — book a pending transaction into N parts across different budget accounts +3. **Sub-Budget Flow** — create budget delegation Freigaben from approved plan positions + +--- + +## Task Dependency Graph + +``` +T1: Migration 083 (budget_freigaben) T2: Migration 084 (split_gruppe_id) + | | + v v +T3: Backend budget freigaben CRUD T4: Backend split booking endpoint + | | + +------------+ v + | | T7: Frontend BookingDialog + v v +T5: Backend plan→ T6: Frontend budget + freigabe endpoint freigaben tab + sidebar + | | + +-----+------+ + v +T8: Frontend plan position → freigabe action +``` + +--- + +## Tasks + +### T1 — Migration 083: `buchhaltung_budget_freigaben` table +**Teammate**: database | **blockedBy**: none + +Create `backend/src/database/migrations/083_buchhaltung_budget_freigaben.sql`: +```sql +CREATE TABLE IF NOT EXISTS buchhaltung_budget_freigaben ( + id SERIAL PRIMARY KEY, + konto_id INT NOT NULL REFERENCES buchhaltung_konten(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + beschreibung TEXT, + betrag_limit NUMERIC(12,2), + planposition_id INT REFERENCES buchhaltung_planpositionen(id) ON DELETE SET NULL, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_budget_freigaben_konto ON buchhaltung_budget_freigaben(konto_id); +CREATE INDEX idx_budget_freigaben_user ON buchhaltung_budget_freigaben(user_id); +``` + +### T2 — Migration 084: Add `split_gruppe_id` to transaktionen +**Teammate**: database | **blockedBy**: none + +Create `backend/src/database/migrations/084_buchhaltung_split_gruppe.sql`: +```sql +ALTER TABLE buchhaltung_transaktionen + ADD COLUMN IF NOT EXISTS split_gruppe_id UUID; +CREATE INDEX IF NOT EXISTS idx_buch_trans_split_gruppe + ON buchhaltung_transaktionen(split_gruppe_id) WHERE split_gruppe_id IS NOT NULL; +``` + +### T3 — Backend: Budget Freigaben CRUD +**Teammate**: backend | **blockedBy**: T1 + +**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts`, `buchhaltung.routes.ts` + +Service functions (add before export block ~line 1937): +- `listBudgetFreigaben(filters: { konto_id?, user_id? })` — JOIN users + konten for display names +- `getMyBudgetFreigaben(userId)` — active freigaben for current user +- `createBudgetFreigabe(data, erstelltVon)` — INSERT +- `updateBudgetFreigabe(id, data, userId)` — UPDATE beschreibung/betrag_limit +- `deactivateBudgetFreigabe(id, userId)` — SET aktiv=false + +Routes (add BEFORE `/:id` catch-all routes): +``` +GET /budget-freigaben manage_accounts listBudgetFreigaben +GET /budget-freigaben/my buchhaltung:view getMyBudgetFreigaben +POST /budget-freigaben manage_accounts createBudgetFreigabe +PATCH /budget-freigaben/:id manage_accounts updateBudgetFreigabe +DELETE /budget-freigaben/:id manage_accounts deactivateBudgetFreigabe +``` + +### T4 — Backend: Split Booking Endpoint +**Teammate**: backend | **blockedBy**: T2 + +**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts` + +Modify `bookTransaktion(id, userId, splits?)`: +- No splits → existing behavior (unchanged) +- With splits `Array<{ betrag, konto_id, bankkonto_id? }>`: + 1. Validate sum equals original betrag + 2. BEGIN transaction + 3. Generate UUID `split_gruppe_id` + 4. Mark original as `status='gebucht'`, set `split_gruppe_id` + 5. INSERT N new booked transactions copying fields from original but with split-specific betrag/konto_id/bankkonto_id, same `split_gruppe_id` + 6. Budget alert check per split konto + 7. Audit: `logAudit(id, 'split_gebucht', { splits, split_gruppe_id }, userId)` + +Controller: read optional `req.body.splits`, pass to service. + +### T5 — Backend: Plan Position → Freigabe Endpoint +**Teammate**: backend | **blockedBy**: T3 + +**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts`, `buchhaltung.routes.ts` + +New function `createBudgetFreigabeFromPlanposition(posId, { user_id, betrag_limit?, beschreibung? }, erstelltVon)`: +- Fetch planposition, verify konto_id exists +- Default betrag_limit to sum of budget_gwg + budget_anlagen + budget_instandhaltung +- Call `createBudgetFreigabe` with `planposition_id` set + +Route: `POST /planung/positionen/:posId/freigabe` — manage_accounts + +### T6 — Frontend: Budget Freigaben Tab + Sidebar +**Teammate**: frontend | **blockedBy**: T3 + +**Files**: `buchhaltung.types.ts`, `buchhaltung.ts`, `Buchhaltung.tsx`, `Sidebar.tsx` + +Types: +- `BudgetFreigabe` — id, konto_id, user_id, beschreibung, betrag_limit, planposition_id, aktiv, konto_bezeichnung, user_name +- `BudgetFreigabeFormData` — konto_id, user_id, beschreibung?, betrag_limit? + +Service: `getBudgetFreigaben`, `getMyBudgetFreigaben`, `createBudgetFreigabe`, `updateBudgetFreigabe`, `deleteBudgetFreigabe` + +Buchhaltung.tsx: +- Add tab 3 "Freigaben" (visible only with `manage_accounts`) +- Table: Konto, Benutzer, Beschreibung, Betrag-Limit, Status, Aktionen +- Create/Edit dialog with Konto select, User select, Betrag-Limit, Beschreibung +- Pattern: follow TransaktionenTab Table+Dialog structure + +Sidebar.tsx (~line 155): add `{ text: 'Freigaben', path: '/buchhaltung?tab=3' }` to buchhaltung subItems + +### T7 — Frontend: Split Booking Dialog +**Teammate**: frontend | **blockedBy**: T4 + +**Files**: `buchhaltung.types.ts`, `buchhaltung.ts`, `Buchhaltung.tsx` + +Types: add `SplitRow { betrag: number; konto_id: number; bankkonto_id?: number }` + +Service: update `buchenTransaktion(id, splits?)` to POST body with optional splits array + +Buchhaltung.tsx: +- Add `BookingDialog` component (inline): + - Shows transaction details (betrag, beschreibung) + - "Einfach buchen" button for simple booking (no splits) + - "Aufteilen" toggle reveals split rows + - Each row: betrag input, konto select, bankkonto select, remove button + - "Zeile hinzufuegen" button + - Sum validation with remaining amount display + - "Buchen" button +- Replace buchen IconButton click: open BookingDialog instead of direct mutation +- Update `buchenMut` to accept `{ id, splits? }` + +### T8 — Frontend: Plan Position → Freigabe Action +**Teammate**: frontend | **blockedBy**: T5, T6 + +**Files**: `buchhaltung.ts`, `HaushaltsplanDetail.tsx` + +Service: add `createBudgetFreigabeFromPosition(posId, { user_id, betrag_limit?, beschreibung? })` + +HaushaltsplanDetail.tsx: +- Add "Freigabe erstellen" IconButton per position row (only when canManage && pos.konto_id set) +- Dialog: User select (fetch members), Betrag-Limit (pre-filled from position total), Beschreibung (pre-filled from position name) +- Pattern: follow existing position edit dialog + +--- + +## Teammate Assignments + +| Teammate | Tasks | Mode | +|----------|-------|------| +| database | T1, T2 | bypassPermissions | +| backend | T3, T4, T5 | bypassPermissions | +| frontend | T6, T7, T8 | bypassPermissions | + +## Verification + +1. Migration: check tables exist via backend startup logs +2. Backend: test budget-freigaben CRUD via curl/frontend +3. Frontend: verify Freigaben tab renders, BookingDialog opens on buchen click +4. Split booking: create pending transaction, open dialog, add split rows, book +5. Plan→Freigabe: open HaushaltsplanDetail, click freigabe button on position with konto_id diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..d2e165f --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"7c1ca5d5-0a55-4bf7-b433-7d5e62543b8c","pid":94069,"acquiredAt":1776064212433} \ No newline at end of file diff --git a/.claude/skills/orchestrate/SKILL.md b/.claude/skills/orchestrate/SKILL.md new file mode 100644 index 0000000..afae8a8 --- /dev/null +++ b/.claude/skills/orchestrate/SKILL.md @@ -0,0 +1,121 @@ +# Orchestrate Feature Implementation + +You are a senior full-stack engineer orchestrating a team of Claude Code agents to implement +changes across database, backend, and frontend layers. + +The user's input (the text after `/orchestrate`) describes the tasks to implement. +If no tasks are provided, ask before proceeding. + +--- + +## Constraints + +### Docker +- NEVER execute Docker commands — Docker is NOT available on this host +- ONLY produce deployment artifacts (Dockerfile, docker-compose.yml, .env.example) + +### Migrations +- ALL migrations must be idempotent (safe to run multiple times) +- Verify column and table names match existing schema before writing +- Never rename env vars used by external systems without explicit approval +- "Purge user data" = delete DATA only — never delete user accounts + +### Scope +- Each teammate only touches files in their assigned layer +- Cross-layer changes require explicit permission or a SendMessage to the relevant teammate +- When debugging, only touch files related to the change you made + +### Team Orchestration +- Use TeamCreate before spawning teammates +- Use TaskCreate for ALL work items with clear descriptions and blockedBy dependencies +- Spawn teammates with Agent tool using team_name and descriptive names +- Teammates pick up tasks autonomously — do NOT micromanage +- Use SendMessage for coordination — teammates do NOT share context windows + +### Task Tracking +- Mark tasks in_progress BEFORE starting, completed when done +- After completing a task, check TaskList for next available work + +### General +- NEVER guess — ask if requirements are ambiguous +- Use brainstorm-to-plan skill during planning for non-trivial tasks +- Use commit-formatter skill for the final commit message + +--- + +## Workflow + +Not every task needs all stages. +- **Simple tasks:** skip explore, go straight to plan +- **Complex tasks:** run all stages + +**Always present a plan and get user approval via `EnterPlanMode` before writing any code — no exceptions, even for trivial changes.** + +--- + +### Stage 1 — Explore *(skip for simple tasks)* + +Launch an Explore agent (`subagent_type="Explore"`) with targeted search queries. It returns: +1. Relevant file paths + one-line role descriptions +2. Patterns and conventions to follow +3. Hard constraints (API contracts, shared types, config shapes) +4. Files that need to be created + +--- + +### Stage 2 — Plan + +Use `EnterPlanMode` to get user approval before any execution. + +Output: +1. Task summary (3–5 bullets) +2. Task list with dependencies: + - Parallel tasks (no blockedBy) + - Sequential tasks (blockedBy earlier tasks) + - Each task description is **SELF-CONTAINED**: context, file paths, patterns to follow, + constraints, and acceptance criteria. Teammates have NO implicit context. +3. Teammate assignments — which teammate handles which layer (database / backend / frontend) + +--- + +### Stage 3 — Execute + +**Create team and tasks** +Use `TeamCreate`. Then `TaskCreate` for every task with `blockedBy` dependencies set. + +Example dependency chain: +``` +Task 1: migration (no blockers) → database teammate +Task 2: service (blockedBy: 1) → backend teammate +Task 3: routes (blockedBy: 2) → backend teammate +Task 4: frontend (blockedBy: 2) → frontend teammate +``` + +**Spawn teammates in parallel** using Agent tool with `team_name`. +Each teammate prompt MUST include: +- Role and layer scope (which files/directories they may touch — no others) +- Instruction to check TaskList and claim available unblocked tasks +- Key context from the explore stage (file paths, patterns, constraints) +- Acceptance criteria they must verify before marking a task complete +- Instruction to SendMessage to teammates when producing artifacts they depend on + (e.g. schema shape, API response format, new TypeScript types) + +**Coordinate** +Monitor via idle notifications and messages. Resolve blockers, answer questions, +reassign tasks if needed. When all tasks are complete, shut down teammates via SendMessage. + +--- + +### Stage 4 — Verify + +After all teammates finish and before the final commit: + +1. `cd frontend && npx tsc --noEmit` — fix any type errors +2. `cd backend && npx tsc --noEmit` — fix any type errors +3. Confirm all new migrations are idempotent and column names match the existing schema +4. Verify all new UI components handle loading / error / empty states +5. Check modals open and close correctly; forms reset after submit +6. Verify list operations (add / remove / reorder) persist correctly +7. Confirm API response shapes match frontend TypeScript types + +**Finalize:** use commit-formatter skill for the commit message. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2cb2d5f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# Feuerwehr Dashboard — Project Rules + +## Stack +- **Frontend:** React / Vite / MUI / TypeScript (`frontend/`) +- **Backend:** Node / Express / TypeScript (`backend/`) +- **DB:** PostgreSQL — migrations in `backend/src/database/migrations/` +- **Deployment:** Docker Compose — never run Docker commands locally + +## Migrations +- All migrations must be idempotent (safe to run multiple times) +- Verify column and table names match existing schema before writing +- Never rename env vars used by external systems without explicit approval + +## Data Safety +- "Purge user data" means delete records / preferences / history only — never delete user accounts + +## Debugging Scope +- When debugging, only touch files directly related to the change you made +- Never edit files in another layer without explicit permission +- If a fix requires a cross-layer change, report it rather than making it unilaterally + +## Multi-Agent Work +- After all agents complete, run a verification pass: build, migrations, UI states, modal behavior +- All agent task descriptions must be self-contained (file paths, patterns, acceptance criteria) diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 5ccad9c..1bf3e83 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -100,7 +100,22 @@ class AuthController { // Step 4: Find or create user in database let user = await userService.findByAuthentikSub(userInfo.sub); - const isNewUser = !user; + let isNewUser = !user; + + // Check for a FDISK-pre-created account to claim on first Authentik login + if (!user) { + const { given_name: fdiskGivenName, family_name: fdiskFamilyName } = extractNames(userInfo); + if (fdiskGivenName && fdiskFamilyName) { + const fdiskUser = await userService.findFdiskUserByName(fdiskGivenName, fdiskFamilyName); + if (fdiskUser) { + user = await userService.claimFdiskUser(fdiskUser.id, userInfo.sub, userInfo.email); + if (user) { + isNewUser = false; + logger.info('Claimed FDISK-pre-created user on first login', { userId: fdiskUser.id, sub: userInfo.sub }); + } + } + } + } if (!user) { // User doesn't exist, create new user diff --git a/backend/src/database/migrations/087_add_sync_source_to_users.sql b/backend/src/database/migrations/087_add_sync_source_to_users.sql new file mode 100644 index 0000000..78761fb --- /dev/null +++ b/backend/src/database/migrations/087_add_sync_source_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS sync_source VARCHAR(16) DEFAULT NULL; diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts index 70b9fba..6ddbe76 100644 --- a/backend/src/models/user.model.ts +++ b/backend/src/models/user.model.ts @@ -17,6 +17,7 @@ export interface User { updated_at: Date; preferences?: any; // JSONB authentik_groups: string[]; + sync_source?: string | null; } export interface CreateUserData { diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index ac276ab..871a0dd 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -413,6 +413,53 @@ class UserService { throw new Error('Failed to update user groups'); } } + + async findFdiskUserByName(givenName: string, familyName: string): Promise { + try { + const result = await pool.query( + `SELECT id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences, + authentik_groups, sync_source + FROM users + WHERE sync_source = 'fdisk' + AND last_login_at IS NULL + AND LOWER(given_name) = LOWER($1) + AND LOWER(family_name) = LOWER($2)`, + [givenName, familyName] + ); + if (result.rows.length === 1) return result.rows[0]; + if (result.rows.length > 1) { + logger.warn('Ambiguous FDISK name match on login — skipping merge', { givenName, familyName, count: result.rows.length }); + } + return null; + } catch (error) { + logger.error('Error finding FDISK user by name', { error }); + return null; + } + } + + async claimFdiskUser(userId: string, authentikSub: string, email: string): Promise { + try { + const result = await pool.query( + `UPDATE users SET + authentik_sub = $2, + email = $3 + WHERE id = $1 AND sync_source = 'fdisk' AND last_login_at IS NULL + RETURNING id, email, authentik_sub, name, preferred_username, given_name, + family_name, profile_picture_url, refresh_token, refresh_token_expires_at, + is_active, last_login_at, created_at, updated_at, preferences, + authentik_groups, sync_source`, + [userId, authentikSub, email] + ); + if (result.rows.length === 0) return null; + logger.info('FDISK user claimed by Authentik login', { userId, authentikSub }); + return result.rows[0]; + } catch (error) { + logger.error('Error claiming FDISK user', { error, userId }); + return null; + } + } } export default new UserService(); diff --git a/sync/src/db.ts b/sync/src/db.ts index 379b66d..78ca2c3 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -98,6 +98,7 @@ export async function syncToDatabase( let updated = 0; let unchanged = 0; let forced = 0; + let created = 0; let skipped = 0; for (const member of members) { @@ -107,8 +108,7 @@ export async function syncToDatabase( `SELECT mp.user_id FROM mitglieder_profile mp JOIN users u ON u.id = mp.user_id - WHERE mp.fdisk_standesbuch_nr = $1 - AND u.last_login_at IS NOT NULL`, + WHERE mp.fdisk_standesbuch_nr = $1`, [member.standesbuchNr] ); @@ -123,8 +123,7 @@ export async function syncToDatabase( FROM users u JOIN mitglieder_profile mp ON mp.user_id = u.id WHERE LOWER(u.given_name) = LOWER($1) - AND LOWER(u.family_name) = LOWER($2) - AND u.last_login_at IS NOT NULL`, + AND LOWER(u.family_name) = LOWER($2)`, [member.vorname, member.zuname] ); @@ -142,8 +141,44 @@ export async function syncToDatabase( } if (!userId) { - skipped++; - continue; + // No matching user found — create a new dashboard user pre-seeded from FDISK + const insertResult = await client.query<{ id: string }>( + `INSERT INTO users (authentik_sub, email, name, given_name, family_name, is_active, sync_source) + VALUES ($1, $2, $3, $4, $5, true, 'fdisk') + ON CONFLICT (authentik_sub) DO NOTHING + RETURNING id`, + [ + `fdisk:${member.standesbuchNr}`, + `fdisk_sync_${member.standesbuchNr}@intern.noreply`, + `${member.vorname} ${member.zuname}`, + member.vorname, + member.zuname, + ] + ); + + if (insertResult.rows.length > 0) { + userId = insertResult.rows[0].id; + } else { + // ON CONFLICT hit — user already existed (idempotent re-run); fetch it + const existingResult = await client.query<{ id: string }>( + `SELECT id FROM users WHERE authentik_sub = $1`, + [`fdisk:${member.standesbuchNr}`] + ); + userId = existingResult.rows[0]?.id ?? null; + } + + if (userId) { + await client.query( + `INSERT INTO mitglieder_profile (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, + [userId] + ); + log(`Created ${member.vorname} ${member.zuname} (${member.standesbuchNr}): new FDISK-only user`); + created++; + } else { + log(`WARN: could not create user for ${member.vorname} ${member.zuname} (${member.standesbuchNr})`); + skipped++; + continue; + } } // Fetch current values to detect what actually changed @@ -222,7 +257,7 @@ export async function syncToDatabase( } } - log(`Members: ${updated} changed, ${unchanged} unchanged, ${forced} forced, ${skipped} skipped (no dashboard account)`); + log(`Members: ${updated} changed, ${unchanged} unchanged, ${forced} forced, ${created} created, ${skipped} skipped`); // Upsert Ausbildungen let ausbildungNew = 0;