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

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

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

View File

@@ -32,26 +32,85 @@ function calculateNextDueDate(intervall: string | null, intervall_tage: number |
}
}
// Subquery fragments for junction table arrays
const JUNCTION_SUBQUERIES = `
ARRAY(SELECT fahrzeug_typ_id FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) AS fahrzeug_typ_ids,
ARRAY(SELECT fahrzeug_id::text FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id) AS fahrzeug_ids,
ARRAY(SELECT ausruestung_typ_id FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id) AS ausruestung_typ_ids,
ARRAY(SELECT ausruestung_id::text FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id) AS ausruestung_ids,
ARRAY(SELECT ft.name FROM checklist_vorlage_fahrzeug_typen cvft JOIN fahrzeug_typen ft ON ft.id = cvft.fahrzeug_typ_id WHERE cvft.vorlage_id = v.id) AS fahrzeug_typ_names,
ARRAY(SELECT f.bezeichnung FROM checklist_vorlage_fahrzeuge cvf JOIN fahrzeuge f ON f.id = cvf.fahrzeug_id WHERE cvf.vorlage_id = v.id) AS fahrzeug_names,
ARRAY(SELECT at2.name FROM checklist_vorlage_ausruestung_typen cvat JOIN ausruestung_typen at2 ON at2.id = cvat.ausruestung_typ_id WHERE cvat.vorlage_id = v.id) AS ausruestung_typ_names,
ARRAY(SELECT a.bezeichnung FROM checklist_vorlage_ausruestung cva JOIN ausruestung a ON a.id = cva.ausruestung_id WHERE cva.vorlage_id = v.id) AS ausruestung_names
`;
// Helper: insert rows into junction tables within a transaction client
async function insertJunctionRows(client: any, vorlageId: number, data: {
fahrzeug_typ_ids?: number[];
fahrzeug_ids?: string[];
ausruestung_typ_ids?: number[];
ausruestung_ids?: string[];
}) {
if (data.fahrzeug_typ_ids?.length) {
for (const id of data.fahrzeug_typ_ids) {
await client.query(
`INSERT INTO checklist_vorlage_fahrzeug_typen (vorlage_id, fahrzeug_typ_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[vorlageId, id]
);
}
}
if (data.fahrzeug_ids?.length) {
for (const id of data.fahrzeug_ids) {
await client.query(
`INSERT INTO checklist_vorlage_fahrzeuge (vorlage_id, fahrzeug_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[vorlageId, id]
);
}
}
if (data.ausruestung_typ_ids?.length) {
for (const id of data.ausruestung_typ_ids) {
await client.query(
`INSERT INTO checklist_vorlage_ausruestung_typen (vorlage_id, ausruestung_typ_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[vorlageId, id]
);
}
}
if (data.ausruestung_ids?.length) {
for (const id of data.ausruestung_ids) {
await client.query(
`INSERT INTO checklist_vorlage_ausruestung (vorlage_id, ausruestung_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[vorlageId, id]
);
}
}
}
// Helper: delete all junction rows for a vorlage
async function deleteJunctionRows(client: any, vorlageId: number) {
await client.query(`DELETE FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = $1`, [vorlageId]);
await client.query(`DELETE FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = $1`, [vorlageId]);
await client.query(`DELETE FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = $1`, [vorlageId]);
await client.query(`DELETE FROM checklist_vorlage_ausruestung WHERE vorlage_id = $1`, [vorlageId]);
}
// "Global" template condition: no rows in any junction table
const GLOBAL_TEMPLATE_CONDITION = `
NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id)
`;
// ---------------------------------------------------------------------------
// Vorlagen (Templates)
// ---------------------------------------------------------------------------
async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_id?: number; aktiv?: boolean }) {
async function getVorlagen(filter?: { aktiv?: boolean }) {
try {
const conditions: string[] = [];
const values: any[] = [];
let idx = 1;
if (filter?.fahrzeug_typ_id !== undefined) {
conditions.push(`v.fahrzeug_typ_id = $${idx}`);
values.push(filter.fahrzeug_typ_id);
idx++;
}
if (filter?.ausruestung_typ_id !== undefined) {
conditions.push(`v.ausruestung_typ_id = $${idx}`);
values.push(filter.ausruestung_typ_id);
idx++;
}
if (filter?.aktiv !== undefined) {
conditions.push(`v.aktiv = $${idx}`);
values.push(filter.aktiv);
@@ -61,11 +120,8 @@ async function getVorlagen(filter?: { fahrzeug_typ_id?: number; ausruestung_typ_
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT v.*,
ft.name AS fahrzeug_typ_name,
at.name AS ausruestung_typ_name
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
${where}
ORDER BY v.name ASC`,
values
@@ -81,11 +137,8 @@ async function getVorlageById(id: number) {
try {
const vorlageResult = await pool.query(
`SELECT v.*,
ft.name AS fahrzeug_typ_name,
at.name AS ausruestung_typ_name
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
WHERE v.id = $1`,
[id]
);
@@ -106,74 +159,93 @@ async function getVorlageById(id: number) {
async function createVorlage(data: {
name: string;
fahrzeug_typ_id?: number | null;
fahrzeug_id?: string | null;
ausruestung_typ_id?: number | null;
ausruestung_id?: string | null;
fahrzeug_typ_ids?: number[];
fahrzeug_ids?: string[];
ausruestung_typ_ids?: number[];
ausruestung_ids?: string[];
intervall?: string | null;
intervall_tage?: number | null;
beschreibung?: string | null;
}) {
const client = await pool.connect();
try {
const result = await pool.query(
`INSERT INTO checklist_vorlagen (name, fahrzeug_typ_id, fahrzeug_id, ausruestung_typ_id, ausruestung_id, intervall, intervall_tage, beschreibung)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
await client.query('BEGIN');
const result = await client.query(
`INSERT INTO checklist_vorlagen (name, intervall, intervall_tage, beschreibung)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
data.name,
data.fahrzeug_typ_id ?? null,
data.fahrzeug_id ?? null,
data.ausruestung_typ_id ?? null,
data.ausruestung_id ?? null,
data.intervall ?? null,
data.intervall_tage ?? null,
data.beschreibung ?? null,
]
[data.name, data.intervall ?? null, data.intervall_tage ?? null, data.beschreibung ?? null]
);
return result.rows[0];
const vorlage = result.rows[0];
await insertJunctionRows(client, vorlage.id, data);
await client.query('COMMIT');
return getVorlageById(vorlage.id);
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('ChecklistService.createVorlage failed', { error });
throw new Error('Vorlage konnte nicht erstellt werden');
} finally {
client.release();
}
}
async function updateVorlage(id: number, data: {
name?: string;
fahrzeug_typ_id?: number | null;
fahrzeug_id?: string | null;
ausruestung_typ_id?: number | null;
ausruestung_id?: string | null;
fahrzeug_typ_ids?: number[];
fahrzeug_ids?: string[];
ausruestung_typ_ids?: number[];
ausruestung_ids?: string[];
intervall?: string | null;
intervall_tage?: number | null;
beschreibung?: string | null;
aktiv?: boolean;
}) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Build SET clauses for scalar fields only
const setClauses: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.name !== undefined) { setClauses.push(`name = $${idx}`); values.push(data.name); idx++; }
if ('fahrzeug_typ_id' in data) { setClauses.push(`fahrzeug_typ_id = $${idx}`); values.push(data.fahrzeug_typ_id); idx++; }
if ('fahrzeug_id' in data) { setClauses.push(`fahrzeug_id = $${idx}`); values.push(data.fahrzeug_id); idx++; }
if ('ausruestung_typ_id' in data) { setClauses.push(`ausruestung_typ_id = $${idx}`); values.push(data.ausruestung_typ_id); idx++; }
if ('ausruestung_id' in data) { setClauses.push(`ausruestung_id = $${idx}`); values.push(data.ausruestung_id); idx++; }
if ('intervall' in data) { setClauses.push(`intervall = $${idx}`); values.push(data.intervall); idx++; }
if ('intervall_tage' in data) { setClauses.push(`intervall_tage = $${idx}`); values.push(data.intervall_tage); idx++; }
if ('beschreibung' in data) { setClauses.push(`beschreibung = $${idx}`); values.push(data.beschreibung); idx++; }
if (data.aktiv !== undefined) { setClauses.push(`aktiv = $${idx}`); values.push(data.aktiv); idx++; }
if (setClauses.length === 0) return getVorlageById(id);
if (setClauses.length > 0) {
values.push(id);
await client.query(
`UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx}`,
values
);
}
values.push(id);
const result = await pool.query(
`UPDATE checklist_vorlagen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
return result.rows[0] || null;
// Replace junction rows if any array key is present
const hasJunctionData = 'fahrzeug_typ_ids' in data || 'fahrzeug_ids' in data
|| 'ausruestung_typ_ids' in data || 'ausruestung_ids' in data;
if (hasJunctionData) {
await deleteJunctionRows(client, id);
await insertJunctionRows(client, id, {
fahrzeug_typ_ids: data.fahrzeug_typ_ids ?? [],
fahrzeug_ids: data.fahrzeug_ids ?? [],
ausruestung_typ_ids: data.ausruestung_typ_ids ?? [],
ausruestung_ids: data.ausruestung_ids ?? [],
});
}
await client.query('COMMIT');
return getVorlageById(id);
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('ChecklistService.updateVorlage failed', { error, id });
throw new Error('Vorlage konnte nicht aktualisiert werden');
} finally {
client.release();
}
}
@@ -445,25 +517,31 @@ async function deleteEquipmentItem(id: number) {
}
// ---------------------------------------------------------------------------
// Templates for a specific vehicle (via type junction)
// Templates for a specific vehicle (via junction tables)
// ---------------------------------------------------------------------------
async function getTemplatesForVehicle(fahrzeugId: string) {
try {
// Templates that match the vehicle directly, by type, or global (no assignment)
const result = await pool.query(
`SELECT DISTINCT v.*, ft.name AS fahrzeug_typ_name
`SELECT DISTINCT v.*,
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v
LEFT JOIN fahrzeug_typen ft ON ft.id = v.fahrzeug_typ_id
WHERE v.aktiv = true
AND v.ausruestung_id IS NULL
AND v.ausruestung_typ_id IS NULL
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id)
AND (
v.fahrzeug_id = $1
OR v.fahrzeug_typ_id IN (
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1
EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge cvf WHERE cvf.vorlage_id = v.id AND cvf.fahrzeug_id = $1)
OR EXISTS (
SELECT 1 FROM checklist_vorlage_fahrzeug_typen cvft
WHERE cvft.vorlage_id = v.id
AND cvft.fahrzeug_typ_id IN (SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = $1)
)
OR (
NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = v.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = v.id)
)
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL)
)
ORDER BY v.name ASC`,
[fahrzeugId]
@@ -486,22 +564,26 @@ async function getTemplatesForVehicle(fahrzeugId: string) {
}
// ---------------------------------------------------------------------------
// Templates for a specific equipment item (via type junction)
// Templates for a specific equipment item (via junction tables)
// ---------------------------------------------------------------------------
async function getTemplatesForEquipment(ausruestungId: string) {
try {
const result = await pool.query(
`SELECT DISTINCT v.*, at.name AS ausruestung_typ_name
`SELECT DISTINCT v.*,
${JUNCTION_SUBQUERIES}
FROM checklist_vorlagen v
LEFT JOIN ausruestung_typen at ON at.id = v.ausruestung_typ_id
WHERE v.aktiv = true
AND (
v.ausruestung_id = $1
OR v.ausruestung_typ_id IN (
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = v.id AND cva.ausruestung_id = $1)
OR EXISTS (
SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat
WHERE cvat.vorlage_id = v.id
AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = $1)
)
OR (
${GLOBAL_TEMPLATE_CONDITION}
)
OR (v.fahrzeug_typ_id IS NULL AND v.fahrzeug_id IS NULL AND v.ausruestung_typ_id IS NULL AND v.ausruestung_id IS NULL)
)
ORDER BY v.name ASC`,
[ausruestungId]
@@ -530,7 +612,6 @@ async function getTemplatesForEquipment(ausruestungId: string) {
async function getOverviewItems() {
try {
// All vehicles with their assigned templates (direct, by type, or global)
// LEFT JOIN faelligkeit so unexecuted templates still appear
const vehiclesResult = await pool.query(`
SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name,
json_agg(DISTINCT jsonb_build_object(
@@ -541,14 +622,21 @@ async function getOverviewItems() {
)) AS checklists
FROM fahrzeuge f
JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.ausruestung_id IS NULL
AND cv.ausruestung_typ_id IS NULL
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
AND (
cv.fahrzeug_id = f.id
OR cv.fahrzeug_typ_id IN (
SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = f.id
EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge cvf WHERE cvf.vorlage_id = cv.id AND cvf.fahrzeug_id = f.id)
OR EXISTS (
SELECT 1 FROM checklist_vorlage_fahrzeug_typen cvft
WHERE cvft.vorlage_id = cv.id
AND cvft.fahrzeug_typ_id IN (SELECT fahrzeug_typ_id FROM fahrzeug_fahrzeug_typen WHERE fahrzeug_id = f.id)
)
OR (
NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
)
OR (cv.fahrzeug_id IS NULL AND cv.fahrzeug_typ_id IS NULL)
)
LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.fahrzeug_id = f.id
WHERE f.deleted_at IS NULL
@@ -567,14 +655,19 @@ async function getOverviewItems() {
)) AS checklists
FROM ausruestung a
JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.fahrzeug_id IS NULL
AND cv.fahrzeug_typ_id IS NULL
AND (
cv.ausruestung_id = a.id
OR cv.ausruestung_typ_id IN (
SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id)
OR EXISTS (
SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat
WHERE cvat.vorlage_id = cv.id
AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id)
)
OR (
NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
)
OR (cv.ausruestung_id IS NULL AND cv.ausruestung_typ_id IS NULL)
)
LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id
WHERE a.deleted_at IS NULL

View File

@@ -157,6 +157,34 @@ class CleanupService {
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> {
if (!confirm) {
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');