diff --git a/backend/src/database/migrations/073_checklist_subitems.sql b/backend/src/database/migrations/073_checklist_subitems.sql new file mode 100644 index 0000000..7871415 --- /dev/null +++ b/backend/src/database/migrations/073_checklist_subitems.sql @@ -0,0 +1,11 @@ +-- Add parent_item_id to vorlage items (self-referential) +ALTER TABLE checklist_vorlage_items + ADD COLUMN parent_item_id INT REFERENCES checklist_vorlage_items(id) ON DELETE CASCADE; + +CREATE INDEX idx_vorlage_items_parent ON checklist_vorlage_items(parent_item_id); + +-- Add parent_ausfuehrung_item_id to execution items (self-referential) +ALTER TABLE checklist_ausfuehrung_items + ADD COLUMN parent_ausfuehrung_item_id INT REFERENCES checklist_ausfuehrung_items(id) ON DELETE CASCADE; + +CREATE INDEX idx_ausfuehrung_items_parent ON checklist_ausfuehrung_items(parent_ausfuehrung_item_id); diff --git a/backend/src/services/checklist.service.ts b/backend/src/services/checklist.service.ts index b8d7206..4e129a6 100644 --- a/backend/src/services/checklist.service.ts +++ b/backend/src/services/checklist.service.ts @@ -212,13 +212,14 @@ async function addVorlageItem(vorlageId: number, data: { beschreibung?: string | null; pflicht?: boolean; sort_order?: number; + parent_item_id?: number | null; }) { try { const result = await pool.query( - `INSERT INTO checklist_vorlage_items (vorlage_id, bezeichnung, beschreibung, pflicht, sort_order) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO checklist_vorlage_items (vorlage_id, bezeichnung, beschreibung, pflicht, sort_order, parent_item_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [vorlageId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0] + [vorlageId, data.bezeichnung, data.beschreibung ?? null, data.pflicht ?? true, data.sort_order ?? 0, data.parent_item_id ?? null] ); return result.rows[0]; } catch (error) { @@ -609,17 +610,34 @@ async function startExecution(fahrzeugId: string | null, vorlageId: number, user ); const execution = execResult.rows[0]; - // Copy template items into execution items + // Copy template items into execution items (two-pass to preserve parent-child hierarchy) const vorlageItems = await client.query( `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, [vorlageId] ); + // First pass: insert all items, record the vorlage_item_id → ausfuehrung_item_id mapping + const vorlageToAusfuehrungId = new Map(); for (const item of vorlageItems.rows) { - await client.query( + const inserted = await client.query( `INSERT INTO checklist_ausfuehrung_items (ausfuehrung_id, vorlage_item_id, bezeichnung) - VALUES ($1, $2, $3)`, + VALUES ($1, $2, $3) + RETURNING id`, [execution.id, item.id, item.bezeichnung] ); + vorlageToAusfuehrungId.set(item.id, inserted.rows[0].id); + } + // Second pass: set parent_ausfuehrung_item_id for items that have a parent + for (const item of vorlageItems.rows) { + if (item.parent_item_id != null) { + const parentAusfuehrungId = vorlageToAusfuehrungId.get(item.parent_item_id); + const childAusfuehrungId = vorlageToAusfuehrungId.get(item.id); + if (parentAusfuehrungId && childAusfuehrungId) { + await client.query( + `UPDATE checklist_ausfuehrung_items SET parent_ausfuehrung_item_id = $1 WHERE id = $2`, + [parentAusfuehrungId, childAusfuehrungId] + ); + } + } } // Copy entity-specific items (vehicle or equipment) @@ -714,6 +732,7 @@ async function submitExecution( } // Check if all pflicht items have ergebnis = 'ok' + // Exclude parent items (those that have subitems) — their children carry the pflicht flag const pflichtCheck = await client.query( `SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id FROM checklist_ausfuehrung_items ai @@ -721,7 +740,11 @@ async function submitExecution( LEFT JOIN fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id WHERE ai.ausfuehrung_id = $1 - AND (COALESCE(vi.pflicht, fi.pflicht, aci.pflicht, true) = true)`, + AND (COALESCE(vi.pflicht, fi.pflicht, aci.pflicht, true) = true) + AND NOT EXISTS ( + SELECT 1 FROM checklist_ausfuehrung_items child + WHERE child.parent_ausfuehrung_item_id = ai.id + )`, [id] ); diff --git a/frontend/src/pages/ChecklistAusfuehrung.tsx b/frontend/src/pages/ChecklistAusfuehrung.tsx index 68ff03c..0fca44f 100644 --- a/frontend/src/pages/ChecklistAusfuehrung.tsx +++ b/frontend/src/pages/ChecklistAusfuehrung.tsx @@ -101,10 +101,20 @@ export default function ChecklistAusfuehrung() { // ── Submit ── const submitMutation = useMutation({ - mutationFn: () => checklistenApi.submitExecution(id!, { - items: Object.entries(itemResults).map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })), - notizen: notizen || undefined, - }), + mutationFn: () => { + // Exclude parent items (those that have children) from the submit payload + const parentIds = new Set( + (execution?.items ?? []) + .filter((i) => i.parent_ausfuehrung_item_id != null) + .map((i) => i.parent_ausfuehrung_item_id!) + ); + return checklistenApi.submitExecution(id!, { + items: Object.entries(itemResults) + .filter(([itemId]) => !parentIds.has(Number(itemId))) + .map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })), + notizen: notizen || undefined, + }); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] }); queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] }); @@ -159,55 +169,73 @@ export default function ChecklistAusfuehrung() { const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => { if (groupItems.length === 0) return null; + + const topLevel = groupItems.filter((i) => !i.parent_ausfuehrung_item_id); + const getSubitems = (parentId: number) => groupItems.filter((i) => i.parent_ausfuehrung_item_id === parentId); + + const renderSingleItem = (item: ChecklistAusfuehrungItem, isSubitem: boolean) => { + const result = itemResults[item.id]; + return ( + + + + {item.bezeichnung} + + {isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]} + + {isReadOnly ? ( + + + {result?.kommentar && ( + {result.kommentar} + )} + + ) : ( + + setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')} + > + } label="OK" /> + } label="Nicht OK" /> + } label="N/A" /> + + setItemComment(item.id, e.target.value)} + sx={{ mt: 0.5 }} + /> + + )} + + ); + }; + + const renderItem = (item: ChecklistAusfuehrungItem) => { + const subitems = getSubitems(item.id); + if (subitems.length > 0) { + return ( + + {item.bezeichnung} + {subitems.map((sub) => renderSingleItem(sub, true))} + + ); + } + return renderSingleItem(item, false); + }; + return ( {title} - {groupItems.map((item) => { - const result = itemResults[item.id]; - return ( - - - - {item.bezeichnung} - {/* Indicate pflicht with asterisk - we don't have pflicht on ausfuehrung items directly but could check */} - - {isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]} - - {isReadOnly ? ( - - - {result?.kommentar && ( - {result.kommentar} - )} - - ) : ( - - setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')} - > - } label="OK" /> - } label="Nicht OK" /> - } label="N/A" /> - - setItemComment(item.id, e.target.value)} - sx={{ mt: 0.5 }} - /> - - )} - - ); - })} + {topLevel.map((item) => renderItem(item))} ); }; diff --git a/frontend/src/pages/Checklisten.tsx b/frontend/src/pages/Checklisten.tsx index f356dba..59f5daf 100644 --- a/frontend/src/pages/Checklisten.tsx +++ b/frontend/src/pages/Checklisten.tsx @@ -65,6 +65,7 @@ import { } from '../types/checklist.types'; import type { ChecklistVorlage, + ChecklistVorlageItem, ChecklistAusfuehrung, ChecklistOverviewItem, ChecklistOverviewChecklist, @@ -625,10 +626,18 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }: const [newItem, setNewItem] = useState({ bezeichnung: '', pflicht: false, sort_order: 0 }); const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState<{ bezeichnung: string; pflicht: boolean }>({ bezeichnung: '', pflicht: false }); + const [addingSubitemForId, setAddingSubitemForId] = useState(null); + const [newSubitem, setNewSubitem] = useState<{ bezeichnung: string; pflicht: boolean }>({ bezeichnung: '', pflicht: false }); const addMutation = useMutation({ mutationFn: (data: CreateVorlageItemPayload) => checklistenApi.addVorlageItem(vorlageId, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); showSuccess('Item hinzugefügt'); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['checklisten-vorlage-items', vorlageId] }); + setNewItem({ bezeichnung: '', pflicht: false, sort_order: 0 }); + setAddingSubitemForId(null); + setNewSubitem({ bezeichnung: '', pflicht: false }); + showSuccess('Item hinzugefügt'); + }, onError: () => showError('Fehler beim Hinzufügen'), }); @@ -649,47 +658,88 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }: setEditForm({ bezeichnung: item.bezeichnung, pflicht: item.pflicht }); }; + const topLevelItems = items.filter((i) => !i.parent_item_id); + const getSubitems = (parentId: number) => items.filter((i) => i.parent_item_id === parentId); + + const renderItemRow = (item: ChecklistVorlageItem, isSubitem: boolean) => { + const subitems = isSubitem ? [] : getSubitems(item.id); + return ( + + {editingId === item.id ? ( + + setEditForm((f) => ({ ...f, bezeichnung: e.target.value }))} + sx={{ flexGrow: 1 }} + autoFocus + /> + setEditForm((f) => ({ ...f, pflicht: e.target.checked }))} />} + label="Pflicht" + /> + + + + ) : ( + + + {item.bezeichnung} {item.pflicht && } + + {!isSubitem && ( + + { setAddingSubitemForId(item.id); setNewSubitem({ bezeichnung: '', pflicht: false }); }}> + + + + )} + startEdit(item)}> + deleteMutation.mutate(item.id)}> + + )} + {subitems.map((sub) => renderItemRow(sub, true))} + {addingSubitemForId === item.id && ( + + setNewSubitem((n) => ({ ...n, bezeichnung: e.target.value }))} + sx={{ flexGrow: 1 }} + autoFocus + /> + setNewSubitem((n) => ({ ...n, pflicht: e.target.checked }))} />} + label="Pflicht" + /> + + + + )} + + ); + }; + if (isLoading) return ; return ( Checklisten-Items - {items.map((item) => ( - - {editingId === item.id ? ( - - setEditForm((f) => ({ ...f, bezeichnung: e.target.value }))} - sx={{ flexGrow: 1 }} - autoFocus - /> - setEditForm((f) => ({ ...f, pflicht: e.target.checked }))} />} - label="Pflicht" - /> - - - - ) : ( - - - {item.bezeichnung} {item.pflicht && } - - startEdit(item)}> - deleteMutation.mutate(item.id)}> - - )} - - ))} + {topLevelItems.map((item) => renderItemRow(item, false))} setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} /> setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" /> diff --git a/frontend/src/types/checklist.types.ts b/frontend/src/types/checklist.types.ts index 8975818..6a95405 100644 --- a/frontend/src/types/checklist.types.ts +++ b/frontend/src/types/checklist.types.ts @@ -13,6 +13,7 @@ export interface ChecklistVorlageItem { beschreibung?: string; pflicht: boolean; sort_order: number; + parent_item_id?: number | null; } export interface AusruestungTyp { @@ -61,6 +62,7 @@ export interface ChecklistAusfuehrungItem { bezeichnung: string; ergebnis?: 'ok' | 'nok' | 'na'; kommentar?: string; + parent_ausfuehrung_item_id?: number | null; created_at: string; } @@ -151,6 +153,7 @@ export interface CreateVorlageItemPayload { beschreibung?: string; pflicht?: boolean; sort_order?: number; + parent_item_id?: number | null; } export interface UpdateVorlageItemPayload {