feat: add hierarchical subitems to checklist templates and executions

This commit is contained in:
Matthias Hochmeister
2026-03-28 18:37:36 +01:00
parent 51be3b54f6
commit 893fbe43a0
5 changed files with 209 additions and 94 deletions

View File

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

View File

@@ -212,13 +212,14 @@ async function addVorlageItem(vorlageId: number, data: {
beschreibung?: string | null; beschreibung?: string | null;
pflicht?: boolean; pflicht?: boolean;
sort_order?: number; sort_order?: number;
parent_item_id?: number | null;
}) { }) {
try { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO checklist_vorlage_items (vorlage_id, bezeichnung, beschreibung, pflicht, sort_order) `INSERT INTO checklist_vorlage_items (vorlage_id, bezeichnung, beschreibung, pflicht, sort_order, parent_item_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`, 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]; return result.rows[0];
} catch (error) { } catch (error) {
@@ -609,17 +610,34 @@ async function startExecution(fahrzeugId: string | null, vorlageId: number, user
); );
const execution = execResult.rows[0]; 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( const vorlageItems = await client.query(
`SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`, `SELECT * FROM checklist_vorlage_items WHERE vorlage_id = $1 ORDER BY sort_order ASC, id ASC`,
[vorlageId] [vorlageId]
); );
// First pass: insert all items, record the vorlage_item_id → ausfuehrung_item_id mapping
const vorlageToAusfuehrungId = new Map<number, number>();
for (const item of vorlageItems.rows) { 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) `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] [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) // Copy entity-specific items (vehicle or equipment)
@@ -714,6 +732,7 @@ async function submitExecution(
} }
// Check if all pflicht items have ergebnis = 'ok' // 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( const pflichtCheck = await client.query(
`SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id `SELECT ai.id, ai.ergebnis, ai.vorlage_item_id, ai.fahrzeug_item_id, ai.ausruestung_item_id
FROM checklist_ausfuehrung_items ai 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 fahrzeug_checklist_items fi ON fi.id = ai.fahrzeug_item_id
LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id LEFT JOIN ausruestung_checklist_items aci ON aci.id = ai.ausruestung_item_id
WHERE ai.ausfuehrung_id = $1 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] [id]
); );

View File

@@ -101,10 +101,20 @@ export default function ChecklistAusfuehrung() {
// ── Submit ── // ── Submit ──
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: () => checklistenApi.submitExecution(id!, { mutationFn: () => {
items: Object.entries(itemResults).map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })), // Exclude parent items (those that have children) from the submit payload
notizen: notizen || undefined, 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] }); queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrung', id] });
queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] }); queryClient.invalidateQueries({ queryKey: ['checklisten-ausfuehrungen'] });
@@ -159,55 +169,73 @@ export default function ChecklistAusfuehrung() {
const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => { const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => {
if (groupItems.length === 0) return null; 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 (
<Paper key={item.id} variant="outlined" sx={{ p: 2, mb: 1, ml: isSubitem ? 2 : 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, flexGrow: 1 }}>
{item.bezeichnung}
</Typography>
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
</Box>
{isReadOnly ? (
<Box>
<Chip
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
size="small"
/>
{result?.kommentar && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
)}
</Box>
) : (
<Box>
<RadioGroup
row
value={result?.ergebnis ?? ''}
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
>
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
</RadioGroup>
<TextField
size="small"
placeholder="Kommentar (optional)"
fullWidth
value={result?.kommentar ?? ''}
onChange={(e) => setItemComment(item.id, e.target.value)}
sx={{ mt: 0.5 }}
/>
</Box>
)}
</Paper>
);
};
const renderItem = (item: ChecklistAusfuehrungItem) => {
const subitems = getSubitems(item.id);
if (subitems.length > 0) {
return (
<Box key={item.id} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mt: 1, mb: 0.5 }}>{item.bezeichnung}</Typography>
{subitems.map((sub) => renderSingleItem(sub, true))}
</Box>
);
}
return renderSingleItem(item, false);
};
return ( return (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography>
{groupItems.map((item) => { {topLevel.map((item) => renderItem(item))}
const result = itemResults[item.id];
return (
<Paper key={item.id} variant="outlined" sx={{ p: 2, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, flexGrow: 1 }}>
{item.bezeichnung}
{/* Indicate pflicht with asterisk - we don't have pflicht on ausfuehrung items directly but could check */}
</Typography>
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
</Box>
{isReadOnly ? (
<Box>
<Chip
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
size="small"
/>
{result?.kommentar && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
)}
</Box>
) : (
<Box>
<RadioGroup
row
value={result?.ergebnis ?? ''}
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
>
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
</RadioGroup>
<TextField
size="small"
placeholder="Kommentar (optional)"
fullWidth
value={result?.kommentar ?? ''}
onChange={(e) => setItemComment(item.id, e.target.value)}
sx={{ mt: 0.5 }}
/>
</Box>
)}
</Paper>
);
})}
</Box> </Box>
); );
}; };

View File

@@ -65,6 +65,7 @@ import {
} from '../types/checklist.types'; } from '../types/checklist.types';
import type { import type {
ChecklistVorlage, ChecklistVorlage,
ChecklistVorlageItem,
ChecklistAusfuehrung, ChecklistAusfuehrung,
ChecklistOverviewItem, ChecklistOverviewItem,
ChecklistOverviewChecklist, ChecklistOverviewChecklist,
@@ -625,10 +626,18 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
const [newItem, setNewItem] = useState<CreateVorlageItemPayload>({ bezeichnung: '', pflicht: false, sort_order: 0 }); const [newItem, setNewItem] = useState<CreateVorlageItemPayload>({ bezeichnung: '', pflicht: false, sort_order: 0 });
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<{ bezeichnung: string; pflicht: boolean }>({ bezeichnung: '', pflicht: false }); const [editForm, setEditForm] = useState<{ bezeichnung: string; pflicht: boolean }>({ bezeichnung: '', pflicht: false });
const [addingSubitemForId, setAddingSubitemForId] = useState<number | null>(null);
const [newSubitem, setNewSubitem] = useState<{ bezeichnung: string; pflicht: boolean }>({ bezeichnung: '', pflicht: false });
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: (data: CreateVorlageItemPayload) => checklistenApi.addVorlageItem(vorlageId, data), 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'), onError: () => showError('Fehler beim Hinzufügen'),
}); });
@@ -649,47 +658,88 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
setEditForm({ bezeichnung: item.bezeichnung, pflicht: item.pflicht }); 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 (
<Box key={item.id} sx={{ mb: 0.5 }}>
{editingId === item.id ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, pl: isSubitem ? 4 : 0 }}>
<TextField
size="small"
value={editForm.bezeichnung}
onChange={(e) => setEditForm((f) => ({ ...f, bezeichnung: e.target.value }))}
sx={{ flexGrow: 1 }}
autoFocus
/>
<FormControlLabel
control={<Switch size="small" checked={editForm.pflicht} onChange={(e) => setEditForm((f) => ({ ...f, pflicht: e.target.checked }))} />}
label="Pflicht"
/>
<Button
size="small"
variant="contained"
disabled={!editForm.bezeichnung.trim() || updateMutation.isPending}
onClick={() => updateMutation.mutate({ id: item.id, data: editForm })}
>
Speichern
</Button>
<Button size="small" onClick={() => setEditingId(null)}>Abbrechen</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, pl: isSubitem ? 4 : 0 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{item.bezeichnung} {item.pflicht && <Chip label="Pflicht" size="small" color="warning" sx={{ ml: 0.5 }} />}
</Typography>
{!isSubitem && (
<Tooltip title="Unteritem hinzufügen">
<IconButton size="small" onClick={() => { setAddingSubitemForId(item.id); setNewSubitem({ bezeichnung: '', pflicht: false }); }}>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<IconButton size="small" onClick={() => startEdit(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMutation.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
)}
{subitems.map((sub) => renderItemRow(sub, true))}
{addingSubitemForId === item.id && (
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, pl: 4, alignItems: 'center' }}>
<TextField
size="small"
placeholder="Unteritem..."
value={newSubitem.bezeichnung}
onChange={(e) => setNewSubitem((n) => ({ ...n, bezeichnung: e.target.value }))}
sx={{ flexGrow: 1 }}
autoFocus
/>
<FormControlLabel
control={<Switch size="small" checked={newSubitem.pflicht} onChange={(e) => setNewSubitem((n) => ({ ...n, pflicht: e.target.checked }))} />}
label="Pflicht"
/>
<Button
size="small"
variant="outlined"
disabled={!newSubitem.bezeichnung.trim() || addMutation.isPending}
onClick={() => addMutation.mutate({ ...newSubitem, parent_item_id: item.id })}
>
Hinzufügen
</Button>
<Button size="small" onClick={() => setAddingSubitemForId(null)}>Abbrechen</Button>
</Box>
)}
</Box>
);
};
if (isLoading) return <CircularProgress size={20} />; if (isLoading) return <CircularProgress size={20} />;
return ( return (
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Checklisten-Items</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>Checklisten-Items</Typography>
{items.map((item) => ( {topLevelItems.map((item) => renderItemRow(item, false))}
<Box key={item.id} sx={{ mb: 0.5 }}>
{editingId === item.id ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
value={editForm.bezeichnung}
onChange={(e) => setEditForm((f) => ({ ...f, bezeichnung: e.target.value }))}
sx={{ flexGrow: 1 }}
autoFocus
/>
<FormControlLabel
control={<Switch size="small" checked={editForm.pflicht} onChange={(e) => setEditForm((f) => ({ ...f, pflicht: e.target.checked }))} />}
label="Pflicht"
/>
<Button
size="small"
variant="contained"
disabled={!editForm.bezeichnung.trim() || updateMutation.isPending}
onClick={() => updateMutation.mutate({ id: item.id, data: editForm })}
>
Speichern
</Button>
<Button size="small" onClick={() => setEditingId(null)}>Abbrechen</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{item.bezeichnung} {item.pflicht && <Chip label="Pflicht" size="small" color="warning" sx={{ ml: 0.5 }} />}
</Typography>
<IconButton size="small" onClick={() => startEdit(item)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteMutation.mutate(item.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
)}
</Box>
))}
<Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, mt: 1, alignItems: 'center' }}>
<TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} /> <TextField size="small" placeholder="Neues Item..." value={newItem.bezeichnung} onChange={(e) => setNewItem((n) => ({ ...n, bezeichnung: e.target.value }))} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" /> <FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />

View File

@@ -13,6 +13,7 @@ export interface ChecklistVorlageItem {
beschreibung?: string; beschreibung?: string;
pflicht: boolean; pflicht: boolean;
sort_order: number; sort_order: number;
parent_item_id?: number | null;
} }
export interface AusruestungTyp { export interface AusruestungTyp {
@@ -61,6 +62,7 @@ export interface ChecklistAusfuehrungItem {
bezeichnung: string; bezeichnung: string;
ergebnis?: 'ok' | 'nok' | 'na'; ergebnis?: 'ok' | 'nok' | 'na';
kommentar?: string; kommentar?: string;
parent_ausfuehrung_item_id?: number | null;
created_at: string; created_at: string;
} }
@@ -151,6 +153,7 @@ export interface CreateVorlageItemPayload {
beschreibung?: string; beschreibung?: string;
pflicht?: boolean; pflicht?: boolean;
sort_order?: number; sort_order?: number;
parent_item_id?: number | null;
} }
export interface UpdateVorlageItemPayload { export interface UpdateVorlageItemPayload {