feat: checklist multi-type assignments, tab layouts for Fahrzeuge/Ausruestung, admin cleanup

- Migration 074: convert checklist vorlage single FK fields to junction tables
  (vorlage_fahrzeug_typen, vorlage_fahrzeuge, vorlage_ausruestung_typen, vorlage_ausruestungen)
- Backend checklist service: multi-type create/update/query with array fields
- Backend cleanup service: add checklist-history and reset-checklist-history targets
- Frontend types/service: singular FK fields replaced with arrays (fahrzeug_typ_ids, etc.)
- Frontend Checklisten.tsx: multi-select Autocomplete pickers for all assignment types
- Fahrzeuge.tsx/Ausruestung.tsx: add tab layout (Uebersicht + Einstellungen), inline type CRUD
- FahrzeugEinstellungen/AusruestungEinstellungen: replaced with redirects to tab URLs
- Sidebar: add Uebersicht sub-items, update Einstellungen paths to tab URLs
- DataManagementTab: add checklist-history cleanup and reset sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-28 18:57:46 +01:00
parent 893fbe43a0
commit 4349de9bc9
14 changed files with 1078 additions and 1188 deletions

View File

@@ -21,13 +21,7 @@ class ChecklistController {
async getVorlagen(req: Request, res: Response): Promise<void> { async getVorlagen(req: Request, res: Response): Promise<void> {
try { try {
const filter: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean } = {}; const filter: { 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);
}
if (req.query.aktiv !== undefined) { if (req.query.aktiv !== undefined) {
filter.aktiv = req.query.aktiv === 'true'; filter.aktiv = req.query.aktiv === 'true';
} }

View File

@@ -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 $$;

View File

@@ -225,7 +225,7 @@ const cleanupBodySchema = z.object({
confirm: z.boolean().optional().default(false), 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<CleanupTarget, (days: number, confirm: boolean) => Promise<{ count: number; deleted: boolean }>> = { const CLEANUP_TARGETS: Record<CleanupTarget, (days: number, confirm: boolean) => Promise<{ count: number; deleted: boolean }>> = {
'notifications': (d, c) => cleanupService.cleanupNotifications(d, c), 'notifications': (d, c) => cleanupService.cleanupNotifications(d, c),
@@ -235,11 +235,13 @@ const CLEANUP_TARGETS: Record<CleanupTarget, (days: number, confirm: boolean) =>
'orders': (d, c) => cleanupService.cleanupOrders(d, c), 'orders': (d, c) => cleanupService.cleanupOrders(d, c),
'vehicle-history': (d, c) => cleanupService.cleanupVehicleHistory(d, c), 'vehicle-history': (d, c) => cleanupService.cleanupVehicleHistory(d, c),
'equipment-history': (d, c) => cleanupService.cleanupEquipmentHistory(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-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-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-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('/cleanup/issues-all', authenticate, requirePermission('admin:write'), (req, res) => { req.params.resetTarget = 'issues-all'; return resetHandler(req, res); });
router.delete( router.delete(
@@ -279,6 +281,7 @@ const RESET_TARGETS: Record<string, (confirm: boolean) => Promise<{ count: numbe
'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c), 'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c),
'reset-issues': (c) => cleanupService.resetIssuesSequence(c), 'reset-issues': (c) => cleanupService.resetIssuesSequence(c),
'issues-all': (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<void> => { const resetHandler = async (req: Request, res: Response): Promise<void> => {

View File

@@ -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) // Vorlagen (Templates)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) { async function getVorlagen(filter?: { aktiv?: boolean }) {
try { try {
const conditions: string[] = []; const conditions: string[] = [];
const values: any[] = []; const values: any[] = [];
let idx = 1; 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) { if (filter?.aktiv !== undefined) {
conditions.push(`v.aktiv = $${idx}`); conditions.push(`v.aktiv = $${idx}`);
values.push(filter.aktiv); 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 where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query( const result = await pool.query(
`SELECT v.*, `SELECT v.*,
ft.name AS fahrzeug_typ_name, ${JUNCTION_SUBQUERIES}
at.name AS ausruestung_typ_name
FROM checklist_vorlagen v 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} ${where}
ORDER BY v.name ASC`, ORDER BY v.name ASC`,
values values
@@ -81,11 +137,8 @@ async function getVorlageById(id: number) {
try { try {
const vorlageResult = await pool.query( const vorlageResult = await pool.query(
`SELECT v.*, `SELECT v.*,
ft.name AS fahrzeug_typ_name, ${JUNCTION_SUBQUERIES}
at.name AS ausruestung_typ_name
FROM checklist_vorlagen v 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`, WHERE v.id = $1`,
[id] [id]
); );
@@ -106,74 +159,93 @@ async function getVorlageById(id: number) {
async function createVorlage(data: { async function createVorlage(data: {
name: string; name: string;
fahrzeug_typ_id?: number | null; fahrzeug_typ_ids?: number[];
fahrzeug_id?: string | null; fahrzeug_ids?: string[];
ausruestung_typ_id?: number | null; ausruestung_typ_ids?: number[];
ausruestung_id?: string | null; ausruestung_ids?: string[];
intervall?: string | null; intervall?: string | null;
intervall_tage?: number | null; intervall_tage?: number | null;
beschreibung?: string | null; beschreibung?: string | null;
}) { }) {
const client = await pool.connect();
try { try {
const result = await pool.query( await client.query('BEGIN');
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung) const result = await client.query(
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `INSERT INTO checklist_vorlagen (name, intervall, intervall_tage, beschreibung)
VALUES ($1, $2, $3, $4)
RETURNING *`, RETURNING *`,
[ [data.name, data.intervall ?? null, data.intervall_tage ?? null, data.beschreibung ?? null]
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,
]
); );
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) { } catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('ChecklistService.createVorlage failed', { error }); logger.error('ChecklistService.createVorlage failed', { error });
throw new Error('Vorlage konnte nicht erstellt werden'); throw new Error('Vorlage konnte nicht erstellt werden');
} finally {
client.release();
} }
} }
async function updateVorlage(id: number, data: { async function updateVorlage(id: number, data: {
name?: string; name?: string;
fahrzeug_typ_id?: number | null; fahrzeug_typ_ids?: number[];
fahrzeug_id?: string | null; fahrzeug_ids?: string[];
ausruestung_typ_id?: number | null; ausruestung_typ_ids?: number[];
ausruestung_id?: string | null; ausruestung_ids?: string[];
intervall?: string | null; intervall?: string | null;
intervall_tage?: number | null; intervall_tage?: number | null;
beschreibung?: string | null; beschreibung?: string | null;
aktiv?: boolean; aktiv?: boolean;
}) { }) {
const client = await pool.connect();
try { try {
await client.query('BEGIN');
// Build SET clauses for scalar fields only
const setClauses: string[] = []; const setClauses: string[] = [];
const values: any[] = []; const values: any[] = [];
let idx = 1; let idx = 1;
if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; } 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' 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 ('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 ('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 (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); values.push(id);
const result = await pool.query( await client.query(
`UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`, `UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx}`,
values 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) { } catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('ChecklistService.updateVorlage failed', { error, id }); logger.error('ChecklistService.updateVorlage failed', { error, id });
throw new Error('Vorlage konnte nicht aktualisiert werden'); 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) { async function getTemplatesForVehicle(fahrzeugId: string) {
try { try {
// Templates that match the vehicle directly, by type, or global (no assignment)
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name `SELECT DISTINCT v.*,
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
WHERE v.aktiv = true WHERE v.aktiv = true
AND v.ausruestung_id IS NULL AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id)
AND v.ausruestung_typ_id IS NULL AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id)
AND ( AND (
v.fahrzeug_id = $1 EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge cvf WHERE cvf.vorlage_id = v.id AND cvf.fahrzeug_id = $1)
OR v.fahrzeug_typ_id IN ( OR EXISTS (
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1 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`, ORDER BY v.name ASC`,
[fahrzeugId] [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) { async function getTemplatesForEquipment(ausruestungId: string) {
try { try {
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT v.*, at.name AS ausruestung_typ_name `SELECT DISTINCT v.*,
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v FROM checklist_vorlagen v
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
WHERE v.aktiv = true WHERE v.aktiv = true
AND ( AND (
v.ausruestung_id = $1 EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = v.id AND cva.ausruestung_id = $1)
OR v.ausruestung_typ_id IN ( OR EXISTS (
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1 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`, ORDER BY v.name ASC`,
[ausruestungId] [ausruestungId]
@@ -530,7 +612,6 @@ async function getTemplatesForEquipment(ausruestungId: string) {
async function getOverviewItems() { async function getOverviewItems() {
try { try {
// All vehicles with their assigned templates (direct, by type, or global) // All vehicles with their assigned templates (direct, by type, or global)
// LEFT JOIN faelligkeit so unexecuted templates still appear
const vehiclesResult = await pool.query(` const vehiclesResult = await pool.query(`
SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name, SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name,
json_agg(DISTINCT jsonb_build_object( json_agg(DISTINCT jsonb_build_object(
@@ -541,14 +622,21 @@ async function getOverviewItems() {
)) AS checklists )) AS checklists
FROM fahrzeuge f FROM fahrzeuge f
JOIN checklist_vorlagen cv ON cv.aktiv = true JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.ausruestung_id IS NULL AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND cv.ausruestung_typ_id IS NULL AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
AND ( AND (
cv.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 cv.fahrzeug_typ_id IN ( OR EXISTS (
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = f.id 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 LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.fahrzeug_id = f.id
WHERE f.deleted_at IS NULL WHERE f.deleted_at IS NULL
@@ -567,14 +655,19 @@ async function getOverviewItems() {
)) AS checklists )) AS checklists
FROM ausruestung a FROM ausruestung a
JOIN checklist_vorlagen cv ON cv.aktiv = true JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.fahrzeug_id IS NULL
AND cv.fahrzeug_typ_id IS NULL
AND ( AND (
cv.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 cv.ausruestung_typ_id IN ( OR EXISTS (
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id 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 LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id
WHERE a.deleted_at IS NULL WHERE a.deleted_at IS NULL

View File

@@ -157,6 +157,34 @@ class CleanupService {
return { count, deleted: true }; return { count, deleted: true };
} }
async cleanupChecklistHistory(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> { async resetIssuesSequence(confirm: boolean): Promise<CleanupResult> {
if (!confirm) { if (!confirm) {
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues'); const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');

View File

@@ -24,6 +24,7 @@ const SECTIONS: CleanupSection[] = [
{ key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 }, { key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 },
{ key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 }, { 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: '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 { 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-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-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-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 { interface SectionState {

View File

@@ -193,12 +193,17 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const vehicleSubItems: SubItem[] = useMemo( const vehicleSubItems: SubItem[] = useMemo(
() => { () => {
const items: SubItem[] = (vehicleList ?? []).map((v) => ({ const items: SubItem[] = [
{ text: 'Übersicht', path: '/fahrzeuge?tab=0' },
];
(vehicleList ?? []).forEach((v) => {
items.push({
text: v.bezeichnung ?? v.kurzname, text: v.bezeichnung ?? v.kurzname,
path: `/fahrzeuge/${v.id}`, path: `/fahrzeuge/${v.id}`,
})); });
});
if (hasPermission('fahrzeuge:edit')) { if (hasPermission('fahrzeuge:edit')) {
items.push({ text: 'Einstellungen', path: '/fahrzeuge/einstellungen' }); items.push({ text: 'Einstellungen', path: '/fahrzeuge?tab=1' });
} }
return items; return items;
}, },
@@ -246,9 +251,11 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
.map((item) => { .map((item) => {
if (item.path === '/fahrzeuge') return fahrzeugeItem; if (item.path === '/fahrzeuge') return fahrzeugeItem;
if (item.path === '/ausruestung') { if (item.path === '/ausruestung') {
const ausruestungSubs: SubItem[] = []; const ausruestungSubs: SubItem[] = [
{ text: 'Übersicht', path: '/ausruestung?tab=0' },
];
if (hasPermission('ausruestung:manage_types')) { 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; return ausruestungSubs.length > 0 ? { ...item, subItems: ausruestungSubs } : item;
} }

View File

@@ -9,6 +9,11 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Container, Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
Grid, Grid,
@@ -16,30 +21,43 @@ import {
InputAdornment, InputAdornment,
InputLabel, InputLabel,
MenuItem, MenuItem,
Paper,
Select, Select,
Switch, Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { import {
Add, Add,
Add as AddIcon,
Build, Build,
CheckCircle, CheckCircle,
Close,
Delete,
Edit,
Error as ErrorIcon, Error as ErrorIcon,
LinkRounded, LinkRounded,
PauseCircle, PauseCircle,
RemoveCircle, RemoveCircle,
Save,
Search, Search,
Settings,
Star, Star,
Warning, Warning,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { ausruestungTypenApi } from '../services/ausruestungTypen'; import { ausruestungTypenApi, AusruestungTyp } from '../services/ausruestungTypen';
import { import {
AusruestungListItem, AusruestungListItem,
AusruestungKategorie, AusruestungKategorie,
@@ -48,6 +66,7 @@ import {
EquipmentStats, EquipmentStats,
} from '../types/equipment.types'; } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -233,10 +252,248 @@ const EquipmentCard: React.FC<EquipmentCardProps> = ({ 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<AusruestungTyp | null>(null);
const [formName, setFormName] = useState('');
const [formBeschreibung, setFormBeschreibung] = useState('');
const [formIcon, setFormIcon] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingTyp, setDeletingTyp] = useState<AusruestungTyp | null>(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 (
<Box>
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Typen konnten nicht geladen werden.
</Alert>
)}
{!isLoading && !isError && (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{typen.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Noch keine Typen vorhanden.
</Typography>
</TableCell>
</TableRow>
)}
{typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell>{typ.name}</TableCell>
<TableCell>{typ.beschreibung || '---'}</TableCell>
<TableCell>{typ.icon || '---'}</TableCell>
<TableCell align="right">
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(typ)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" startIcon={<AddIcon />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
<IconButton onClick={closeDialog} size="small"><Close /></IconButton>
</DialogTitle>
<DialogContent>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
sx={{ mt: 1, mb: 2 }}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formName.trim()}
startIcon={isSaving ? <CircularProgress size={16} /> : <Save />}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}>
<DialogTitle>Typ löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen?
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)} disabled={deleteMutation.isPending}>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
disabled={deleteMutation.isPending}
startIcon={deleteMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
function Ausruestung() { function Ausruestung() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { canManageEquipment, hasPermission } = usePermissions(); const { canManageEquipment, hasPermission } = usePermissions();
const canManageTypes = hasPermission('ausruestung:manage_types'); const canManageTypes = hasPermission('ausruestung:manage_types');
@@ -285,7 +542,6 @@ function Ausruestung() {
// Client-side filtering // Client-side filtering
const filtered = useMemo(() => { const filtered = useMemo(() => {
return equipment.filter((item) => { return equipment.filter((item) => {
// Text search
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const matches = const matches =
@@ -295,37 +551,16 @@ function Ausruestung() {
(item.hersteller?.toLowerCase().includes(q) ?? false); (item.hersteller?.toLowerCase().includes(q) ?? false);
if (!matches) return false; if (!matches) return false;
} }
if (selectedCategory && item.kategorie_id !== selectedCategory) return false;
// Category filter
if (selectedCategory && item.kategorie_id !== selectedCategory) {
return false;
}
// Type filter
if (selectedTyp) { if (selectedTyp) {
const typId = parseInt(selectedTyp, 10); const typId = parseInt(selectedTyp, 10);
if (!item.typen?.some((t) => t.id === typId)) { if (!item.typen?.some((t) => t.id === typId)) return false;
return false;
} }
} if (selectedStatus && item.status !== selectedStatus) return false;
if (nurWichtige && !item.ist_wichtig) 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 (pruefungFaellig) { if (pruefungFaellig) {
if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) { if (item.pruefung_tage_bis_faelligkeit === null || item.pruefung_tage_bis_faelligkeit > 30) return false;
return false;
} }
}
return true; return true;
}); });
}, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]); }, [equipment, search, selectedCategory, selectedTyp, selectedStatus, nurWichtige, pruefungFaellig]);
@@ -338,19 +573,12 @@ function Ausruestung() {
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}> <Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Ausrüstungsverwaltung Ausrüstungsverwaltung
</Typography> </Typography>
{canManageTypes && (
<Tooltip title="Einstellungen">
<IconButton onClick={() => navigate('/ausruestung/einstellungen')} size="small">
<Settings />
</IconButton>
</Tooltip>
)}
</Box> </Box>
{!loading && stats && ( {!loading && stats && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
@@ -374,6 +602,17 @@ function Ausruestung() {
</Box> </Box>
</Box> </Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canManageTypes && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{/* Overdue alert */} {/* Overdue alert */}
{hasOverdue && ( {hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}> <Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
@@ -527,6 +766,12 @@ function Ausruestung() {
<Add /> <Add />
</ChatAwareFab> </ChatAwareFab>
)} )}
</>
)}
{tab === 1 && canManageTypes && (
<AusruestungTypenSettings />
)}
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -1,434 +1,4 @@
import { useState } from 'react'; import { Navigate } from 'react-router-dom';
import { export default function AusruestungEinstellungen() {
Alert, return <Navigate to="/ausruestung?tab=1" replace />;
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<AusruestungTyp | null>(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<AusruestungTyp | null>(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 (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>Keine Berechtigung</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstungstypen zu verwalten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="lg">
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/ausruestung')}
sx={{ mb: 2 }}
size="small"
>
Ausrüstungsübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Ausrüstungs-Einstellungen</Typography>
</Box>
{/* Ausrüstungstypen Section */}
<Typography variant="h6" gutterBottom>Ausrüstungstypen</Typography>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Typen konnten nicht geladen werden.
</Alert>
)}
{!isLoading && !isError && (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{typen.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
Noch keine Typen vorhanden.
</Typography>
</TableCell>
</TableRow>
)}
{typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell>{typ.name}</TableCell>
<TableCell>{typ.beschreibung || '---'}</TableCell>
<TableCell>{typ.icon || '---'}</TableCell>
<TableCell align="right">
<Tooltip title="Bearbeiten">
<IconButton size="small" onClick={() => openEditDialog(typ)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => openDeleteDialog(typ)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" startIcon={<Add />} onClick={openAddDialog}>
Neuer Typ
</Button>
</Box>
</Paper>
)}
{/* Add/Edit dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
<IconButton onClick={closeDialog} size="small"><Close /></IconButton>
</DialogTitle>
<DialogContent>
<TextField
label="Name *"
fullWidth
value={formName}
onChange={(e) => setFormName(e.target.value)}
sx={{ mt: 1, mb: 2 }}
inputProps={{ maxLength: 100 }}
/>
<TextField
label="Beschreibung"
fullWidth
multiline
rows={2}
value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
label="Icon (MUI Icon-Name)"
fullWidth
value={formIcon}
onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formName.trim()}
startIcon={isSaving ? <CircularProgress size={16} /> : <Save />}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}>
<DialogTitle>Typ löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen?
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)} disabled={deleteMutation.isPending}>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
disabled={deleteMutation.isPending}
startIcon={deleteMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
<Divider sx={{ my: 4 }} />
<EquipmentTypeAssignment allTypes={typen} />
</Container>
</DashboardLayout>
);
}
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<AusruestungTyp[]>([]);
const [saving, setSaving] = useState(false);
const [equipmentTypesMap, setEquipmentTypesMap] = useState<Record<string, AusruestungTyp[]>>({});
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 (
<>
<Typography variant="h6" gutterBottom>Typzuweisung je Gerät</Typography>
{isLoading ? (
<CircularProgress size={24} />
) : (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Gerät</TableCell>
<TableCell>Zugewiesene Typen</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{equipment.map((e) => {
const types = equipmentTypesMap[e.id];
return (
<TableRow key={e.id} hover>
<TableCell>{e.bezeichnung}</TableCell>
<TableCell>
{types === undefined ? (
<Typography variant="body2" color="text.disabled"></Typography>
) : types.length === 0 ? (
<Typography variant="body2" color="text.disabled">Keine</Typography>
) : (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{types.map((t) => <Chip key={t.id} label={t.name} size="small" variant="outlined" />)}
</Box>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="Typen zuweisen">
<IconButton size="small" onClick={() => openAssign(e.id, e.bezeichnung)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
<Dialog open={!!assignDialog} onClose={() => setAssignDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Typen für {assignDialog?.equipmentName}
<IconButton size="small" onClick={() => setAssignDialog(null)}><Close /></IconButton>
</DialogTitle>
<DialogContent sx={{ mt: 1 }}>
<Autocomplete
multiple
options={allTypes}
getOptionLabel={(o) => o.name}
value={selected}
onChange={(_e, val) => setSelected(val)}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Ausrüstungstypen" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setAssignDialog(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : <Save />}>
Speichern
</Button>
</DialogActions>
</Dialog>
</>
);
} }

View File

@@ -93,18 +93,18 @@ const INTERVALL_LABELS: Record<string, string> = {
type AssignmentType = 'global' | 'fahrzeug_typ' | 'fahrzeug' | 'ausruestung_typ' | 'ausruestung'; type AssignmentType = 'global' | 'fahrzeug_typ' | 'fahrzeug' | 'ausruestung_typ' | 'ausruestung';
function getAssignmentType(v: ChecklistVorlage): AssignmentType { function getAssignmentType(v: ChecklistVorlage): AssignmentType {
if (v.fahrzeug_id) return 'fahrzeug'; if (v.fahrzeug_ids?.length) return 'fahrzeug';
if (v.fahrzeug_typ_id) return 'fahrzeug_typ'; if (v.fahrzeug_typ_ids?.length) return 'fahrzeug_typ';
if (v.ausruestung_id) return 'ausruestung'; if (v.ausruestung_ids?.length) return 'ausruestung';
if (v.ausruestung_typ_id) return 'ausruestung_typ'; if (v.ausruestung_typ_ids?.length) return 'ausruestung_typ';
return 'global'; return 'global';
} }
function getAssignmentLabel(v: ChecklistVorlage): string { function getAssignmentLabel(v: ChecklistVorlage): string {
if (v.fahrzeug_id) return v.fahrzeug_name ? `Fahrzeug: ${v.fahrzeug_name}` : 'Fahrzeug (direkt)'; if (v.fahrzeug_ids?.length) return v.fahrzeug_names?.length ? `Fahrzeug: ${v.fahrzeug_names.join(', ')}` : 'Fahrzeug (direkt)';
if (v.fahrzeug_typ_id) return v.fahrzeug_typ?.name ? `Fahrzeugtyp: ${v.fahrzeug_typ.name}` : 'Fahrzeugtyp'; if (v.fahrzeug_typ_ids?.length) return v.fahrzeug_typ_names?.length ? `Fahrzeugtyp: ${v.fahrzeug_typ_names.join(', ')}` : 'Fahrzeugtyp';
if (v.ausruestung_id) return v.ausruestung_name ? `Ausrüstung: ${v.ausruestung_name}` : 'Ausrüstung (direkt)'; if (v.ausruestung_ids?.length) return v.ausruestung_names?.length ? `Ausrüstung: ${v.ausruestung_names.join(', ')}` : 'Ausrüstung (direkt)';
if (v.ausruestung_typ_id) return v.ausruestung_typ ? `Ausrüstungstyp: ${v.ausruestung_typ}` : 'Ausrüstungstyp'; if (v.ausruestung_typ_ids?.length) return v.ausruestung_typ_names?.length ? `Ausrüstungstyp: ${v.ausruestung_typ_names.join(', ')}` : 'Ausrüstungstyp';
return 'Global'; return 'Global';
} }
@@ -365,8 +365,8 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
const [assignmentType, setAssignmentType] = useState<AssignmentType>('global'); const [assignmentType, setAssignmentType] = useState<AssignmentType>('global');
const emptyForm: CreateVorlagePayload = { const emptyForm: CreateVorlagePayload = {
name: '', fahrzeug_typ_id: undefined, fahrzeug_id: undefined, name: '', fahrzeug_typ_ids: [], fahrzeug_ids: [],
ausruestung_typ_id: undefined, ausruestung_id: undefined, ausruestung_typ_ids: [], ausruestung_ids: [],
intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true, intervall: undefined, intervall_tage: undefined, beschreibung: '', aktiv: true,
}; };
const [form, setForm] = useState<CreateVorlagePayload>(emptyForm); const [form, setForm] = useState<CreateVorlagePayload>(emptyForm);
@@ -417,10 +417,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
setAssignmentType(getAssignmentType(v)); setAssignmentType(getAssignmentType(v));
setForm({ setForm({
name: v.name, name: v.name,
fahrzeug_typ_id: v.fahrzeug_typ_id, fahrzeug_typ_ids: v.fahrzeug_typ_ids ?? [],
fahrzeug_id: v.fahrzeug_id, fahrzeug_ids: v.fahrzeug_ids ?? [],
ausruestung_typ_id: v.ausruestung_typ_id, ausruestung_typ_ids: v.ausruestung_typ_ids ?? [],
ausruestung_id: v.ausruestung_id, ausruestung_ids: v.ausruestung_ids ?? [],
intervall: v.intervall, intervall: v.intervall,
intervall_tage: v.intervall_tage, intervall_tage: v.intervall_tage,
beschreibung: v.beschreibung ?? '', beschreibung: v.beschreibung ?? '',
@@ -438,10 +438,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
aktiv: form.aktiv, aktiv: form.aktiv,
}; };
switch (assignmentType) { switch (assignmentType) {
case 'fahrzeug_typ': return { ...base, fahrzeug_typ_id: form.fahrzeug_typ_id }; case 'fahrzeug_typ': return { ...base, fahrzeug_typ_ids: form.fahrzeug_typ_ids };
case 'fahrzeug': return { ...base, fahrzeug_id: form.fahrzeug_id }; case 'fahrzeug': return { ...base, fahrzeug_ids: form.fahrzeug_ids };
case 'ausruestung_typ': return { ...base, ausruestung_typ_id: form.ausruestung_typ_id }; case 'ausruestung_typ': return { ...base, ausruestung_typ_ids: form.ausruestung_typ_ids };
case 'ausruestung': return { ...base, ausruestung_id: form.ausruestung_id }; case 'ausruestung': return { ...base, ausruestung_ids: form.ausruestung_ids };
default: return base; default: return base;
} }
}; };
@@ -460,10 +460,10 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
setAssignmentType(newType); setAssignmentType(newType);
setForm((f) => ({ setForm((f) => ({
...f, ...f,
fahrzeug_typ_id: undefined, fahrzeug_typ_ids: [],
fahrzeug_id: undefined, fahrzeug_ids: [],
ausruestung_typ_id: undefined, ausruestung_typ_ids: [],
ausruestung_id: undefined, ausruestung_ids: [],
})); }));
}; };
@@ -547,41 +547,46 @@ function VorlagenTab({ vorlagen, loading, fahrzeugTypen, queryClient, showSucces
{/* Assignment picker based on type */} {/* Assignment picker based on type */}
{assignmentType === 'fahrzeug_typ' && ( {assignmentType === 'fahrzeug_typ' && (
<FormControl fullWidth> <Autocomplete
<InputLabel>Fahrzeugtyp</InputLabel> multiple
<Select options={fahrzeugTypen}
label="Fahrzeugtyp" getOptionLabel={(t) => t.name}
value={form.fahrzeug_typ_id ?? ''} value={fahrzeugTypen.filter((t) => form.fahrzeug_typ_ids?.includes(t.id))}
onChange={(e) => setForm((f) => ({ ...f, fahrzeug_typ_id: e.target.value ? Number(e.target.value) : undefined }))} onChange={(_e, vals) => setForm((f) => ({ ...f, fahrzeug_typ_ids: vals.map((v) => v.id) }))}
> isOptionEqualToValue={(a, b) => a.id === b.id}
{fahrzeugTypen.map((t) => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)} renderInput={(params) => <TextField {...params} label="Fahrzeugtypen" />}
</Select> />
</FormControl>
)} )}
{assignmentType === 'fahrzeug' && ( {assignmentType === 'fahrzeug' && (
<Autocomplete <Autocomplete
multiple
options={vehiclesList} options={vehiclesList}
getOptionLabel={(v) => v.bezeichnung ?? v.kurzname ?? String(v.id)} getOptionLabel={(v) => v.bezeichnung ?? v.kurzname ?? String(v.id)}
value={vehiclesList.find((v) => v.id === form.fahrzeug_id) ?? null} value={vehiclesList.filter((v) => form.fahrzeug_ids?.includes(v.id))}
onChange={(_e, v) => setForm((f) => ({ ...f, fahrzeug_id: v?.id }))} onChange={(_e, vals) => setForm((f) => ({ ...f, fahrzeug_ids: vals.map((v) => v.id) }))}
renderInput={(params) => <TextField {...params} label="Fahrzeug" />} isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Fahrzeuge" />}
/> />
)} )}
{assignmentType === 'ausruestung_typ' && ( {assignmentType === 'ausruestung_typ' && (
<Autocomplete <Autocomplete
multiple
options={ausruestungTypen} options={ausruestungTypen}
getOptionLabel={(t: AusruestungTyp) => t.name} getOptionLabel={(t: AusruestungTyp) => t.name}
value={ausruestungTypen.find((t: AusruestungTyp) => t.id === form.ausruestung_typ_id) ?? null} value={ausruestungTypen.filter((t: AusruestungTyp) => form.ausruestung_typ_ids?.includes(t.id))}
onChange={(_e, t: AusruestungTyp | null) => setForm((f) => ({ ...f, ausruestung_typ_id: t?.id }))} onChange={(_e, vals: AusruestungTyp[]) => setForm((f) => ({ ...f, ausruestung_typ_ids: vals.map((v) => v.id) }))}
renderInput={(params) => <TextField {...params} label="Ausrüstungstyp" />} isOptionEqualToValue={(a: AusruestungTyp, b: AusruestungTyp) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Ausrüstungstypen" />}
/> />
)} )}
{assignmentType === 'ausruestung' && ( {assignmentType === 'ausruestung' && (
<Autocomplete <Autocomplete
multiple
options={equipmentList} options={equipmentList}
getOptionLabel={(eq) => eq.bezeichnung ?? String(eq.id)} getOptionLabel={(eq) => eq.bezeichnung ?? String(eq.id)}
value={equipmentList.find((eq) => eq.id === form.ausruestung_id) ?? null} value={equipmentList.filter((eq) => form.ausruestung_ids?.includes(eq.id))}
onChange={(_e, eq) => setForm((f) => ({ ...f, ausruestung_id: eq?.id }))} onChange={(_e, vals) => setForm((f) => ({ ...f, ausruestung_ids: vals.map((v) => v.id) }))}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Ausrüstung" />} renderInput={(params) => <TextField {...params} label="Ausrüstung" />}
/> />
)} )}

View File

@@ -1,353 +1,4 @@
import { useState } from 'react'; import { Navigate } from 'react-router-dom';
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';
export default function FahrzeugEinstellungen() { export default function FahrzeugEinstellungen() {
const queryClient = useQueryClient(); return <Navigate to="/fahrzeuge?tab=1" replace />;
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<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => 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<FahrzeugTyp> }) =>
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 (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">Keine Berechtigung für diese Seite.</Alert>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Settings color="action" />
<Typography variant="h4" component="h1">
Fahrzeug-Einstellungen
</Typography>
</Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
<Divider sx={{ my: 4 }} />
<VehicleTypeAssignment allTypes={fahrzeugTypen} />
</Container>
</DashboardLayout>
);
}
// ── 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<FahrzeugTyp[]>([]);
const [saving, setSaving] = useState(false);
// cache of per-vehicle types: vehicleId → FahrzeugTyp[]
const [vehicleTypesMap, setVehicleTypesMap] = useState<Record<string, FahrzeugTyp[]>>({});
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 (
<>
<Typography variant="h6" sx={{ mb: 2 }}>Typzuweisung je Fahrzeug</Typography>
{isLoading ? (
<CircularProgress size={24} />
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fahrzeug</TableCell>
<TableCell>Zugewiesene Typen</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicles.map((v) => {
const types = vehicleTypesMap[v.id];
return (
<TableRow key={v.id} hover>
<TableCell>{v.bezeichnung ?? v.kurzname}</TableCell>
<TableCell>
{types === undefined ? (
<Typography variant="body2" color="text.disabled"></Typography>
) : types.length === 0 ? (
<Typography variant="body2" color="text.disabled">Keine</Typography>
) : (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{types.map((t) => <Chip key={t.id} label={t.name} size="small" variant="outlined" />)}
</Box>
)}
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openAssign(v.id, v.bezeichnung ?? v.kurzname ?? v.id)}>
<EditIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
<Dialog open={!!assignDialog} onClose={() => setAssignDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle>Typen für {assignDialog?.vehicleName}</DialogTitle>
<DialogContent sx={{ mt: 1 }}>
<Autocomplete
multiple
options={allTypes}
getOptionLabel={(o) => o.name}
value={selected}
onChange={(_e, val) => setSelected(val)}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => <TextField {...params} label="Fahrzeugtypen" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setAssignDialog(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</>
);
} }

View File

@@ -10,16 +10,33 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Container, Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid, Grid,
IconButton,
InputAdornment, InputAdornment,
Paper,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { import {
Add, Add,
Add as AddIcon,
CheckCircle, CheckCircle,
Delete as DeleteIcon,
DirectionsCar, DirectionsCar,
Edit as EditIcon,
Error as ErrorIcon, Error as ErrorIcon,
FileDownload, FileDownload,
PauseCircle, PauseCircle,
@@ -28,11 +45,13 @@ import {
Warning, Warning,
ReportProblem, ReportProblem,
} from '@mui/icons-material'; } 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 DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
import type { VehicleEquipmentWarning } from '../types/equipment.types'; import type { VehicleEquipmentWarning } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { import {
@@ -40,7 +59,10 @@ import {
FahrzeugStatus, FahrzeugStatus,
FahrzeugStatusLabel, FahrzeugStatusLabel,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import type { FahrzeugTyp } from '../types/checklist.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -284,17 +306,203 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ 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<FahrzeugTyp | null>(null);
const [form, setForm] = useState({ name: '', beschreibung: '', icon: '' });
const [deleteError, setDeleteError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: Partial<FahrzeugTyp>) => 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<FahrzeugTyp> }) =>
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 (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>
Fahrzeugtypen
</Typography>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError(null)}>
{deleteError}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Neuer Fahrzeugtyp
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Icon</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fahrzeugTypen.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
Keine Fahrzeugtypen vorhanden
</TableCell>
</TableRow>
) : (
fahrzeugTypen.map((t) => (
<TableRow key={t.id} hover>
<TableCell>{t.name}</TableCell>
<TableCell>{t.beschreibung ?? ''}</TableCell>
<TableCell>{t.icon ?? ''}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(t)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(t.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name *"
fullWidth
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<TextField
label="Beschreibung"
fullWidth
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
<TextField
label="Icon"
fullWidth
value={form.icon}
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() { function Fahrzeuge() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const tab = parseInt(searchParams.get('tab') ?? '0', 10);
const { isAdmin } = usePermissions(); const { isAdmin } = usePermissions();
const { hasPermission } = usePermissionContext();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]); const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map()); const [equipmentWarnings, setEquipmentWarnings] = useState<Map<string, VehicleEquipmentWarning[]>>(new Map());
const canEditSettings = hasPermission('checklisten:manage_templates');
const fetchVehicles = useCallback(async () => { const fetchVehicles = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
@@ -310,7 +518,6 @@ function Fahrzeuge() {
useEffect(() => { fetchVehicles(); }, [fetchVehicles]); useEffect(() => { fetchVehicles(); }, [fetchVehicles]);
// Fetch equipment warnings separately — must not block or delay vehicle list rendering
useEffect(() => { useEffect(() => {
async function fetchWarnings() { async function fetchWarnings() {
try { try {
@@ -323,7 +530,6 @@ function Fahrzeuge() {
}); });
setEquipmentWarnings(warningsMap); setEquipmentWarnings(warningsMap);
} catch { } catch {
// Silently fail — equipment warnings are non-critical
setEquipmentWarnings(new Map()); setEquipmentWarnings(new Map());
} }
} }
@@ -361,7 +567,6 @@ function Fahrzeuge() {
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length; 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( const hasOverdue = vehicles.some(
(v) => (v) =>
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) || (v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
@@ -371,7 +576,7 @@ function Fahrzeuge() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="xl"> <Container maxWidth="xl">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box> <Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}> <Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Fahrzeugverwaltung Fahrzeugverwaltung
@@ -386,6 +591,7 @@ function Fahrzeuge() {
</Typography> </Typography>
)} )}
</Box> </Box>
{tab === 0 && (
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
@@ -394,8 +600,20 @@ function Fahrzeuge() {
> >
Prüfungen CSV Prüfungen CSV
</Button> </Button>
)}
</Box> </Box>
<Tabs
value={tab}
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="Übersicht" />
{canEditSettings && <Tab label="Einstellungen" />}
</Tabs>
{tab === 0 && (
<>
{hasOverdue && ( {hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}> <Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist. <strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
@@ -463,6 +681,12 @@ function Fahrzeuge() {
<Add /> <Add />
</ChatAwareFab> </ChatAwareFab>
)} )}
</>
)}
{tab === 1 && canEditSettings && (
<FahrzeugTypenSettings />
)}
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -27,7 +27,6 @@ export const checklistenApi = {
// ── Vorlagen (Templates) ── // ── Vorlagen (Templates) ──
getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => { getVorlagen: async (filter?: ChecklistVorlageFilter): Promise<ChecklistVorlage[]> => {
const params = new URLSearchParams(); 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)); if (filter?.aktiv != null) params.set('aktiv', String(filter.aktiv));
const qs = params.toString(); const qs = params.toString();
const r = await api.get(`/api/checklisten/vorlagen${qs ? `?${qs}` : ''}`); const r = await api.get(`/api/checklisten/vorlagen${qs ? `?${qs}` : ''}`);

View File

@@ -26,14 +26,14 @@ export interface AusruestungTyp {
export interface ChecklistVorlage { export interface ChecklistVorlage {
id: number; id: number;
name: string; name: string;
fahrzeug_typ_id?: number; fahrzeug_typ_ids?: number[];
fahrzeug_typ?: FahrzeugTyp; fahrzeug_ids?: string[];
fahrzeug_id?: string; ausruestung_typ_ids?: number[];
fahrzeug_name?: string; ausruestung_ids?: string[];
ausruestung_id?: string; fahrzeug_typ_names?: string[];
ausruestung_name?: string; fahrzeug_names?: string[];
ausruestung_typ_id?: number; ausruestung_typ_names?: string[];
ausruestung_typ?: string; ausruestung_names?: string[];
intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null; intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null;
intervall_tage?: number; intervall_tage?: number;
beschreibung?: string; beschreibung?: string;
@@ -114,7 +114,6 @@ export const CHECKLIST_STATUS_COLORS: Record<ChecklistAusfuehrungStatus, 'defaul
}; };
export interface ChecklistVorlageFilter { export interface ChecklistVorlageFilter {
fahrzeug_typ_id?: number;
aktiv?: boolean; aktiv?: boolean;
} }
@@ -126,10 +125,10 @@ export interface ChecklistAusfuehrungFilter {
export interface CreateVorlagePayload { export interface CreateVorlagePayload {
name: string; name: string;
fahrzeug_typ_id?: number | null; fahrzeug_typ_ids?: number[];
fahrzeug_id?: string | null; fahrzeug_ids?: string[];
ausruestung_typ_id?: number | null; ausruestung_typ_ids?: number[];
ausruestung_id?: string | null; ausruestung_ids?: string[];
intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null; intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null;
intervall_tage?: number | null; intervall_tage?: number | null;
beschreibung?: string | null; beschreibung?: string | null;
@@ -138,10 +137,10 @@ export interface CreateVorlagePayload {
export interface UpdateVorlagePayload { export interface UpdateVorlagePayload {
name?: string; name?: string;
fahrzeug_typ_id?: number | null; fahrzeug_typ_ids?: number[];
fahrzeug_id?: string | null; fahrzeug_ids?: string[];
ausruestung_typ_id?: number | null; ausruestung_typ_ids?: number[];
ausruestung_id?: string | null; ausruestung_ids?: string[];
intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null; intervall?: 'weekly' | 'monthly' | 'quarterly' | 'halfyearly' | 'yearly' | 'custom' | null;
intervall_tage?: number | null; intervall_tage?: number | null;
beschreibung?: string | null; beschreibung?: string | null;