diff --git a/backend/src/controllers/checklist.controller.ts b/backend/src/controllers/checklist.controller.ts index 8fc8558..63aa386 100644 --- a/backend/src/controllers/checklist.controller.ts +++ b/backend/src/controllers/checklist.controller.ts @@ -21,13 +21,7 @@ class ChecklistController { async getVorlagen(req: Request, res: Response): Promise { try { - const filter: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean } = {}; - if (req.query.fahrzeug_typ_id) { - filter.fahrzeug_typ_id = parseInt(req.query.fahrzeug_typ_id as string, 10); - } - if (req.query.ausruestung_typ_id) { - filter.ausruestung_typ_id = parseInt(req.query.ausruestung_typ_id as string, 10); - } + const filter: { aktiv?: boolean } = {}; if (req.query.aktiv !== undefined) { filter.aktiv = req.query.aktiv === 'true'; } diff --git a/backend/src/database/migrations/074_checklist_multi_type.sql b/backend/src/database/migrations/074_checklist_multi_type.sql new file mode 100644 index 0000000..48c0c87 --- /dev/null +++ b/backend/src/database/migrations/074_checklist_multi_type.sql @@ -0,0 +1,70 @@ +-- Migration 074: Checklist multi-type assignment (junction tables) +-- Replaces single FK columns with M:N junction tables + +-- 1. Create junction tables +CREATE TABLE IF NOT EXISTS checklist_vorlage_fahrzeug_typen ( + vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + fahrzeug_typ_id INTEGER NOT NULL REFERENCES fahrzeug_typen(id) ON DELETE CASCADE, + PRIMARY KEY (vorlage_id, fahrzeug_typ_id) +); + +CREATE TABLE IF NOT EXISTS checklist_vorlage_fahrzeuge ( + vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + PRIMARY KEY (vorlage_id, fahrzeug_id) +); + +CREATE TABLE IF NOT EXISTS checklist_vorlage_ausruestung_typen ( + vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + ausruestung_typ_id INTEGER NOT NULL REFERENCES ausruestung_typen(id) ON DELETE CASCADE, + PRIMARY KEY (vorlage_id, ausruestung_typ_id) +); + +CREATE TABLE IF NOT EXISTS checklist_vorlage_ausruestung ( + vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE, + ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE, + PRIMARY KEY (vorlage_id, ausruestung_id) +); + +-- 2. Migrate existing single-FK data into junction tables +-- (only if the old columns exist — safe with DO $$ blocks) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_typ_id') THEN + INSERT INTO checklist_vorlage_fahrzeug_typen (vorlage_id, fahrzeug_typ_id) + SELECT id, fahrzeug_typ_id FROM checklist_vorlagen WHERE fahrzeug_typ_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_id') THEN + INSERT INTO checklist_vorlage_fahrzeuge (vorlage_id, fahrzeug_id) + SELECT id, fahrzeug_id FROM checklist_vorlagen WHERE fahrzeug_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_typ_id') THEN + INSERT INTO checklist_vorlage_ausruestung_typen (vorlage_id, ausruestung_typ_id) + SELECT id, ausruestung_typ_id FROM checklist_vorlagen WHERE ausruestung_typ_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_id') THEN + INSERT INTO checklist_vorlage_ausruestung (vorlage_id, ausruestung_id) + SELECT id, ausruestung_id FROM checklist_vorlagen WHERE ausruestung_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; +END $$; + +-- 3. Drop old FK columns (use DO $$ for safety) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_typ_id') THEN + ALTER TABLE checklist_vorlagen DROP COLUMN fahrzeug_typ_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_id') THEN + ALTER TABLE checklist_vorlagen DROP COLUMN fahrzeug_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_typ_id') THEN + ALTER TABLE checklist_vorlagen DROP COLUMN ausruestung_typ_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_id') THEN + ALTER TABLE checklist_vorlagen DROP COLUMN ausruestung_id; + END IF; +END $$; diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 51c5519..e94e64d 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -225,7 +225,7 @@ const cleanupBodySchema = z.object({ confirm: z.boolean().optional().default(false), }); -type CleanupTarget = 'notifications' | 'audit-log' | 'events' | 'bookings' | 'orders' | 'vehicle-history' | 'equipment-history'; +type CleanupTarget = 'notifications' | 'audit-log' | 'events' | 'bookings' | 'orders' | 'vehicle-history' | 'equipment-history' | 'checklist-history'; const CLEANUP_TARGETS: Record Promise<{ count: number; deleted: boolean }>> = { 'notifications': (d, c) => cleanupService.cleanupNotifications(d, c), @@ -235,11 +235,13 @@ const CLEANUP_TARGETS: Record 'orders': (d, c) => cleanupService.cleanupOrders(d, c), 'vehicle-history': (d, c) => cleanupService.cleanupVehicleHistory(d, c), 'equipment-history': (d, c) => cleanupService.cleanupEquipmentHistory(d, c), + 'checklist-history': (d, c) => cleanupService.cleanupChecklistHistory(d, c), }; router.delete('/cleanup/reset-bestellungen', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'reset-bestellungen'; return resetHandler(req, res); }); router.delete('/cleanup/reset-ausruestung-anfragen', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'reset-ausruestung-anfragen'; return resetHandler(req, res); }); router.delete('/cleanup/reset-issues', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'reset-issues'; return resetHandler(req, res); }); +router.delete('/cleanup/reset-checklist-history', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'reset-checklist-history'; return resetHandler(req, res); }); router.delete('/cleanup/issues-all', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'issues-all'; return resetHandler(req, res); }); router.delete( @@ -279,6 +281,7 @@ const RESET_TARGETS: Record Promise<{ count: numbe 'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c), 'reset-issues': (c) => cleanupService.resetIssuesSequence(c), 'issues-all': (c) => cleanupService.resetIssuesSequence(c), + 'reset-checklist-history': (c) => cleanupService.resetChecklistHistory(c), }; const resetHandler = async (req: Request, res: Response): Promise => { diff --git a/backend/src/services/checklist.service.ts b/backend/src/services/checklist.service.ts index 4e129a6..dd8044a 100644 --- a/backend/src/services/checklist.service.ts +++ b/backend/src/services/checklist.service.ts @@ -32,26 +32,85 @@ function calculateNextDueDate(intervall: string | null, intervall_tage: number | } } +// Subquery fragments for junction table arrays +const JUNCTION_SUBQUERIES = ` + ARRAY(SELECT fahrzeug_typ_id FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) AS fahrzeug_typ_ids, + ARRAY(SELECT fahrzeug_id::text FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id) AS fahrzeug_ids, + ARRAY(SELECT ausruestung_typ_id FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id) AS ausruestung_typ_ids, + ARRAY(SELECT ausruestung_id::text FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id) AS ausruestung_ids, + ARRAY(SELECT ft.name FROM checklist_vorlage_fahrzeug_typen cvft JOIN fahrzeug_typen ft ON ft.id = cvft.fahrzeug_typ_id WHERE cvft.vorlage_id = v.id) AS fahrzeug_typ_names, + ARRAY(SELECT f.bezeichnung FROM checklist_vorlage_fahrzeuge cvf JOIN fahrzeuge f ON f.id = cvf.fahrzeug_id WHERE cvf.vorlage_id = v.id) AS fahrzeug_names, + ARRAY(SELECT at2.name FROM checklist_vorlage_ausruestung_typen cvat JOIN ausruestung_typen at2 ON at2.id = cvat.ausruestung_typ_id WHERE cvat.vorlage_id = v.id) AS ausruestung_typ_names, + ARRAY(SELECT a.bezeichnung FROM checklist_vorlage_ausruestung cva JOIN ausruestung a ON a.id = cva.ausruestung_id WHERE cva.vorlage_id = v.id) AS ausruestung_names +`; + +// Helper: insert rows into junction tables within a transaction client +async function insertJunctionRows(client: any, vorlageId: number, data: { + fahrzeug_typ_ids?: number[]; + fahrzeug_ids?: string[]; + ausruestung_typ_ids?: number[]; + ausruestung_ids?: string[]; +}) { + if (data.fahrzeug_typ_ids?.length) { + for (const id of data.fahrzeug_typ_ids) { + await client.query( + `INSERT INTO checklist_vorlage_fahrzeug_typen (vorlage_id, fahrzeug_typ_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [vorlageId, id] + ); + } + } + if (data.fahrzeug_ids?.length) { + for (const id of data.fahrzeug_ids) { + await client.query( + `INSERT INTO checklist_vorlage_fahrzeuge (vorlage_id, fahrzeug_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [vorlageId, id] + ); + } + } + if (data.ausruestung_typ_ids?.length) { + for (const id of data.ausruestung_typ_ids) { + await client.query( + `INSERT INTO checklist_vorlage_ausruestung_typen (vorlage_id, ausruestung_typ_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [vorlageId, id] + ); + } + } + if (data.ausruestung_ids?.length) { + for (const id of data.ausruestung_ids) { + await client.query( + `INSERT INTO checklist_vorlage_ausruestung (vorlage_id, ausruestung_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [vorlageId, id] + ); + } + } +} + +// Helper: delete all junction rows for a vorlage +async function deleteJunctionRows(client: any, vorlageId: number) { + await client.query(`DELETE FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = $1`, [vorlageId]); + await client.query(`DELETE FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = $1`, [vorlageId]); + await client.query(`DELETE FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = $1`, [vorlageId]); + await client.query(`DELETE FROM checklist_vorlage_ausruestung WHERE vorlage_id = $1`, [vorlageId]); +} + +// "Global" template condition: no rows in any junction table +const GLOBAL_TEMPLATE_CONDITION = ` + NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id) +`; + // --------------------------------------------------------------------------- // Vorlagen (Templates) // --------------------------------------------------------------------------- -async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) { +async function getVorlagen(filter?: { aktiv?: boolean }) { try { const conditions: string[] = []; const values: any[] = []; let idx = 1; - if (filter?.fahrzeug_typ_id !== undefined) { - conditions.push(`v.fahrzeug_typ_id = $${idx}`); - values.push(filter.fahrzeug_typ_id); - idx++; - } - if (filter?.ausruestung_typ_id !== undefined) { - conditions.push(`v.ausruestung_typ_id = $${idx}`); - values.push(filter.ausruestung_typ_id); - idx++; - } if (filter?.aktiv !== undefined) { conditions.push(`v.aktiv = $${idx}`); values.push(filter.aktiv); @@ -61,11 +120,8 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT v.*, - ft.name AS fahrzeug_typ_name, - at.name AS ausruestung_typ_name + ${JUNCTION_SUBQUERIES} FROM checklist_vorlagen v - LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id - LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id ${where} ORDER BY v.name ASC`, values @@ -81,11 +137,8 @@ async function getVorlageById(id: number) { try { const vorlageResult = await pool.query( `SELECT v.*, - ft.name AS fahrzeug_typ_name, - at.name AS ausruestung_typ_name + ${JUNCTION_SUBQUERIES} FROM checklist_vorlagen v - LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id - LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id WHERE v.id = $1`, [id] ); @@ -106,74 +159,93 @@ async function getVorlageById(id: number) { async function createVorlage(data: { name: string; - fahrzeug_typ_id?: number | null; - fahrzeug_id?: string | null; - ausruestung_typ_id?: number | null; - ausruestung_id?: string | null; + fahrzeug_typ_ids?: number[]; + fahrzeug_ids?: string[]; + ausruestung_typ_ids?: number[]; + ausruestung_ids?: string[]; intervall?: string | null; intervall_tage?: number | null; beschreibung?: string | null; }) { + const client = await pool.connect(); try { - const result = await pool.query( - `INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + await client.query('BEGIN'); + const result = await client.query( + `INSERT INTO checklist_vorlagen (name, intervall, intervall_tage, beschreibung) + VALUES ($1, $2, $3, $4) RETURNING *`, - [ - data.name, - data.fahrzeug_typ_id ?? null, - data.fahrzeug_id ?? null, - data.ausruestung_typ_id ?? null, - data.ausruestung_id ?? null, - data.intervall ?? null, - data.intervall_tage ?? null, - data.beschreibung ?? null, - ] + [data.name, data.intervall ?? null, data.intervall_tage ?? null, data.beschreibung ?? null] ); - return result.rows[0]; + const vorlage = result.rows[0]; + + await insertJunctionRows(client, vorlage.id, data); + + await client.query('COMMIT'); + return getVorlageById(vorlage.id); } catch (error) { + await client.query('ROLLBACK').catch(() => {}); logger.error('ChecklistService.createVorlage failed', { error }); throw new Error('Vorlage konnte nicht erstellt werden'); + } finally { + client.release(); } } async function updateVorlage(id: number, data: { name?: string; - fahrzeug_typ_id?: number | null; - fahrzeug_id?: string | null; - ausruestung_typ_id?: number | null; - ausruestung_id?: string | null; + fahrzeug_typ_ids?: number[]; + fahrzeug_ids?: string[]; + ausruestung_typ_ids?: number[]; + ausruestung_ids?: string[]; intervall?: string | null; intervall_tage?: number | null; beschreibung?: string | null; aktiv?: boolean; }) { + const client = await pool.connect(); try { + await client.query('BEGIN'); + + // Build SET clauses for scalar fields only const setClauses: string[] = []; const values: any[] = []; let idx = 1; if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; } - if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; } - if ('fahrzeug_id' in data) { setClauses.push(`fahrzeug_id = $${idx}`); values.push(data.fahrzeug_id); idx++; } - if ('ausruestung_typ_id' in data) { setClauses.push(`ausruestung_typ_id = $${idx}`); values.push(data.ausruestung_typ_id); idx++; } - if ('ausruestung_id' in data) { setClauses.push(`ausruestung_id = $${idx}`); values.push(data.ausruestung_id); idx++; } if ('intervall' in data) { setClauses.push(`intervall = $${idx}`); values.push(data.intervall); idx++; } if ('intervall_tage' in data) { setClauses.push(`intervall_tage = $${idx}`); values.push(data.intervall_tage); idx++; } if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; } if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; } - if (setClauses.length === 0) return getVorlageById(id); + if (setClauses.length > 0) { + values.push(id); + await client.query( + `UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx}`, + values + ); + } - values.push(id); - const result = await pool.query( - `UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, - values - ); - return result.rows[0] || null; + // Replace junction rows if any array key is present + const hasJunctionData = 'fahrzeug_typ_ids' in data || 'fahrzeug_ids' in data + || 'ausruestung_typ_ids' in data || 'ausruestung_ids' in data; + if (hasJunctionData) { + await deleteJunctionRows(client, id); + await insertJunctionRows(client, id, { + fahrzeug_typ_ids: data.fahrzeug_typ_ids ?? [], + fahrzeug_ids: data.fahrzeug_ids ?? [], + ausruestung_typ_ids: data.ausruestung_typ_ids ?? [], + ausruestung_ids: data.ausruestung_ids ?? [], + }); + } + + await client.query('COMMIT'); + return getVorlageById(id); } catch (error) { + await client.query('ROLLBACK').catch(() => {}); logger.error('ChecklistService.updateVorlage failed', { error, id }); throw new Error('Vorlage konnte nicht aktualisiert werden'); + } finally { + client.release(); } } @@ -445,25 +517,31 @@ async function deleteEquipmentItem(id: number) { } // --------------------------------------------------------------------------- -// Templates for a specific vehicle (via type junction) +// Templates for a specific vehicle (via junction tables) // --------------------------------------------------------------------------- async function getTemplatesForVehicle(fahrzeugId: string) { try { - // Templates that match the vehicle directly, by type, or global (no assignment) const result = await pool.query( - `SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name + `SELECT DISTINCT v.*, + ${JUNCTION_SUBQUERIES} FROM checklist_vorlagen v - LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id WHERE v.aktiv = true - AND v.ausruestung_id IS NULL - AND v.ausruestung_typ_id IS NULL + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id) AND ( - v.fahrzeug_id = $1 - OR v.fahrzeug_typ_id IN ( - SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1 + EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge cvf WHERE cvf.vorlage_id = v.id AND cvf.fahrzeug_id = $1) + OR EXISTS ( + SELECT 1 FROM checklist_vorlage_fahrzeug_typen cvft + WHERE cvft.vorlage_id = v.id + AND cvft.fahrzeug_typ_id IN (SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1) + ) + OR ( + NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id) ) - OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL) ) ORDER BY v.name ASC`, [fahrzeugId] @@ -486,22 +564,26 @@ async function getTemplatesForVehicle(fahrzeugId: string) { } // --------------------------------------------------------------------------- -// Templates for a specific equipment item (via type junction) +// Templates for a specific equipment item (via junction tables) // --------------------------------------------------------------------------- async function getTemplatesForEquipment(ausruestungId: string) { try { const result = await pool.query( - `SELECT DISTINCT v.*, at.name AS ausruestung_typ_name + `SELECT DISTINCT v.*, + ${JUNCTION_SUBQUERIES} FROM checklist_vorlagen v - LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id WHERE v.aktiv = true AND ( - v.ausruestung_id = $1 - OR v.ausruestung_typ_id IN ( - SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1 + EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = v.id AND cva.ausruestung_id = $1) + OR EXISTS ( + SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat + WHERE cvat.vorlage_id = v.id + AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1) + ) + OR ( + ${GLOBAL_TEMPLATE_CONDITION} ) - OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL AND v.ausruestung_typ_id IS NULL AND v.ausruestung_id IS NULL) ) ORDER BY v.name ASC`, [ausruestungId] @@ -530,7 +612,6 @@ async function getTemplatesForEquipment(ausruestungId: string) { async function getOverviewItems() { try { // All vehicles with their assigned templates (direct, by type, or global) - // LEFT JOIN faelligkeit so unexecuted templates still appear const vehiclesResult = await pool.query(` SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name, json_agg(DISTINCT jsonb_build_object( @@ -541,14 +622,21 @@ async function getOverviewItems() { )) AS checklists FROM fahrzeuge f JOIN checklist_vorlagen cv ON cv.aktiv = true - AND cv.ausruestung_id IS NULL - AND cv.ausruestung_typ_id IS NULL + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id) AND ( - cv.fahrzeug_id = f.id - OR cv.fahrzeug_typ_id IN ( - SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = f.id + EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge cvf WHERE cvf.vorlage_id = cv.id AND cvf.fahrzeug_id = f.id) + OR EXISTS ( + SELECT 1 FROM checklist_vorlage_fahrzeug_typen cvft + WHERE cvft.vorlage_id = cv.id + AND cvft.fahrzeug_typ_id IN (SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = f.id) + ) + OR ( + NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id) ) - OR (cv.fahrzeug_id IS NULL AND cv.fahrzeug_typ_id IS NULL) ) LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.fahrzeug_id = f.id WHERE f.deleted_at IS NULL @@ -567,14 +655,19 @@ async function getOverviewItems() { )) AS checklists FROM ausruestung a JOIN checklist_vorlagen cv ON cv.aktiv = true - AND cv.fahrzeug_id IS NULL - AND cv.fahrzeug_typ_id IS NULL AND ( - cv.ausruestung_id = a.id - OR cv.ausruestung_typ_id IN ( - SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id + EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id) + OR EXISTS ( + SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat + WHERE cvat.vorlage_id = cv.id + AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id) + ) + OR ( + NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id) ) - OR (cv.ausruestung_id IS NULL AND cv.ausruestung_typ_id IS NULL) ) LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id WHERE a.deleted_at IS NULL diff --git a/backend/src/services/cleanup.service.ts b/backend/src/services/cleanup.service.ts index bf849ba..cab2cad 100644 --- a/backend/src/services/cleanup.service.ts +++ b/backend/src/services/cleanup.service.ts @@ -157,6 +157,34 @@ class CleanupService { return { count, deleted: true }; } + async cleanupChecklistHistory(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM checklist_ausfuehrungen WHERE ausgefuehrt_am < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM checklist_ausfuehrungen WHERE ausgefuehrt_am < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} checklist executions older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async resetChecklistHistory(confirm: boolean): Promise { + if (!confirm) { + const { rows } = await pool.query(`SELECT COUNT(*)::int AS count FROM checklist_ausfuehrungen`); + return { count: rows[0].count, deleted: false }; + } + await pool.query(`TRUNCATE checklist_ausfuehrungen CASCADE`); + await pool.query(`TRUNCATE checklist_faelligkeit CASCADE`); + logger.info('Cleanup: reset all checklist history'); + return { count: 0, deleted: true }; + } + async resetIssuesSequence(confirm: boolean): Promise { if (!confirm) { const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues'); diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index d93020e..dce4f49 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -24,6 +24,7 @@ const SECTIONS: CleanupSection[] = [ { key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 }, { key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 }, { key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 }, + { key: 'checklist-history', label: 'Checklisten-Historie', description: 'Alte Checklisten-Ausfuehrungen entfernen.', defaultDays: 365 }, ]; interface ResetSection { @@ -36,6 +37,7 @@ const RESET_SECTIONS: ResetSection[] = [ { key: 'reset-bestellungen', label: 'Bestellungen zuruecksetzen', description: 'Alle Bestellungen, Positionen, Dateien, Erinnerungen und Historie loeschen und Nummern zuruecksetzen.' }, { key: 'reset-ausruestung-anfragen', label: 'Interne Bestellungen zuruecksetzen', description: 'Alle internen Bestellungen und zugehoerige Positionen loeschen und Nummern zuruecksetzen.' }, { key: 'reset-issues', label: 'Issues zuruecksetzen', description: 'Alle Issues und Kommentare loeschen und Nummern zuruecksetzen.' }, + { key: 'reset-checklist-history', label: 'Checklisten-Historie zuruecksetzen', description: 'Alle Checklisten-Ausfuehrungen und Faelligkeiten loeschen und Nummern zuruecksetzen.' }, ]; interface SectionState { diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 162ae40..5b772a0 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -193,12 +193,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const vehicleSubItems: SubItem[] = useMemo( () => { - const items: SubItem[] = (vehicleList ?? []).map((v) => ({ - text: v.bezeichnung ?? v.kurzname, - path: `/fahrzeuge/${v.id}`, - })); + const items: SubItem[] = [ + { text: 'Übersicht', path: '/fahrzeuge?tab=0' }, + ]; + (vehicleList ?? []).forEach((v) => { + items.push({ + text: v.bezeichnung ?? v.kurzname, + path: `/fahrzeuge/${v.id}`, + }); + }); if (hasPermission('fahrzeuge:edit')) { - items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' }); + items.push({ text: 'Einstellungen', path: '/fahrzeuge?tab=1' }); } return items; }, @@ -246,9 +251,11 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { .map((item) => { if (item.path === '/fahrzeuge') return fahrzeugeItem; if (item.path === '/ausruestung') { - const ausruestungSubs: SubItem[] = []; + const ausruestungSubs: SubItem[] = [ + { text: 'Übersicht', path: '/ausruestung?tab=0' }, + ]; if (hasPermission('ausruestung:manage_types')) { - ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung/einstellungen' }); + ausruestungSubs.push({ text: 'Einstellungen', path: '/ausruestung?tab=1' }); } return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item; } diff --git a/frontend/src/pages/Ausruestung.tsx b/frontend/src/pages/Ausruestung.tsx index 3697b39..51879d6 100644 --- a/frontend/src/pages/Ausruestung.tsx +++ b/frontend/src/pages/Ausruestung.tsx @@ -9,6 +9,11 @@ import { Chip, CircularProgress, Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, FormControl, FormControlLabel, Grid, @@ -16,30 +21,43 @@ import { InputAdornment, InputLabel, MenuItem, + Paper, Select, Switch, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, TextField, Tooltip, Typography, } from '@mui/material'; import { Add, + Add as AddIcon, Build, CheckCircle, + Close, + Delete, + Edit, Error as ErrorIcon, LinkRounded, PauseCircle, RemoveCircle, + Save, Search, - Settings, Star, Warning, } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { equipmentApi } from '../services/equipment'; -import { ausruestungTypenApi } from '../services/ausruestungTypen'; +import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen'; import { AusruestungListItem, AusruestungKategorie, @@ -48,6 +66,7 @@ import { EquipmentStats, } from '../types/equipment.types'; import { usePermissions } from '../hooks/usePermissions'; +import { useNotification } from '../contexts/NotificationContext'; import ChatAwareFab from '../components/shared/ChatAwareFab'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -233,10 +252,248 @@ const EquipmentCard: React.FC = ({ item, onClick }) => { ); }; +// ── Ausrüstungstypen-Verwaltung (Einstellungen Tab) ────────────────────────── + +function AusruestungTypenSettings() { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const { data: typen = [], isLoading, isError } = useQuery({ + queryKey: ['ausruestungTypen'], + queryFn: ausruestungTypenApi.getAll, + }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingTyp, setEditingTyp] = useState(null); + const [formName, setFormName] = useState(''); + const [formBeschreibung, setFormBeschreibung] = useState(''); + const [formIcon, setFormIcon] = useState(''); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingTyp, setDeletingTyp] = useState(null); + + const createMutation = useMutation({ + mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) => + ausruestungTypenApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); + showSuccess('Typ erstellt'); + closeDialog(); + }, + onError: () => showError('Typ konnte nicht erstellt werden'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) => + ausruestungTypenApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); + showSuccess('Typ aktualisiert'); + closeDialog(); + }, + onError: () => showError('Typ konnte nicht aktualisiert werden'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => ausruestungTypenApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); + showSuccess('Typ gelöscht'); + setDeleteDialogOpen(false); + setDeletingTyp(null); + }, + onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'), + }); + + const openAddDialog = () => { + setEditingTyp(null); + setFormName(''); + setFormBeschreibung(''); + setFormIcon(''); + setDialogOpen(true); + }; + + const openEditDialog = (typ: AusruestungTyp) => { + setEditingTyp(typ); + setFormName(typ.name); + setFormBeschreibung(typ.beschreibung ?? ''); + setFormIcon(typ.icon ?? ''); + setDialogOpen(true); + }; + + const closeDialog = () => { + setDialogOpen(false); + setEditingTyp(null); + }; + + const handleSave = () => { + if (!formName.trim()) return; + const data = { + name: formName.trim(), + beschreibung: formBeschreibung.trim() || undefined, + icon: formIcon.trim() || undefined, + }; + if (editingTyp) { + updateMutation.mutate({ id: editingTyp.id, data }); + } else { + createMutation.mutate(data); + } + }; + + const openDeleteDialog = (typ: AusruestungTyp) => { + setDeletingTyp(typ); + setDeleteDialogOpen(true); + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( + + Ausrüstungstypen + + {isLoading && ( + + + + )} + + {isError && ( + + Typen konnten nicht geladen werden. + + )} + + {!isLoading && !isError && ( + + + + + + Name + Beschreibung + Icon + Aktionen + + + + {typen.length === 0 && ( + + + + Noch keine Typen vorhanden. + + + + )} + {typen.map((typ) => ( + + {typ.name} + {typ.beschreibung || '---'} + {typ.icon || '---'} + + + openEditDialog(typ)}> + + + + + openDeleteDialog(typ)}> + + + + + + ))} + +
+
+ + + +
+ )} + + {/* Add/Edit dialog */} + + + {editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'} + + + + setFormName(e.target.value)} + sx={{ mt: 1, mb: 2 }} + inputProps={{ maxLength: 100 }} + /> + setFormBeschreibung(e.target.value)} + sx={{ mb: 2 }} + /> + setFormIcon(e.target.value)} + placeholder="z.B. Build, LocalFireDepartment" + /> + + + + + + + + {/* Delete confirmation dialog */} + !deleteMutation.isPending && setDeleteDialogOpen(false)}> + Typ löschen + + + Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? + Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung. + + + + + + + +
+ ); +} + // ── Main Page ───────────────────────────────────────────────────────────────── function Ausruestung() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = parseInt(searchParams.get('tab') ?? '0', 10); const { canManageEquipment, hasPermission } = usePermissions(); const canManageTypes = hasPermission('ausruestung:manage_types'); @@ -285,7 +542,6 @@ function Ausruestung() { // Client-side filtering const filtered = useMemo(() => { return equipment.filter((item) => { - // Text search if (search.trim()) { const q = search.toLowerCase(); const matches = @@ -295,37 +551,16 @@ function Ausruestung() { (item.hersteller?.toLowerCase().includes(q) ?? false); if (!matches) return false; } - - // Category filter - if (selectedCategory && item.kategorie_id !== selectedCategory) { - return false; - } - - // Type filter + if (selectedCategory && item.kategorie_id !== selectedCategory) return false; if (selectedTyp) { const typId = parseInt(selectedTyp, 10); - if (!item.typen?.some((t) => t.id === typId)) { - return false; - } + if (!item.typen?.some((t) => t.id === typId)) return false; } - - // Status filter - if (selectedStatus && item.status !== selectedStatus) { - return false; - } - - // Nur wichtige - if (nurWichtige && !item.ist_wichtig) { - return false; - } - - // Prüfung fällig (within 30 days or overdue) + if (selectedStatus && item.status !== selectedStatus) return false; + if (nurWichtige && !item.ist_wichtig) return false; if (pruefungFaellig) { - if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) { - return false; - } + if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) return false; } - return true; }); }, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]); @@ -338,19 +573,12 @@ function Ausruestung() { {/* Header */} - + Ausrüstungsverwaltung - {canManageTypes && ( - - navigate('/ausruestung/einstellungen')} size="small"> - - - - )} {!loading && stats && ( @@ -374,158 +602,175 @@ function Ausruestung() { - {/* Overdue alert */} - {hasOverdue && ( - }> - Achtung: Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist. - - )} + setSearchParams({ tab: String(v) })} + sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} + > + + {canManageTypes && } + - {/* Filter controls */} - - setSearch(e.target.value)} - size="small" - sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> + {tab === 0 && ( + <> + {/* Overdue alert */} + {hasOverdue && ( + }> + Achtung: Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist. + + )} - - Kategorie - - - - - Typ - - - - - Status - - - - setNurWichtige(e.target.checked)} + {/* Filter controls */} + + setSearch(e.target.value)} size="small" + sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - } - label={Nur wichtige} - /> - setPruefungFaellig(e.target.checked)} - size="small" + + Kategorie + + + + + Typ + + + + + Status + + + + setNurWichtige(e.target.checked)} + size="small" + /> + } + label={Nur wichtige} /> - } - label={Prüfung fällig} - /> - - {/* Loading state */} - {loading && ( - - - - )} + setPruefungFaellig(e.target.checked)} + size="small" + /> + } + label={Prüfung fällig} + /> + - {/* Error state */} - {!loading && error && ( - - Erneut versuchen - - } - > - {error} - - )} + {/* Loading state */} + {loading && ( + + + + )} - {/* Empty states */} - {!loading && !error && filtered.length === 0 && ( - - - - {equipment.length === 0 - ? 'Keine Ausrüstung vorhanden' - : 'Keine Ausrüstung gefunden'} - - - )} + {/* Error state */} + {!loading && error && ( + + Erneut versuchen + + } + > + {error} + + )} - {/* Equipment grid */} - {!loading && !error && filtered.length > 0 && ( - - {filtered.map((item) => ( - - navigate(`/ausruestung/${id}`)} - /> + {/* Empty states */} + {!loading && !error && filtered.length === 0 && ( + + + + {equipment.length === 0 + ? 'Keine Ausrüstung vorhanden' + : 'Keine Ausrüstung gefunden'} + + + )} + + {/* Equipment grid */} + {!loading && !error && filtered.length > 0 && ( + + {filtered.map((item) => ( + + navigate(`/ausruestung/${id}`)} + /> + + ))} - ))} - + )} + + {/* FAB for adding new equipment */} + {canManageEquipment && ( + navigate('/ausruestung/neu')} + > + + + )} + )} - {/* FAB for adding new equipment */} - {canManageEquipment && ( - navigate('/ausruestung/neu')} - > - - + {tab === 1 && canManageTypes && ( + )} diff --git a/frontend/src/pages/AusruestungEinstellungen.tsx b/frontend/src/pages/AusruestungEinstellungen.tsx index 520cf3f..097c93d 100644 --- a/frontend/src/pages/AusruestungEinstellungen.tsx +++ b/frontend/src/pages/AusruestungEinstellungen.tsx @@ -1,434 +1,4 @@ -import { useState } from 'react'; -import { - Alert, - Autocomplete, - Box, - Button, - Chip, - CircularProgress, - Container, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Divider, - IconButton, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, -} from '@mui/material'; -import { Add, ArrowBack, Delete, Edit, Save, Close } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen'; -import { equipmentApi } from '../services/equipment'; -import { usePermissions } from '../hooks/usePermissions'; -import { useNotification } from '../contexts/NotificationContext'; - -function AusruestungEinstellungen() { - const navigate = useNavigate(); - const { hasPermission } = usePermissions(); - const { showSuccess, showError } = useNotification(); - const queryClient = useQueryClient(); - - const canManageTypes = hasPermission('ausruestung:manage_types'); - - // Data - const { data: typen = [], isLoading, isError } = useQuery({ - queryKey: ['ausruestungTypen'], - queryFn: ausruestungTypenApi.getAll, - }); - - // Dialog state - const [dialogOpen, setDialogOpen] = useState(false); - const [editingTyp, setEditingTyp] = useState(null); - const [formName, setFormName] = useState(''); - const [formBeschreibung, setFormBeschreibung] = useState(''); - const [formIcon, setFormIcon] = useState(''); - - // Delete dialog state - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deletingTyp, setDeletingTyp] = useState(null); - - // Mutations - const createMutation = useMutation({ - mutationFn: (data: { name: string; beschreibung?: string; icon?: string }) => - ausruestungTypenApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); - showSuccess('Typ erstellt'); - closeDialog(); - }, - onError: () => showError('Typ konnte nicht erstellt werden'), - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; beschreibung: string; icon: string }> }) => - ausruestungTypenApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); - showSuccess('Typ aktualisiert'); - closeDialog(); - }, - onError: () => showError('Typ konnte nicht aktualisiert werden'), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: number) => ausruestungTypenApi.delete(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungTypen'] }); - showSuccess('Typ gelöscht'); - setDeleteDialogOpen(false); - setDeletingTyp(null); - }, - onError: () => showError('Typ konnte nicht gelöscht werden. Möglicherweise ist er noch Geräten zugeordnet.'), - }); - - const openAddDialog = () => { - setEditingTyp(null); - setFormName(''); - setFormBeschreibung(''); - setFormIcon(''); - setDialogOpen(true); - }; - - const openEditDialog = (typ: AusruestungTyp) => { - setEditingTyp(typ); - setFormName(typ.name); - setFormBeschreibung(typ.beschreibung ?? ''); - setFormIcon(typ.icon ?? ''); - setDialogOpen(true); - }; - - const closeDialog = () => { - setDialogOpen(false); - setEditingTyp(null); - }; - - const handleSave = () => { - if (!formName.trim()) return; - const data = { - name: formName.trim(), - beschreibung: formBeschreibung.trim() || undefined, - icon: formIcon.trim() || undefined, - }; - if (editingTyp) { - updateMutation.mutate({ id: editingTyp.id, data }); - } else { - createMutation.mutate(data); - } - }; - - const openDeleteDialog = (typ: AusruestungTyp) => { - setDeletingTyp(typ); - setDeleteDialogOpen(true); - }; - - const isSaving = createMutation.isPending || updateMutation.isPending; - - // Permission guard - if (!canManageTypes) { - return ( - - - - Keine Berechtigung - - Sie haben nicht die erforderlichen Rechte, um Ausrüstungstypen zu verwalten. - - - - - - ); - } - - return ( - - - - - - Ausrüstungs-Einstellungen - - - {/* Ausrüstungstypen Section */} - Ausrüstungstypen - - {isLoading && ( - - - - )} - - {isError && ( - - Typen konnten nicht geladen werden. - - )} - - {!isLoading && !isError && ( - - - - - - Name - Beschreibung - Icon - Aktionen - - - - {typen.length === 0 && ( - - - - Noch keine Typen vorhanden. - - - - )} - {typen.map((typ) => ( - - {typ.name} - {typ.beschreibung || '---'} - {typ.icon || '---'} - - - openEditDialog(typ)}> - - - - - openDeleteDialog(typ)}> - - - - - - ))} - -
-
- - - -
- )} - - {/* Add/Edit dialog */} - - - {editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'} - - - - setFormName(e.target.value)} - sx={{ mt: 1, mb: 2 }} - inputProps={{ maxLength: 100 }} - /> - setFormBeschreibung(e.target.value)} - sx={{ mb: 2 }} - /> - setFormIcon(e.target.value)} - placeholder="z.B. Build, LocalFireDepartment" - /> - - - - - - - - {/* Delete confirmation dialog */} - !deleteMutation.isPending && setDeleteDialogOpen(false)}> - Typ löschen - - - Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? - Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung. - - - - - - - - - - -
-
- ); -} - -export default AusruestungEinstellungen; - -// ── Per-equipment type assignment ────────────────────────────────────────────── - -function EquipmentTypeAssignment({ allTypes }: { allTypes: AusruestungTyp[] }) { - const { showSuccess, showError } = useNotification(); - const { data: equipment = [], isLoading } = useQuery({ - queryKey: ['equipment'], - queryFn: equipmentApi.getAll, - }); - - const [assignDialog, setAssignDialog] = useState<{ equipmentId: string; equipmentName: string } | null>(null); - const [selected, setSelected] = useState([]); - const [saving, setSaving] = useState(false); - const [equipmentTypesMap, setEquipmentTypesMap] = useState>({}); - - const openAssign = async (equipmentId: string, equipmentName: string) => { - let current = equipmentTypesMap[equipmentId]; - if (!current) { - try { current = await ausruestungTypenApi.getTypesForEquipment(equipmentId); } - catch { current = []; } - setEquipmentTypesMap((m) => ({ ...m, [equipmentId]: current })); - } - setSelected(current); - setAssignDialog({ equipmentId, equipmentName }); - }; - - const handleSave = async () => { - if (!assignDialog) return; - try { - setSaving(true); - await ausruestungTypenApi.setTypesForEquipment(assignDialog.equipmentId, selected.map((t) => t.id)); - setEquipmentTypesMap((m) => ({ ...m, [assignDialog.equipmentId]: selected })); - setAssignDialog(null); - showSuccess('Typen gespeichert'); - } catch { - showError('Fehler beim Speichern'); - } finally { - setSaving(false); - } - }; - - return ( - <> - Typzuweisung je Gerät - {isLoading ? ( - - ) : ( - - - - - - Gerät - Zugewiesene Typen - Aktionen - - - - {equipment.map((e) => { - const types = equipmentTypesMap[e.id]; - return ( - - {e.bezeichnung} - - {types === undefined ? ( - - ) : types.length === 0 ? ( - Keine - ) : ( - - {types.map((t) => )} - - )} - - - - openAssign(e.id, e.bezeichnung)}> - - - - - - ); - })} - -
-
-
- )} - - setAssignDialog(null)} maxWidth="sm" fullWidth> - - Typen für {assignDialog?.equipmentName} - setAssignDialog(null)}> - - - o.name} - value={selected} - onChange={(_e, val) => setSelected(val)} - isOptionEqualToValue={(a, b) => a.id === b.id} - renderInput={(params) => } - /> - - - - - - - - ); +import { Navigate } from 'react-router-dom'; +export default function AusruestungEinstellungen() { + return ; } diff --git a/frontend/src/pages/Checklisten.tsx b/frontend/src/pages/Checklisten.tsx index 59f5daf..c843382 100644 --- a/frontend/src/pages/Checklisten.tsx +++ b/frontend/src/pages/Checklisten.tsx @@ -93,18 +93,18 @@ const INTERVALL_LABELS: Record = { type AssignmentType = 'global' | 'fahrzeug_typ' | 'fahrzeug' | 'ausruestung_typ' | 'ausruestung'; function getAssignmentType(v: ChecklistVorlage): AssignmentType { - if (v.fahrzeug_id) return 'fahrzeug'; - if (v.fahrzeug_typ_id) return 'fahrzeug_typ'; - if (v.ausruestung_id) return 'ausruestung'; - if (v.ausruestung_typ_id) return 'ausruestung_typ'; + if (v.fahrzeug_ids?.length) return 'fahrzeug'; + if (v.fahrzeug_typ_ids?.length) return 'fahrzeug_typ'; + if (v.ausruestung_ids?.length) return 'ausruestung'; + if (v.ausruestung_typ_ids?.length) return 'ausruestung_typ'; return 'global'; } function getAssignmentLabel(v: ChecklistVorlage): string { - if (v.fahrzeug_id) return v.fahrzeug_name ? `Fahrzeug: ${v.fahrzeug_name}` : 'Fahrzeug (direkt)'; - if (v.fahrzeug_typ_id) return v.fahrzeug_typ?.name ? `Fahrzeugtyp: ${v.fahrzeug_typ.name}` : 'Fahrzeugtyp'; - if (v.ausruestung_id) return v.ausruestung_name ? `Ausrüstung: ${v.ausruestung_name}` : 'Ausrüstung (direkt)'; - if (v.ausruestung_typ_id) return v.ausruestung_typ ? `Ausrüstungstyp: ${v.ausruestung_typ}` : 'Ausrüstungstyp'; + if (v.fahrzeug_ids?.length) return v.fahrzeug_names?.length ? `Fahrzeug: ${v.fahrzeug_names.join(', ')}` : 'Fahrzeug (direkt)'; + if (v.fahrzeug_typ_ids?.length) return v.fahrzeug_typ_names?.length ? `Fahrzeugtyp: ${v.fahrzeug_typ_names.join(', ')}` : 'Fahrzeugtyp'; + if (v.ausruestung_ids?.length) return v.ausruestung_names?.length ? `Ausrüstung: ${v.ausruestung_names.join(', ')}` : 'Ausrüstung (direkt)'; + if (v.ausruestung_typ_ids?.length) return v.ausruestung_typ_names?.length ? `Ausrüstungstyp: ${v.ausruestung_typ_names.join(', ')}` : 'Ausrüstungstyp'; return 'Global'; } @@ -365,8 +365,8 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces const [assignmentType, setAssignmentType] = useState('global'); const emptyForm: CreateVorlagePayload = { - name: '', fahrzeug_typ_id: undefined, fahrzeug_id: undefined, - ausruestung_typ_id: undefined, ausruestung_id: undefined, + name: '', fahrzeug_typ_ids: [], fahrzeug_ids: [], + ausruestung_typ_ids: [], ausruestung_ids: [], intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true, }; const [form, setForm] = useState(emptyForm); @@ -417,10 +417,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces setAssignmentType(getAssignmentType(v)); setForm({ name: v.name, - fahrzeug_typ_id: v.fahrzeug_typ_id, - fahrzeug_id: v.fahrzeug_id, - ausruestung_typ_id: v.ausruestung_typ_id, - ausruestung_id: v.ausruestung_id, + fahrzeug_typ_ids: v.fahrzeug_typ_ids ?? [], + fahrzeug_ids: v.fahrzeug_ids ?? [], + ausruestung_typ_ids: v.ausruestung_typ_ids ?? [], + ausruestung_ids: v.ausruestung_ids ?? [], intervall: v.intervall, intervall_tage: v.intervall_tage, beschreibung: v.beschreibung ?? '', @@ -438,10 +438,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces aktiv: form.aktiv, }; switch (assignmentType) { - case 'fahrzeug_typ': return { ...base, fahrzeug_typ_id: form.fahrzeug_typ_id }; - case 'fahrzeug': return { ...base, fahrzeug_id: form.fahrzeug_id }; - case 'ausruestung_typ': return { ...base, ausruestung_typ_id: form.ausruestung_typ_id }; - case 'ausruestung': return { ...base, ausruestung_id: form.ausruestung_id }; + case 'fahrzeug_typ': return { ...base, fahrzeug_typ_ids: form.fahrzeug_typ_ids }; + case 'fahrzeug': return { ...base, fahrzeug_ids: form.fahrzeug_ids }; + case 'ausruestung_typ': return { ...base, ausruestung_typ_ids: form.ausruestung_typ_ids }; + case 'ausruestung': return { ...base, ausruestung_ids: form.ausruestung_ids }; default: return base; } }; @@ -460,10 +460,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces setAssignmentType(newType); setForm((f) => ({ ...f, - fahrzeug_typ_id: undefined, - fahrzeug_id: undefined, - ausruestung_typ_id: undefined, - ausruestung_id: undefined, + fahrzeug_typ_ids: [], + fahrzeug_ids: [], + ausruestung_typ_ids: [], + ausruestung_ids: [], })); }; @@ -547,41 +547,46 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces {/* Assignment picker based on type */} {assignmentType === 'fahrzeug_typ' && ( - - Fahrzeugtyp - - + t.name} + value={fahrzeugTypen.filter((t) => form.fahrzeug_typ_ids?.includes(t.id))} + onChange={(_e, vals) => setForm((f) => ({ ...f, fahrzeug_typ_ids: vals.map((v) => v.id) }))} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => } + /> )} {assignmentType === 'fahrzeug' && ( v.bezeichnung ?? v.kurzname ?? String(v.id)} - value={vehiclesList.find((v) => v.id === form.fahrzeug_id) ?? null} - onChange={(_e, v) => setForm((f) => ({ ...f, fahrzeug_id: v?.id }))} - renderInput={(params) => } + value={vehiclesList.filter((v) => form.fahrzeug_ids?.includes(v.id))} + onChange={(_e, vals) => setForm((f) => ({ ...f, fahrzeug_ids: vals.map((v) => v.id) }))} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => } /> )} {assignmentType === 'ausruestung_typ' && ( t.name} - value={ausruestungTypen.find((t: AusruestungTyp) => t.id === form.ausruestung_typ_id) ?? null} - onChange={(_e, t: AusruestungTyp | null) => setForm((f) => ({ ...f, ausruestung_typ_id: t?.id }))} - renderInput={(params) => } + value={ausruestungTypen.filter((t: AusruestungTyp) => form.ausruestung_typ_ids?.includes(t.id))} + onChange={(_e, vals: AusruestungTyp[]) => setForm((f) => ({ ...f, ausruestung_typ_ids: vals.map((v) => v.id) }))} + isOptionEqualToValue={(a: AusruestungTyp, b: AusruestungTyp) => a.id === b.id} + renderInput={(params) => } /> )} {assignmentType === 'ausruestung' && ( eq.bezeichnung ?? String(eq.id)} - value={equipmentList.find((eq) => eq.id === form.ausruestung_id) ?? null} - onChange={(_e, eq) => setForm((f) => ({ ...f, ausruestung_id: eq?.id }))} + value={equipmentList.filter((eq) => form.ausruestung_ids?.includes(eq.id))} + onChange={(_e, vals) => setForm((f) => ({ ...f, ausruestung_ids: vals.map((v) => v.id) }))} + isOptionEqualToValue={(a, b) => a.id === b.id} renderInput={(params) => } /> )} diff --git a/frontend/src/pages/FahrzeugEinstellungen.tsx b/frontend/src/pages/FahrzeugEinstellungen.tsx index 42bde11..215dce1 100644 --- a/frontend/src/pages/FahrzeugEinstellungen.tsx +++ b/frontend/src/pages/FahrzeugEinstellungen.tsx @@ -1,353 +1,4 @@ -import { useState } from 'react'; -import { - Alert, - Autocomplete, - Box, - Button, - Chip, - CircularProgress, - Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - IconButton, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Typography, -} from '@mui/material'; -import { - Add as AddIcon, - Delete as DeleteIcon, - Edit as EditIcon, - Settings, -} from '@mui/icons-material'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { usePermissionContext } from '../contexts/PermissionContext'; -import { useNotification } from '../contexts/NotificationContext'; -import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; -import { vehiclesApi } from '../services/vehicles'; -import type { FahrzeugTyp } from '../types/checklist.types'; - +import { Navigate } from 'react-router-dom'; export default function FahrzeugEinstellungen() { - const queryClient = useQueryClient(); - const { hasPermission } = usePermissionContext(); - const { showSuccess, showError } = useNotification(); - - const canEdit = hasPermission('checklisten:manage_templates'); - - const { data: fahrzeugTypen = [], isLoading } = useQuery({ - queryKey: ['fahrzeug-typen'], - queryFn: fahrzeugTypenApi.getAll, - }); - - const [dialogOpen, setDialogOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); - const [deleteError, setDeleteError] = useState(null); - - const createMutation = useMutation({ - mutationFn: (data: Partial) => fahrzeugTypenApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); - setDialogOpen(false); - showSuccess('Fahrzeugtyp erstellt'); - }, - onError: () => showError('Fehler beim Erstellen'), - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Partial }) => - fahrzeugTypenApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); - setDialogOpen(false); - showSuccess('Fahrzeugtyp aktualisiert'); - }, - onError: () => showError('Fehler beim Aktualisieren'), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: number) => fahrzeugTypenApi.delete(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); - setDeleteError(null); - showSuccess('Fahrzeugtyp gelöscht'); - }, - onError: (err: any) => { - const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.'; - setDeleteError(msg); - }, - }); - - const openCreate = () => { - setEditing(null); - setForm({ name: '', beschreibung: '', icon: '' }); - setDialogOpen(true); - }; - - const openEdit = (t: FahrzeugTyp) => { - setEditing(t); - setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' }); - setDialogOpen(true); - }; - - const handleSubmit = () => { - if (!form.name.trim()) return; - if (editing) { - updateMutation.mutate({ id: editing.id, data: form }); - } else { - createMutation.mutate(form); - } - }; - - const isSaving = createMutation.isPending || updateMutation.isPending; - - if (!canEdit) { - return ( - - - Keine Berechtigung für diese Seite. - - - ); - } - - return ( - - - - - - Fahrzeug-Einstellungen - - - - - Fahrzeugtypen - - - {deleteError && ( - setDeleteError(null)}> - {deleteError} - - )} - - {isLoading ? ( - - - - ) : ( - <> - - - - - - - - - Name - Beschreibung - Icon - Aktionen - - - - {fahrzeugTypen.length === 0 ? ( - - - Keine Fahrzeugtypen vorhanden - - - ) : ( - fahrzeugTypen.map((t) => ( - - {t.name} - {t.beschreibung ?? '–'} - {t.icon ?? '–'} - - openEdit(t)}> - - - deleteMutation.mutate(t.id)} - > - - - - - )) - )} - -
-
- - )} - - setDialogOpen(false)} maxWidth="sm" fullWidth> - - {editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} - - - setForm((f) => ({ ...f, name: e.target.value }))} - /> - setForm((f) => ({ ...f, beschreibung: e.target.value }))} - /> - setForm((f) => ({ ...f, icon: e.target.value }))} - placeholder="z.B. fire_truck" - /> - - - - - - - - - -
-
- ); -} - -// ── Per-vehicle type assignment ──────────────────────────────────────────────── - -function VehicleTypeAssignment({ allTypes }: { allTypes: FahrzeugTyp[] }) { - const { showSuccess, showError } = useNotification(); - const { data: vehicles = [], isLoading } = useQuery({ - queryKey: ['vehicles'], - queryFn: vehiclesApi.getAll, - }); - - const [assignDialog, setAssignDialog] = useState<{ vehicleId: string; vehicleName: string; current: FahrzeugTyp[] } | null>(null); - const [selected, setSelected] = useState([]); - const [saving, setSaving] = useState(false); - - // cache of per-vehicle types: vehicleId → FahrzeugTyp[] - const [vehicleTypesMap, setVehicleTypesMap] = useState>({}); - - const openAssign = async (vehicleId: string, vehicleName: string) => { - let current = vehicleTypesMap[vehicleId]; - if (!current) { - try { current = await fahrzeugTypenApi.getTypesForVehicle(vehicleId); } - catch { current = []; } - setVehicleTypesMap((m) => ({ ...m, [vehicleId]: current })); - } - setSelected(current); - setAssignDialog({ vehicleId, vehicleName, current }); - }; - - const handleSave = async () => { - if (!assignDialog) return; - try { - setSaving(true); - await fahrzeugTypenApi.setTypesForVehicle(assignDialog.vehicleId, selected.map((t) => t.id)); - setVehicleTypesMap((m) => ({ ...m, [assignDialog.vehicleId]: selected })); - setAssignDialog(null); - showSuccess('Typen gespeichert'); - } catch { - showError('Fehler beim Speichern'); - } finally { - setSaving(false); - } - }; - - return ( - <> - Typzuweisung je Fahrzeug - {isLoading ? ( - - ) : ( - - - - - Fahrzeug - Zugewiesene Typen - Aktionen - - - - {vehicles.map((v) => { - const types = vehicleTypesMap[v.id]; - return ( - - {v.bezeichnung ?? v.kurzname} - - {types === undefined ? ( - - ) : types.length === 0 ? ( - Keine - ) : ( - - {types.map((t) => )} - - )} - - - openAssign(v.id, v.bezeichnung ?? v.kurzname ?? v.id)}> - - - - - ); - })} - -
-
- )} - - setAssignDialog(null)} maxWidth="sm" fullWidth> - Typen für {assignDialog?.vehicleName} - - o.name} - value={selected} - onChange={(_e, val) => setSelected(val)} - isOptionEqualToValue={(a, b) => a.id === b.id} - renderInput={(params) => } - /> - - - - - - - - ); + return ; } diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 8fac99e..fc5d756 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -10,16 +10,33 @@ import { Chip, CircularProgress, Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Grid, + IconButton, InputAdornment, + Paper, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, TextField, Tooltip, Typography, } from '@mui/material'; import { Add, + Add as AddIcon, CheckCircle, + Delete as DeleteIcon, DirectionsCar, + Edit as EditIcon, Error as ErrorIcon, FileDownload, PauseCircle, @@ -28,11 +45,13 @@ import { Warning, ReportProblem, } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { vehiclesApi } from '../services/vehicles'; import { equipmentApi } from '../services/equipment'; +import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; import type { VehicleEquipmentWarning } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { @@ -40,7 +59,10 @@ import { FahrzeugStatus, FahrzeugStatusLabel, } from '../types/vehicle.types'; +import type { FahrzeugTyp } from '../types/checklist.types'; import { usePermissions } from '../hooks/usePermissions'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -284,17 +306,203 @@ const VehicleCard: React.FC = ({ vehicle, onClick, warnings = ); }; +// ── Fahrzeugtypen-Verwaltung (Einstellungen Tab) ───────────────────────────── + +function FahrzeugTypenSettings() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const { data: fahrzeugTypen = [], isLoading } = useQuery({ + queryKey: ['fahrzeug-typen'], + queryFn: fahrzeugTypenApi.getAll, + }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' }); + const [deleteError, setDeleteError] = useState(null); + + const createMutation = useMutation({ + mutationFn: (data: Partial) => fahrzeugTypenApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDialogOpen(false); + showSuccess('Fahrzeugtyp erstellt'); + }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + fahrzeugTypenApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDialogOpen(false); + showSuccess('Fahrzeugtyp aktualisiert'); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => fahrzeugTypenApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fahrzeug-typen'] }); + setDeleteError(null); + showSuccess('Fahrzeugtyp gelöscht'); + }, + onError: (err: any) => { + const msg = err?.response?.data?.message || 'Fehler beim Löschen — Typ ist möglicherweise noch in Verwendung.'; + setDeleteError(msg); + }, + }); + + const openCreate = () => { + setEditing(null); + setForm({ name: '', beschreibung: '', icon: '' }); + setDialogOpen(true); + }; + + const openEdit = (t: FahrzeugTyp) => { + setEditing(t); + setForm({ name: t.name, beschreibung: t.beschreibung ?? '', icon: t.icon ?? '' }); + setDialogOpen(true); + }; + + const handleSubmit = () => { + if (!form.name.trim()) return; + if (editing) { + updateMutation.mutate({ id: editing.id, data: form }); + } else { + createMutation.mutate(form); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( + + + Fahrzeugtypen + + + {deleteError && ( + setDeleteError(null)}> + {deleteError} + + )} + + {isLoading ? ( + + + + ) : ( + <> + + + + + + + + + Name + Beschreibung + Icon + Aktionen + + + + {fahrzeugTypen.length === 0 ? ( + + + Keine Fahrzeugtypen vorhanden + + + ) : ( + fahrzeugTypen.map((t) => ( + + {t.name} + {t.beschreibung ?? '–'} + {t.icon ?? '–'} + + openEdit(t)}> + + + deleteMutation.mutate(t.id)} + > + + + + + )) + )} + +
+
+ + )} + + setDialogOpen(false)} maxWidth="sm" fullWidth> + + {editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} + + + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, beschreibung: e.target.value }))} + /> + setForm((f) => ({ ...f, icon: e.target.value }))} + placeholder="z.B. fire_truck" + /> + + + + + + +
+ ); +} + // ── Main Page ───────────────────────────────────────────────────────────────── function Fahrzeuge() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = parseInt(searchParams.get('tab') ?? '0', 10); const { isAdmin } = usePermissions(); + const { hasPermission } = usePermissionContext(); const [vehicles, setVehicles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); const [equipmentWarnings, setEquipmentWarnings] = useState>(new Map()); + const canEditSettings = hasPermission('checklisten:manage_templates'); + const fetchVehicles = useCallback(async () => { try { setLoading(true); @@ -310,7 +518,6 @@ function Fahrzeuge() { useEffect(() => { fetchVehicles(); }, [fetchVehicles]); - // Fetch equipment warnings separately — must not block or delay vehicle list rendering useEffect(() => { async function fetchWarnings() { try { @@ -323,7 +530,6 @@ function Fahrzeuge() { }); setEquipmentWarnings(warningsMap); } catch { - // Silently fail — equipment warnings are non-critical setEquipmentWarnings(new Map()); } } @@ -361,7 +567,6 @@ function Fahrzeuge() { const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length; - // An overdue inspection exists if §57a OR Wartung is past due const hasOverdue = vehicles.some( (v) => (v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) || @@ -371,7 +576,7 @@ function Fahrzeuge() { return ( - + Fahrzeugverwaltung @@ -386,82 +591,101 @@ function Fahrzeuge() { )} - + {tab === 0 && ( + + )} - {hasOverdue && ( - }> - Achtung: Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist. - - )} + setSearchParams({ tab: String(v) })} + sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} + > + + {canEditSettings && } + - setSearch(e.target.value)} - fullWidth - size="small" - sx={{ mb: 3, maxWidth: 480 }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> + {tab === 0 && ( + <> + {hasOverdue && ( + }> + Achtung: Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist. + + )} - {loading && ( - - - - )} + setSearch(e.target.value)} + fullWidth + size="small" + sx={{ mb: 3, maxWidth: 480 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> - {!loading && error && ( - - {error} - - )} + {loading && ( + + + + )} - {!loading && !error && filtered.length === 0 && ( - - - - {vehicles.length === 0 - ? 'Noch keine Fahrzeuge erfasst' - : 'Kein Fahrzeug entspricht dem Suchbegriff'} - - - )} + {!loading && error && ( + + {error} + + )} - {!loading && !error && filtered.length > 0 && ( - - {filtered.map((vehicle) => ( - - navigate(`/fahrzeuge/${id}`)} - warnings={equipmentWarnings.get(vehicle.id) || []} - /> + {!loading && !error && filtered.length === 0 && ( + + + + {vehicles.length === 0 + ? 'Noch keine Fahrzeuge erfasst' + : 'Kein Fahrzeug entspricht dem Suchbegriff'} + + + )} + + {!loading && !error && filtered.length > 0 && ( + + {filtered.map((vehicle) => ( + + navigate(`/fahrzeuge/${id}`)} + warnings={equipmentWarnings.get(vehicle.id) || []} + /> + + ))} - ))} - + )} + + {isAdmin && ( + navigate('/fahrzeuge/neu')} + > + + + )} + )} - {isAdmin && ( - navigate('/fahrzeuge/neu')} - > - - + {tab === 1 && canEditSettings && ( + )} diff --git a/frontend/src/services/checklisten.ts b/frontend/src/services/checklisten.ts index 58652fe..833e9ad 100644 --- a/frontend/src/services/checklisten.ts +++ b/frontend/src/services/checklisten.ts @@ -27,7 +27,6 @@ export const checklistenApi = { // ── Vorlagen (Templates) ── getVorlagen: async (filter?: ChecklistVorlageFilter): Promise => { const params = new URLSearchParams(); - if (filter?.fahrzeug_typ_id != null) params.set('fahrzeug_typ_id', String(filter.fahrzeug_typ_id)); if (filter?.aktiv != null) params.set('aktiv', String(filter.aktiv)); const qs = params.toString(); const r = await api.get(`/api/checklisten/vorlagen${qs ? `?${qs}` : ''}`); diff --git a/frontend/src/types/checklist.types.ts b/frontend/src/types/checklist.types.ts index 6a95405..aca4c8b 100644 --- a/frontend/src/types/checklist.types.ts +++ b/frontend/src/types/checklist.types.ts @@ -26,14 +26,14 @@ export interface AusruestungTyp { export interface ChecklistVorlage { id: number; name: string; - fahrzeug_typ_id?: number; - fahrzeug_typ?: FahrzeugTyp; - fahrzeug_id?: string; - fahrzeug_name?: string; - ausruestung_id?: string; - ausruestung_name?: string; - ausruestung_typ_id?: number; - ausruestung_typ?: string; + fahrzeug_typ_ids?: number[]; + fahrzeug_ids?: string[]; + ausruestung_typ_ids?: number[]; + ausruestung_ids?: string[]; + fahrzeug_typ_names?: string[]; + fahrzeug_names?: string[]; + ausruestung_typ_names?: string[]; + ausruestung_names?: string[]; intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null; intervall_tage?: number; beschreibung?: string; @@ -114,7 +114,6 @@ export const CHECKLIST_STATUS_COLORS: Record