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

@@ -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 (
<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 (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography>
{groupItems.map((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>
);
})}
{topLevel.map((item) => renderItem(item))}
</Box>
);
};

View File

@@ -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<CreateVorlageItemPayload>({ bezeichnung: '', pflicht: false, sort_order: 0 });
const [editingId, setEditingId] = useState<number | null>(null);
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({
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 (
<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} />;
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Checklisten-Items</Typography>
{items.map((item) => (
<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>
))}
{topLevelItems.map((item) => renderItemRow(item, false))}
<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 }} />
<FormControlLabel control={<Switch size="small" checked={newItem.pflicht} onChange={(e) => setNewItem((n) => ({ ...n, pflicht: e.target.checked }))} />} label="Pflicht" />