feat: add hierarchical subitems to checklist templates and executions
This commit is contained in:
11
backend/src/database/migrations/073_checklist_subitems.sql
Normal file
11
backend/src/database/migrations/073_checklist_subitems.sql
Normal 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);
|
||||
@@ -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<number, number>();
|
||||
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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
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,17 +169,17 @@ export default function ChecklistAusfuehrung() {
|
||||
|
||||
const renderItemGroup = (groupItems: ChecklistAusfuehrungItem[], title: string) => {
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>{title}</Typography>
|
||||
{groupItems.map((item) => {
|
||||
|
||||
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 }}>
|
||||
<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}
|
||||
{/* 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>
|
||||
@@ -207,7 +217,25 @@ export default function ChecklistAusfuehrung() {
|
||||
)}
|
||||
</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>
|
||||
{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,15 +658,15 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
|
||||
setEditForm({ bezeichnung: item.bezeichnung, pflicht: item.pflicht });
|
||||
};
|
||||
|
||||
if (isLoading) return <CircularProgress size={20} />;
|
||||
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>
|
||||
<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 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, pl: isSubitem ? 4 : 0 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={editForm.bezeichnung}
|
||||
@@ -680,16 +689,57 @@ function VorlageItemsSection({ vorlageId, queryClient, showSuccess, showError }:
|
||||
<Button size="small" onClick={() => setEditingId(null)}>Abbrechen</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<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>
|
||||
{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