diff --git a/backend/src/app.ts b/backend/src/app.ts index a588407..45353f5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -108,6 +108,7 @@ import checklistRoutes from './routes/checklist.routes'; import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes'; import ausruestungTypRoutes from './routes/ausruestungTyp.routes'; import buchhaltungRoutes from './routes/buchhaltung.routes'; +import personalEquipmentRoutes from './routes/personalEquipment.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -138,6 +139,7 @@ app.use('/api/checklisten', checklistRoutes); app.use('/api/fahrzeug-typen', fahrzeugTypRoutes); app.use('/api/ausruestung-typen', ausruestungTypRoutes); app.use('/api/buchhaltung', buchhaltungRoutes); +app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index 598c11f..c4e64d9 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -555,6 +555,54 @@ class AusruestungsanfrageController { } } + // ------------------------------------------------------------------------- + // Assignment of delivered items + // ------------------------------------------------------------------------- + + async assignDeliveredItems(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const { assignments } = req.body as { + assignments?: Array<{ + positionId: number; + typ: 'ausruestung' | 'persoenlich' | 'keine'; + fahrzeugId?: string; + standort?: string; + userId?: string; + benutzerName?: string; + groesse?: string; + kategorie?: string; + }>; + }; + + if (!assignments || assignments.length === 0) { + res.status(400).json({ success: false, message: 'Mindestens eine Zuweisung ist erforderlich' }); + return; + } + + for (const a of assignments) { + if (!a.positionId || !a.typ) { + res.status(400).json({ success: false, message: 'positionId und typ sind erforderlich' }); + return; + } + if (!['ausruestung', 'persoenlich', 'keine'].includes(a.typ)) { + res.status(400).json({ success: false, message: 'Ungültiger Zuweisungstyp' }); + return; + } + } + + const result = await ausruestungsanfrageService.assignDeliveredItems(id, req.user!.id, assignments); + res.status(200).json({ success: true, data: result }); + } catch (error: any) { + if (error?.message === 'Anfrage nicht gefunden') { + res.status(404).json({ success: false, message: error.message }); + return; + } + logger.error('AusruestungsanfrageController.assignDeliveredItems error', { error }); + res.status(500).json({ success: false, message: 'Zuweisung konnte nicht gespeichert werden' }); + } + } + // ------------------------------------------------------------------------- // Widget overview (lightweight, for dashboard widget) // ------------------------------------------------------------------------- diff --git a/backend/src/controllers/personalEquipment.controller.ts b/backend/src/controllers/personalEquipment.controller.ts new file mode 100644 index 0000000..ebac14d --- /dev/null +++ b/backend/src/controllers/personalEquipment.controller.ts @@ -0,0 +1,177 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import personalEquipmentService from '../services/personalEquipment.service'; +import logger from '../utils/logger'; + +const uuidString = z.string().regex( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + 'Ungültige UUID', +); + +const isoDate = z.string().regex( + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, + 'Erwartet ISO-Datum im Format YYYY-MM-DD', +); + +const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']); + +const CreateSchema = z.object({ + bezeichnung: z.string().min(1).max(200), + kategorie: z.string().max(100).optional(), + artikel_id: z.number().int().positive().optional(), + user_id: uuidString.optional(), + benutzer_name: z.string().max(200).optional(), + groesse: z.string().max(50).optional(), + seriennummer: z.string().max(100).optional(), + inventarnummer: z.string().max(50).optional(), + anschaffung_datum: isoDate.optional(), + zustand: ZustandEnum.optional(), + notizen: z.string().max(2000).optional(), +}); + +const UpdateSchema = z.object({ + bezeichnung: z.string().min(1).max(200).optional(), + kategorie: z.string().max(100).nullable().optional(), + artikel_id: z.number().int().positive().nullable().optional(), + user_id: uuidString.nullable().optional(), + benutzer_name: z.string().max(200).nullable().optional(), + groesse: z.string().max(50).nullable().optional(), + seriennummer: z.string().max(100).nullable().optional(), + inventarnummer: z.string().max(50).nullable().optional(), + anschaffung_datum: isoDate.nullable().optional(), + zustand: ZustandEnum.optional(), + notizen: z.string().max(2000).nullable().optional(), +}); + +function isValidUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +class PersonalEquipmentController { + async list(req: Request, res: Response): Promise { + try { + const filters: Record = {}; + if (req.query.user_id) filters.userId = String(req.query.user_id); + if (req.query.kategorie) filters.kategorie = String(req.query.kategorie); + if (req.query.zustand) filters.zustand = String(req.query.zustand); + + const items = await personalEquipmentService.getAll(filters); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('personalEquipment.list error', { error }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' }); + } + } + + async getMy(req: Request, res: Response): Promise { + try { + const items = await personalEquipmentService.getByUserId(req.user!.id); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('personalEquipment.getMy error', { error }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' }); + } + } + + async getByUser(req: Request, res: Response): Promise { + try { + const { userId } = req.params as Record; + if (!isValidUUID(userId)) { + res.status(400).json({ success: false, message: 'Ungültige User-ID' }); + return; + } + const items = await personalEquipmentService.getByUserId(userId); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('personalEquipment.getByUser error', { error }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' }); + } + } + + async getById(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + const item = await personalEquipmentService.getById(id); + if (!item) { + res.status(404).json({ success: false, message: 'Nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('personalEquipment.getById error', { error }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' }); + } + } + + async create(req: Request, res: Response): Promise { + try { + const parsed = CreateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors }); + return; + } + const item = await personalEquipmentService.create(parsed.data, req.user!.id); + res.status(201).json({ success: true, data: item }); + } catch (error) { + logger.error('personalEquipment.create error', { error }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht erstellt werden' }); + } + } + + async update(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + const parsed = UpdateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors }); + return; + } + if (Object.keys(parsed.data).length === 0) { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + const item = await personalEquipmentService.update(id, parsed.data as any); + if (!item) { + res.status(404).json({ success: false, message: 'Nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error: any) { + if (error?.message === 'No fields to update') { + res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); + return; + } + logger.error('personalEquipment.update error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht aktualisiert werden' }); + } + } + + async delete(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + const deleted = await personalEquipmentService.delete(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Persönliche Ausrüstung gelöscht' }); + } catch (error) { + logger.error('personalEquipment.delete error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht gelöscht werden' }); + } + } +} + +export default new PersonalEquipmentController(); diff --git a/backend/src/database/migrations/084_persoenliche_ausruestung.sql b/backend/src/database/migrations/084_persoenliche_ausruestung.sql new file mode 100644 index 0000000..c2cb69f --- /dev/null +++ b/backend/src/database/migrations/084_persoenliche_ausruestung.sql @@ -0,0 +1,82 @@ +-- Migration: 084_persoenliche_ausruestung +-- Creates persoenliche_ausruestung table, adds assignment columns to +-- ausruestung_anfrage_positionen, and seeds feature_group + permissions. + +-- 1. Create persoenliche_ausruestung table +CREATE TABLE IF NOT EXISTS persoenliche_ausruestung ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bezeichnung TEXT NOT NULL, + kategorie TEXT, + artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + benutzer_name TEXT, + groesse TEXT, + seriennummer TEXT, + inventarnummer TEXT, + anschaffung_datum DATE, + zustand TEXT DEFAULT 'gut' CHECK (zustand IN ('gut','beschaedigt','abgaengig','verloren')), + notizen TEXT, + anfrage_id INT REFERENCES ausruestung_anfragen(id) ON DELETE SET NULL, + anfrage_position_id INT REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ DEFAULT NOW(), + geloescht_am TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_user + ON persoenliche_ausruestung(user_id) WHERE geloescht_am IS NULL; +CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_artikel + ON persoenliche_ausruestung(artikel_id); + +-- Auto-update aktualisiert_am trigger (uses the aktualisiert_am variant from migration 018) +CREATE TRIGGER trg_persoenliche_ausruestung_aktualisiert_am + BEFORE UPDATE ON persoenliche_ausruestung + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am_column(); + +-- 2. Add assignment columns to ausruestung_anfrage_positionen +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS zuweisung_typ TEXT CHECK (zuweisung_typ IN ('ausruestung','persoenlich','keine')); + +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS zuweisung_ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL; + +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS zuweisung_persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL; + +-- 3. Feature group + permissions +INSERT INTO feature_groups (id, name, maintenance_mode) +VALUES ('persoenliche_ausruestung', 'Persönliche Ausrüstung', false) +ON CONFLICT DO NOTHING; + +INSERT INTO permissions (id, feature_group_id, description) VALUES + ('persoenliche_ausruestung:view', 'persoenliche_ausruestung', 'Eigene persönliche Ausrüstung anzeigen'), + ('persoenliche_ausruestung:view_all', 'persoenliche_ausruestung', 'Alle persönliche Ausrüstung anzeigen'), + ('persoenliche_ausruestung:create', 'persoenliche_ausruestung', 'Persönliche Ausrüstung erstellen'), + ('persoenliche_ausruestung:edit', 'persoenliche_ausruestung', 'Persönliche Ausrüstung bearbeiten'), + ('persoenliche_ausruestung:delete', 'persoenliche_ausruestung', 'Persönliche Ausrüstung löschen') +ON CONFLICT DO NOTHING; + +-- Seed permissions for groups: admin, kommandant, gruppenkommandant, zeugmeister get all; others get view only +INSERT INTO group_permissions (group_name, permission_id) +SELECT g.name, p.id +FROM (VALUES + ('dashboard_admin'), + ('dashboard_kommandant'), + ('dashboard_gruppenkommandant'), + ('dashboard_zeugmeister') +) AS g(name) +CROSS JOIN permissions p +WHERE p.feature_group_id = 'persoenliche_ausruestung' +ON CONFLICT DO NOTHING; + +-- All other groups get view only +INSERT INTO group_permissions (group_name, permission_id) +SELECT g.name, 'persoenliche_ausruestung:view' +FROM (VALUES + ('dashboard_feuerwehrmitglied'), + ('dashboard_atemschutztraeger'), + ('dashboard_fahrmeister'), + ('dashboard_jugend') +) AS g(name) +ON CONFLICT DO NOTHING; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index 5bf3c10..e2de5a1 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -71,5 +71,6 @@ router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePerm router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController)); router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController)); router.post('/requests/:id/create-orders', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.createOrders.bind(ausruestungsanfrageController)); +router.post('/requests/:id/assign', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.assignDeliveredItems.bind(ausruestungsanfrageController)); export default router; diff --git a/backend/src/routes/personalEquipment.routes.ts b/backend/src/routes/personalEquipment.routes.ts new file mode 100644 index 0000000..0272dac --- /dev/null +++ b/backend/src/routes/personalEquipment.routes.ts @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import personalEquipmentController from '../controllers/personalEquipment.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; +import { permissionService } from '../services/permission.service'; + +const router = Router(); + +// Own items — any authenticated user with view permission +router.get('/my', authenticate, personalEquipmentController.getMy.bind(personalEquipmentController)); + +// All items — requires view_all +router.get('/', authenticate, requirePermission('persoenliche_ausruestung:view_all'), personalEquipmentController.list.bind(personalEquipmentController)); + +// By user — own data or view_all +router.get('/user/:userId', authenticate, async (req, res, next) => { + const isOwn = req.user!.id === req.params.userId; + if (isOwn) { + const hasView = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view'); + if (hasView || req.user!.groups?.includes('dashboard_admin')) return next(); + } + const hasViewAll = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view_all'); + if (hasViewAll || req.user!.groups?.includes('dashboard_admin')) return next(); + res.status(403).json({ success: false, message: 'Keine Berechtigung' }); +}, personalEquipmentController.getByUser.bind(personalEquipmentController)); + +// Single item +router.get('/:id', authenticate, personalEquipmentController.getById.bind(personalEquipmentController)); + +// Create +router.post('/', authenticate, requirePermission('persoenliche_ausruestung:create'), personalEquipmentController.create.bind(personalEquipmentController)); + +// Update +router.patch('/:id', authenticate, requirePermission('persoenliche_ausruestung:edit'), personalEquipmentController.update.bind(personalEquipmentController)); + +// Delete (soft) +router.delete('/:id', authenticate, requirePermission('persoenliche_ausruestung:delete'), personalEquipmentController.delete.bind(personalEquipmentController)); + +export default router; diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index feee808..82041b8 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -889,6 +889,145 @@ async function getWidgetOverview() { return result.rows[0]; } +// --------------------------------------------------------------------------- +// Assignment of delivered items +// --------------------------------------------------------------------------- + +interface AssignmentInput { + positionId: number; + typ: 'ausruestung' | 'persoenlich' | 'keine'; + fahrzeugId?: string; + standort?: string; + userId?: string; + benutzerName?: string; + groesse?: string; + kategorie?: string; +} + +async function assignDeliveredItems( + anfrageId: number, + requestingUserId: string, + assignments: AssignmentInput[], +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Load anfrage to get anfrager_id (default user for personal assignments) + const anfrageResult = await client.query( + 'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1', + [anfrageId], + ); + if (anfrageResult.rows.length === 0) { + throw new Error('Anfrage nicht gefunden'); + } + const anfragerId = anfrageResult.rows[0].anfrager_id; + + let assigned = 0; + + for (const a of assignments) { + // Load position details + const posResult = await client.query( + 'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2', + [a.positionId, anfrageId], + ); + if (posResult.rows.length === 0) continue; + const pos = posResult.rows[0]; + + if (a.typ === 'ausruestung') { + // Look up kategorie_id from artikel if available + let kategorieId: string | null = null; + if (pos.artikel_id) { + const artikelResult = await client.query( + 'SELECT kategorie_id FROM ausruestung_artikel WHERE id = $1', + [pos.artikel_id], + ); + if (artikelResult.rows[0]?.kategorie_id) { + // artikel has kategorie_id (int FK to ausruestung_kategorien_katalog), but + // ausruestung.kategorie_id is UUID FK to ausruestung_kategorien — look up by name + const katNameResult = await client.query( + 'SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1', + [artikelResult.rows[0].kategorie_id], + ); + if (katNameResult.rows[0]?.name) { + const katResult = await client.query( + 'SELECT id FROM ausruestung_kategorien WHERE name = $1 LIMIT 1', + [katNameResult.rows[0].name], + ); + kategorieId = katResult.rows[0]?.id ?? null; + } + } + } + // Fallback: pick first category + if (!kategorieId) { + const fallback = await client.query('SELECT id FROM ausruestung_kategorien LIMIT 1'); + kategorieId = fallback.rows[0]?.id ?? null; + } + + const insertResult = await client.query( + `INSERT INTO ausruestung ( + id, bezeichnung, kategorie_id, fahrzeug_id, standort, status, ist_wichtig + ) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'einsatzbereit', false) + RETURNING id`, + [pos.bezeichnung, kategorieId, a.fahrzeugId ?? null, a.standort ?? 'Lager'], + ); + const newId = insertResult.rows[0].id; + + await client.query( + `UPDATE ausruestung_anfrage_positionen + SET zuweisung_typ = 'ausruestung', zuweisung_ausruestung_id = $1 + WHERE id = $2`, + [newId, a.positionId], + ); + } else if (a.typ === 'persoenlich') { + const insertResult = await client.query( + `INSERT INTO persoenliche_ausruestung ( + bezeichnung, kategorie, groesse, user_id, benutzer_name, + anfrage_id, anfrage_position_id, artikel_id, erstellt_von + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id`, + [ + pos.bezeichnung, + a.kategorie ?? null, + a.groesse ?? null, + a.userId ?? anfragerId, + a.benutzerName ?? null, + anfrageId, + a.positionId, + pos.artikel_id ?? null, + requestingUserId, + ], + ); + const newId = insertResult.rows[0].id; + + await client.query( + `UPDATE ausruestung_anfrage_positionen + SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1 + WHERE id = $2`, + [newId, a.positionId], + ); + } else { + // typ === 'keine' + await client.query( + `UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'keine' WHERE id = $1`, + [a.positionId], + ); + } + + assigned++; + } + + await client.query('COMMIT'); + return { assigned }; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('ausruestungsanfrageService.assignDeliveredItems failed', { error, anfrageId }); + throw error; + } finally { + client.release(); + } +} + export default { getAllUsers, getKategorien, @@ -919,4 +1058,5 @@ export default { createOrdersFromRequest, getOverview, getWidgetOverview, + assignDeliveredItems, }; diff --git a/backend/src/services/personalEquipment.service.ts b/backend/src/services/personalEquipment.service.ts new file mode 100644 index 0000000..3fd4ec8 --- /dev/null +++ b/backend/src/services/personalEquipment.service.ts @@ -0,0 +1,200 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +interface PersonalEquipmentFilters { + userId?: string; + kategorie?: string; + zustand?: string; +} + +interface CreatePersonalEquipmentData { + bezeichnung: string; + kategorie?: string; + artikel_id?: number; + user_id?: string; + benutzer_name?: string; + groesse?: string; + seriennummer?: string; + inventarnummer?: string; + anschaffung_datum?: string; + zustand?: string; + notizen?: string; +} + +interface UpdatePersonalEquipmentData { + bezeichnung?: string; + kategorie?: string; + artikel_id?: number | null; + user_id?: string | null; + benutzer_name?: string | null; + groesse?: string | null; + seriennummer?: string | null; + inventarnummer?: string | null; + anschaffung_datum?: string | null; + zustand?: string; + notizen?: string | null; +} + +const BASE_SELECT = ` + SELECT pa.*, + COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name, + aa.bezeichnung AS artikel_bezeichnung + FROM persoenliche_ausruestung pa + LEFT JOIN users u ON u.id = pa.user_id + LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id + WHERE pa.geloescht_am IS NULL +`; + +async function getAll(filters: PersonalEquipmentFilters = {}) { + try { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filters.userId) { + params.push(filters.userId); + conditions.push(`pa.user_id = $${params.length}`); + } + if (filters.kategorie) { + params.push(filters.kategorie); + conditions.push(`pa.kategorie = $${params.length}`); + } + if (filters.zustand) { + params.push(filters.zustand); + conditions.push(`pa.zustand = $${params.length}`); + } + + const where = conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `${BASE_SELECT}${where} ORDER BY pa.bezeichnung`, + params, + ); + return result.rows; + } catch (error) { + logger.error('personalEquipmentService.getAll failed', { error }); + throw new Error('Failed to fetch personal equipment'); + } +} + +async function getByUserId(userId: string) { + try { + const result = await pool.query( + `${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`, + [userId], + ); + return result.rows; + } catch (error) { + logger.error('personalEquipmentService.getByUserId failed', { error, userId }); + throw new Error('Failed to fetch personal equipment for user'); + } +} + +async function getById(id: string) { + try { + const result = await pool.query( + `${BASE_SELECT} AND pa.id = $1`, + [id], + ); + return result.rows[0] || null; + } catch (error) { + logger.error('personalEquipmentService.getById failed', { error, id }); + throw new Error('Failed to fetch personal equipment item'); + } +} + +async function create(data: CreatePersonalEquipmentData, requestingUserId: string) { + try { + const result = await pool.query( + `INSERT INTO persoenliche_ausruestung ( + bezeichnung, kategorie, artikel_id, user_id, benutzer_name, + groesse, seriennummer, inventarnummer, anschaffung_datum, + zustand, notizen, erstellt_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + data.bezeichnung, + data.kategorie ?? null, + data.artikel_id ?? null, + data.user_id ?? null, + data.benutzer_name ?? null, + data.groesse ?? null, + data.seriennummer ?? null, + data.inventarnummer ?? null, + data.anschaffung_datum ?? null, + data.zustand ?? 'gut', + data.notizen ?? null, + requestingUserId, + ], + ); + logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId }); + return result.rows[0]; + } catch (error) { + logger.error('personalEquipmentService.create failed', { error }); + throw new Error('Failed to create personal equipment'); + } +} + +async function update(id: string, data: UpdatePersonalEquipmentData) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let p = 1; + + const addField = (col: string, value: unknown) => { + fields.push(`${col} = $${p++}`); + values.push(value); + }; + + if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung); + if (data.kategorie !== undefined) addField('kategorie', data.kategorie); + if (data.artikel_id !== undefined) addField('artikel_id', data.artikel_id); + if (data.user_id !== undefined) addField('user_id', data.user_id); + if (data.benutzer_name !== undefined) addField('benutzer_name', data.benutzer_name); + if (data.groesse !== undefined) addField('groesse', data.groesse); + if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer); + if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer); + if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum); + if (data.zustand !== undefined) addField('zustand', data.zustand); + if (data.notizen !== undefined) addField('notizen', data.notizen); + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + values.push(id); + const result = await pool.query( + `UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`, + values, + ); + + if (result.rows.length === 0) return null; + logger.info('Personal equipment updated', { id }); + return result.rows[0]; + } catch (error) { + logger.error('personalEquipmentService.update failed', { error, id }); + throw error; + } +} + +async function softDelete(id: string) { + try { + const result = await pool.query( + `UPDATE persoenliche_ausruestung SET geloescht_am = NOW() WHERE id = $1 AND geloescht_am IS NULL RETURNING id`, + [id], + ); + if (result.rows.length === 0) return false; + logger.info('Personal equipment soft-deleted', { id }); + return true; + } catch (error) { + logger.error('personalEquipmentService.softDelete failed', { error, id }); + throw new Error('Failed to delete personal equipment'); + } +} + +export default { + getAll, + getByUserId, + getById, + create, + update, + delete: softDelete, +}; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 1b404e6..d4be236 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -350,7 +350,13 @@ class UserService { // User-owned data tables (DELETE rows) await purge('notifications'); - await purge('mitglieder_profile'); + // Keep mitglieder_profile row but NULL FDISK-synced fields + await client.query( + `UPDATE mitglieder_profile SET dienstgrad = NULL, dienstgrad_seit = NULL, eintrittsdatum = NULL, austrittsdatum = NULL, geburtsdatum = NULL, fuehrerscheinklassen = '{}' WHERE user_id = $1`, + [userId] + ); + results['mitglieder_profile_fdisk_cleared'] = 1; + await purge('dienstgrad_verlauf'); await purge('atemschutz_traeger'); await purge('ausbildungen'); @@ -362,7 +368,14 @@ class UserService { await purge('veranstaltung_teilnahmen'); await purge('veranstaltung_ical_tokens'); await purge('fahrzeug_ical_tokens'); - await purge('shop_anfragen', 'anfrager_id'); + // Delete child positions before parent anfragen (FK constraint) + const posResult = await client.query( + 'DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id IN (SELECT id FROM ausruestung_anfragen WHERE anfrager_id = $1)', + [userId] + ); + results['ausruestung_anfrage_positionen'] = posResult.rowCount ?? 0; + await purge('ausruestung_anfragen', 'anfrager_id'); + await purge('persoenliche_ausruestung'); // Clear user preferences (widget layout, etc.) await client.query( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3223850..8a43819 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import Ausruestung from './pages/Ausruestung'; import AusruestungForm from './pages/AusruestungForm'; import AusruestungDetail from './pages/AusruestungDetail'; import AusruestungEinstellungen from './pages/AusruestungEinstellungen'; +import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung'; import Atemschutz from './pages/Atemschutz'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; @@ -192,6 +193,14 @@ function App() { } /> + + + + } + /> personalEquipmentApi.getMy(), + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const displayItems = (items ?? []).slice(0, 5); + + return ( + } + isLoading={isLoading} + skeleton={} + isError={isError} + errorMessage="Ausrüstung konnte nicht geladen werden." + isEmpty={!isLoading && !isError && (items ?? []).length === 0} + emptyMessage="Keine persönlichen Gegenstände erfasst" + onClick={() => navigate('/persoenliche-ausruestung')} + footer={ + items && items.length > 5 ? ( + + +{items.length - 5} weitere + + ) : undefined + } + > + + {displayItems.map((item) => ( + + + + + ))} + + + ); +} + +export default PersoenlicheAusruestungWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 8bf7e83..ca80752 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -25,3 +25,4 @@ export { default as IssueOverviewWidget } from './IssueOverviewWidget'; export { default as ChecklistWidget } from './ChecklistWidget'; export { default as SortableWidget } from './SortableWidget'; export { default as BuchhaltungWidget } from './BuchhaltungWidget'; +export { default as PersoenlicheAusruestungWidget } from './PersoenlicheAusruestungWidget'; diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 5341270..d385734 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -31,6 +31,7 @@ import { Forum, AssignmentTurnedIn, AccountBalance as AccountBalanceIcon, + Checkroom as CheckroomIcon, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -102,6 +103,12 @@ const baseNavigationItems: NavigationItem[] = [ path: '/ausruestung', permission: 'ausruestung:view', }, + { + text: 'Pers. Ausrüstung', + icon: , + path: '/persoenliche-ausruestung', + permission: 'persoenliche_ausruestung:view', + }, { text: 'Mitglieder', icon: , diff --git a/frontend/src/components/templates/StatCard.tsx b/frontend/src/components/templates/StatCard.tsx index 1e89772..1760d50 100644 --- a/frontend/src/components/templates/StatCard.tsx +++ b/frontend/src/components/templates/StatCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { GOLDEN_RATIO } from '../../theme/theme'; +import { WidgetCard } from './WidgetCard'; import { StatSkeleton } from './SkeletonPresets'; export interface StatCardProps { @@ -22,63 +23,48 @@ export const StatCard: React.FC = ({ trend, isLoading = false, onClick, -}) => { - const content = ( - - {isLoading ? ( - - ) : ( - - - - {title} - - - {value} - - {trend && ( - = 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}> - = 0 ? 'success.main' : 'error.main'} - > - {trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}% - {trend.label && ` ${trend.label}`} - - - )} - - - ( + } + onClick={onClick} + noPadding + > + + + + {value} + + {trend && ( + = 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)' }}> + = 0 ? 'success.main' : 'error.main'} > - {icon} - + {trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}% + {trend.label && ` ${trend.label}`} + + )} + + + + {icon} - )} - - ); - - return ( - - {onClick ? ( - - {content} - - ) : ( - content - )} - - ); -}; + + + +); diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index e40cb01..d68b6ab 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -19,6 +19,7 @@ export const WIDGETS = [ { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, { key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true }, { key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true }, + { key: 'persoenlicheAusruestung', label: 'Pers. Ausrüstung', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index d3a5517..ca7d4c8 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -5,10 +5,12 @@ import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, InputLabel, Autocomplete, Checkbox, LinearProgress, Switch, FormControlLabel, Alert, + ToggleButton, ToggleButtonGroup, Stack, Divider, } from '@mui/material'; import { ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, Check as CheckIcon, Close as CloseIcon, ShoppingCart as ShoppingCartIcon, + Assignment as AssignmentIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -18,11 +20,13 @@ import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useAuth } from '../contexts/AuthContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import { vehiclesApi } from '../services/vehicles'; +import { membersService } from '../services/members'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfrage, AusruestungAnfrageDetailResponse, AusruestungAnfrageFormItem, AusruestungAnfrageStatus, - AusruestungEigenschaft, + AusruestungAnfragePosition, AusruestungEigenschaft, } from '../types/ausruestungsanfrage.types'; // ── Helpers ── @@ -34,6 +38,225 @@ function formatOrderId(r: AusruestungAnfrage): string { return `#${r.id}`; } +// ── Helpers ── + +type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine'; + +interface PositionAssignment { + typ: AssignmentTyp; + fahrzeugId?: string; + standort?: string; + userId?: string; + benutzerName?: string; + groesse?: string; + kategorie?: string; +} + +function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { + return positions.filter((p) => p.geliefert && !p.zuweisung_typ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// ItemAssignmentDialog +// ══════════════════════════════════════════════════════════════════════════════ + +interface ItemAssignmentDialogProps { + open: boolean; + onClose: () => void; + anfrage: AusruestungAnfrage; + positions: AusruestungAnfragePosition[]; + onSuccess: () => void; +} + +function ItemAssignmentDialog({ open, onClose, anfrage, positions, onSuccess }: ItemAssignmentDialogProps) { + const { showSuccess, showError } = useNotification(); + const unassigned = getUnassignedPositions(positions); + + const [assignments, setAssignments] = useState>(() => { + const init: Record = {}; + for (const p of unassigned) { + init[p.id] = { typ: 'persoenlich' }; + } + return init; + }); + + const { data: vehicleList } = useQuery({ + queryKey: ['vehicles', 'sidebar'], + queryFn: () => vehiclesApi.getAll(), + staleTime: 2 * 60 * 1000, + enabled: open, + }); + + const { data: membersList } = useQuery({ + queryKey: ['members-list-compact'], + queryFn: () => membersService.getMembers({ pageSize: 500 }), + staleTime: 5 * 60 * 1000, + enabled: open, + }); + + const memberOptions = (membersList?.items ?? []).map((m) => ({ + id: m.id, + name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email, + })); + + const vehicleOptions = (vehicleList ?? []).map((v) => ({ + id: v.id, + name: v.bezeichnung ?? v.kurzname, + })); + + const [submitting, setSubmitting] = useState(false); + + const updateAssignment = (posId: number, patch: Partial) => { + setAssignments((prev) => ({ + ...prev, + [posId]: { ...prev[posId], ...patch }, + })); + }; + + const handleSkipAll = () => { + const updated: Record = {}; + for (const p of unassigned) { + updated[p.id] = { typ: 'keine' }; + } + setAssignments(updated); + }; + + const handleSubmit = async () => { + setSubmitting(true); + try { + const payload = Object.entries(assignments).map(([posId, a]) => ({ + positionId: Number(posId), + typ: a.typ, + fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined, + standort: a.typ === 'ausruestung' ? a.standort : undefined, + userId: a.typ === 'persoenlich' ? a.userId : undefined, + benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined, + groesse: a.typ === 'persoenlich' ? a.groesse : undefined, + kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined, + })); + await ausruestungsanfrageApi.assignItems(anfrage.id, payload); + showSuccess('Gegenstände zugewiesen'); + onSuccess(); + onClose(); + } catch { + showError('Fehler beim Zuweisen'); + } finally { + setSubmitting(false); + } + }; + + if (unassigned.length === 0) return null; + + return ( + + Gegenstände zuweisen + + + Wähle für jeden gelieferten Gegenstand, wie er erfasst werden soll. + + + }> + {unassigned.map((pos) => { + const a = assignments[pos.id] ?? { typ: 'persoenlich' as const }; + return ( + + + + {pos.bezeichnung} + + + + + val && updateAssignment(pos.id, { typ: val })} + sx={{ mb: 1.5 }} + > + Ausrüstung + Persönlich + Nicht erfassen + + + {a.typ === 'ausruestung' && ( + + o.name} + value={vehicleOptions.find((v) => v.id === a.fahrzeugId) ?? null} + onChange={(_e, v) => updateAssignment(pos.id, { fahrzeugId: v?.id })} + renderInput={(params) => } + sx={{ minWidth: 200, flex: 1 }} + /> + updateAssignment(pos.id, { standort: e.target.value })} + sx={{ minWidth: 160, flex: 1 }} + /> + + )} + + {a.typ === 'persoenlich' && ( + + o.name} + value={memberOptions.find((m) => m.id === a.userId) ?? null} + onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })} + renderInput={(params) => ( + + )} + sx={{ minWidth: 200, flex: 1 }} + /> + updateAssignment(pos.id, { groesse: e.target.value })} + sx={{ minWidth: 100 }} + /> + updateAssignment(pos.id, { kategorie: e.target.value })} + sx={{ minWidth: 140 }} + /> + + )} + + ); + })} + + + + + + + + + + ); +} + // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ @@ -59,6 +282,9 @@ export default function AusruestungsanfrageDetail() { const [adminNotizen, setAdminNotizen] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState(''); + // Assignment dialog state + const [assignmentOpen, setAssignmentOpen] = useState(false); + // Eigenschaften state for edit mode const [editItemEigenschaften, setEditItemEigenschaften] = useState>({}); const [editItemEigenschaftValues, setEditItemEigenschaftValues] = useState>>({}); @@ -105,12 +331,19 @@ export default function AusruestungsanfrageDetail() { const statusMut = useMutation({ mutationFn: ({ status, notes }: { status: string; notes?: string }) => ausruestungsanfrageApi.updateRequestStatus(requestId, status, notes), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Status aktualisiert'); setActionDialog(null); setAdminNotizen(''); setStatusChangeValue(''); + // Auto-open assignment dialog when status changes to 'erledigt' and unassigned positions exist + if (variables.status === 'erledigt' && detail) { + const unassigned = getUnassignedPositions(detail.positionen); + if (unassigned.length > 0) { + setAssignmentOpen(true); + } + } }, onError: () => showError('Fehler beim Aktualisieren'), }); @@ -506,6 +739,15 @@ export default function AusruestungsanfrageDetail() { Bestellungen erstellen )} + {showAdminActions && anfrage && anfrage.status === 'erledigt' && detail && getUnassignedPositions(detail.positionen).length > 0 && ( + + )} {canEdit && !editing && (