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:
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
70
backend/src/database/migrations/074_checklist_multi_type.sql
Normal file
70
backend/src/database/migrations/074_checklist_multi_type.sql
Normal 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 $$;
|
||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
values.push(id);
|
// Replace junction rows if any array key is present
|
||||||
const result = await pool.query(
|
const hasJunctionData = 'fahrzeug_typ_ids' in data || 'fahrzeug_ids' in data
|
||||||
`UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
|
|| 'ausruestung_typ_ids' in data || 'ausruestung_ids' in data;
|
||||||
values
|
if (hasJunctionData) {
|
||||||
);
|
await deleteJunctionRows(client, id);
|
||||||
return result.rows[0] || null;
|
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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: v.bezeichnung ?? v.kurzname,
|
{ text: 'Übersicht', path: '/fahrzeuge?tab=0' },
|
||||||
path: `/fahrzeuge/${v.id}`,
|
];
|
||||||
}));
|
(vehicleList ?? []).forEach((v) => {
|
||||||
|
items.push({
|
||||||
|
text: v.bezeichnung ?? v.kurzname,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "{deletingTyp?.name}" 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;
|
||||||
// Status filter
|
if (nurWichtige && !item.ist_wichtig) return false;
|
||||||
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,158 +602,175 @@ function Ausruestung() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Overdue alert */}
|
<Tabs
|
||||||
{hasOverdue && (
|
value={tab}
|
||||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
|
||||||
<strong>Achtung:</strong> Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist.
|
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||||
</Alert>
|
>
|
||||||
)}
|
<Tab label="Übersicht" />
|
||||||
|
{canManageTypes && <Tab label="Einstellungen" />}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* Filter controls */}
|
{tab === 0 && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
|
<>
|
||||||
<TextField
|
{/* Overdue alert */}
|
||||||
placeholder="Suchen (Bezeichnung, Seriennr., Inventarnr., Hersteller...)"
|
{hasOverdue && (
|
||||||
value={search}
|
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<strong>Achtung:</strong> Mindestens eine Ausrüstung hat eine überfällige Prüfungsfrist.
|
||||||
size="small"
|
</Alert>
|
||||||
sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }}
|
)}
|
||||||
InputProps={{
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Search />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
{/* Filter controls */}
|
||||||
<InputLabel>Kategorie</InputLabel>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||||
<Select
|
<TextField
|
||||||
value={selectedCategory}
|
placeholder="Suchen (Bezeichnung, Seriennr., Inventarnr., Hersteller...)"
|
||||||
label="Kategorie"
|
value={search}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
>
|
|
||||||
<MenuItem value="">Alle Kategorien</MenuItem>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<MenuItem key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
||||||
<InputLabel>Typ</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedTyp}
|
|
||||||
label="Typ"
|
|
||||||
onChange={(e) => setSelectedTyp(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Alle Typen</MenuItem>
|
|
||||||
{typen.map((t) => (
|
|
||||||
<MenuItem key={t.id} value={String(t.id)}>
|
|
||||||
{t.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedStatus}
|
|
||||||
label="Status"
|
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="">Alle Status</MenuItem>
|
|
||||||
{Object.values(AusruestungStatus).map((s) => (
|
|
||||||
<MenuItem key={s} value={s}>
|
|
||||||
{AusruestungStatusLabel[s]}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={nurWichtige}
|
|
||||||
onChange={(e) => setNurWichtige(e.target.checked)}
|
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ minWidth: 280, flexGrow: 1, maxWidth: 480 }}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
label={<Typography variant="body2">Nur wichtige</Typography>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
control={
|
<InputLabel>Kategorie</InputLabel>
|
||||||
<Switch
|
<Select
|
||||||
checked={pruefungFaellig}
|
value={selectedCategory}
|
||||||
onChange={(e) => setPruefungFaellig(e.target.checked)}
|
label="Kategorie"
|
||||||
size="small"
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle Kategorien</MenuItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedTyp}
|
||||||
|
label="Typ"
|
||||||
|
onChange={(e) => setSelectedTyp(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle Typen</MenuItem>
|
||||||
|
{typen.map((t) => (
|
||||||
|
<MenuItem key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedStatus}
|
||||||
|
label="Status"
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle Status</MenuItem>
|
||||||
|
{Object.values(AusruestungStatus).map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{AusruestungStatusLabel[s]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={nurWichtige}
|
||||||
|
onChange={(e) => setNurWichtige(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">Nur wichtige</Typography>}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
label={<Typography variant="body2">Prüfung fällig</Typography>}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
<FormControlLabel
|
||||||
{loading && (
|
control={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
<Switch
|
||||||
<CircularProgress />
|
checked={pruefungFaellig}
|
||||||
</Box>
|
onChange={(e) => setPruefungFaellig(e.target.checked)}
|
||||||
)}
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">Prüfung fällig</Typography>}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Loading state */}
|
||||||
{!loading && error && (
|
{loading && (
|
||||||
<Alert
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
severity="error"
|
<CircularProgress />
|
||||||
sx={{ mb: 2 }}
|
</Box>
|
||||||
action={
|
)}
|
||||||
<Button color="inherit" size="small" onClick={fetchData}>
|
|
||||||
Erneut versuchen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty states */}
|
{/* Error state */}
|
||||||
{!loading && !error && filtered.length === 0 && (
|
{!loading && error && (
|
||||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
<Alert
|
||||||
<Build sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
severity="error"
|
||||||
<Typography variant="h6" color="text.secondary">
|
sx={{ mb: 2 }}
|
||||||
{equipment.length === 0
|
action={
|
||||||
? 'Keine Ausrüstung vorhanden'
|
<Button color="inherit" size="small" onClick={fetchData}>
|
||||||
: 'Keine Ausrüstung gefunden'}
|
Erneut versuchen
|
||||||
</Typography>
|
</Button>
|
||||||
</Box>
|
}
|
||||||
)}
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Equipment grid */}
|
{/* Empty states */}
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length === 0 && (
|
||||||
<Grid container spacing={3}>
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
{filtered.map((item) => (
|
<Build sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
<Grid item key={item.id} xs={12} sm={6} md={4} lg={3}>
|
<Typography variant="h6" color="text.secondary">
|
||||||
<EquipmentCard
|
{equipment.length === 0
|
||||||
item={item}
|
? 'Keine Ausrüstung vorhanden'
|
||||||
onClick={(id) => navigate(`/ausruestung/${id}`)}
|
: 'Keine Ausrüstung gefunden'}
|
||||||
/>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment grid */}
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<Grid item key={item.id} xs={12} sm={6} md={4} lg={3}>
|
||||||
|
<EquipmentCard
|
||||||
|
item={item}
|
||||||
|
onClick={(id) => navigate(`/ausruestung/${id}`)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
)}
|
||||||
</Grid>
|
|
||||||
|
{/* FAB for adding new equipment */}
|
||||||
|
{canManageEquipment && (
|
||||||
|
<ChatAwareFab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Ausrüstung hinzufügen"
|
||||||
|
onClick={() => navigate('/ausruestung/neu')}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</ChatAwareFab>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB for adding new equipment */}
|
{tab === 1 && canManageTypes && (
|
||||||
{canManageEquipment && (
|
<AusruestungTypenSettings />
|
||||||
<ChatAwareFab
|
|
||||||
color="primary"
|
|
||||||
aria-label="Ausrüstung hinzufügen"
|
|
||||||
onClick={() => navigate('/ausruestung/neu')}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</ChatAwareFab>
|
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -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 "{deletingTyp?.name}" 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,82 +591,101 @@ function Fahrzeuge() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{tab === 0 && (
|
||||||
variant="outlined"
|
<Button
|
||||||
size="small"
|
variant="outlined"
|
||||||
startIcon={<FileDownload />}
|
size="small"
|
||||||
onClick={handleExportAlerts}
|
startIcon={<FileDownload />}
|
||||||
>
|
onClick={handleExportAlerts}
|
||||||
Prüfungen CSV
|
>
|
||||||
</Button>
|
Prüfungen CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{hasOverdue && (
|
<Tabs
|
||||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
value={tab}
|
||||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
|
onChange={(_e, v) => setSearchParams({ tab: String(v) })}
|
||||||
</Alert>
|
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||||
)}
|
>
|
||||||
|
<Tab label="Übersicht" />
|
||||||
|
{canEditSettings && <Tab label="Einstellungen" />}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<TextField
|
{tab === 0 && (
|
||||||
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
<>
|
||||||
value={search}
|
{hasOverdue && (
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||||
fullWidth
|
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
|
||||||
size="small"
|
</Alert>
|
||||||
sx={{ mb: 3, maxWidth: 480 }}
|
)}
|
||||||
InputProps={{
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Search />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading && (
|
<TextField
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
||||||
<CircularProgress />
|
value={search}
|
||||||
</Box>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
)}
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 3, maxWidth: 480 }}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{!loading && error && (
|
{loading && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
{error}
|
<CircularProgress />
|
||||||
</Alert>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && filtered.length === 0 && (
|
{!loading && error && (
|
||||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
{error}
|
||||||
<Typography variant="h6" color="text.secondary">
|
</Alert>
|
||||||
{vehicles.length === 0
|
)}
|
||||||
? 'Noch keine Fahrzeuge erfasst'
|
|
||||||
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length === 0 && (
|
||||||
<Grid container spacing={3}>
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
{filtered.map((vehicle) => (
|
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
|
<Typography variant="h6" color="text.secondary">
|
||||||
<VehicleCard
|
{vehicles.length === 0
|
||||||
vehicle={vehicle}
|
? 'Noch keine Fahrzeuge erfasst'
|
||||||
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
|
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
|
||||||
warnings={equipmentWarnings.get(vehicle.id) || []}
|
</Typography>
|
||||||
/>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{filtered.map((vehicle) => (
|
||||||
|
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
|
||||||
|
<VehicleCard
|
||||||
|
vehicle={vehicle}
|
||||||
|
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
|
||||||
|
warnings={equipmentWarnings.get(vehicle.id) || []}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
)}
|
||||||
</Grid>
|
|
||||||
|
{isAdmin && (
|
||||||
|
<ChatAwareFab
|
||||||
|
aria-label="Fahrzeug hinzufügen"
|
||||||
|
onClick={() => navigate('/fahrzeuge/neu')}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</ChatAwareFab>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{tab === 1 && canEditSettings && (
|
||||||
<ChatAwareFab
|
<FahrzeugTypenSettings />
|
||||||
aria-label="Fahrzeug hinzufügen"
|
|
||||||
onClick={() => navigate('/fahrzeuge/neu')}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</ChatAwareFab>
|
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -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}` : ''}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user