feat: add hierarchical subitems to checklist templates and executions
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user