feat(checklisten): rework checklist scheduling, overview, and execution UI

This commit is contained in:
Matthias Hochmeister
2026-04-20 18:37:00 +02:00
parent c55ec55e1b
commit 9410441ce2

View File

@@ -110,18 +110,20 @@ function getAssignmentLabel(v: ChecklistVorlage): string {
return 'Global';
}
function getDueColor(nextDue?: string | null, intervall?: string | null): 'error' | 'warning' | 'success' | 'default' {
function getDueColor(nextDue?: string | null, intervall?: string | null, istFaellig?: boolean): 'error' | 'warning' | 'success' | 'default' {
if (!nextDue) return intervall ? 'warning' : 'default'; // has recurrence but never run → warn; no recurrence → neutral
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
if (daysUntil < 0) return 'error';
if (daysUntil < 0) return 'error'; // overdue
if (!istFaellig) return 'success'; // done for current period
if (daysUntil <= 3) return 'warning';
return 'success';
return 'default';
}
function getDueLabel(nextDue?: string | null, intervall?: string | null): string {
function getDueLabel(nextDue?: string | null, intervall?: string | null, istFaellig?: boolean): string {
if (!nextDue) return intervall ? 'Noch nie ausgeführt' : 'Manuell';
const daysUntil = Math.ceil((new Date(nextDue).getTime() - Date.now()) / 86400000);
if (daysUntil < 0) return `${Math.abs(daysUntil)}d überfällig`;
if (!istFaellig) return `Erledigt — nächste in ${daysUntil}d`;
if (daysUntil === 0) return 'Heute fällig';
return `in ${daysUntil}d fällig`;
}
@@ -257,73 +259,9 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
return <Alert severity="info" sx={{ mt: 1 }}>Keine Checklisten zugewiesen</Alert>;
}
// Flatten all checklists and detect open checks
const allChecklists = [
...vehicles.flatMap((v) => v.checklists.map((cl) => ({ ...cl, targetName: v.name, targetId: v.id, targetType: 'fahrzeug' as const }))),
...equipment.flatMap((e) => e.checklists.map((cl) => ({ ...cl, targetName: e.name, targetId: e.id, targetType: 'ausruestung' as const }))),
];
const openChecks = allChecklists.filter((cl) => cl.ist_faellig);
const hasOpenChecks = openChecks.length > 0;
// ── Open checks flat list ──
if (hasOpenChecks) {
return (
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>
Offene Prüfungen ({openChecks.length})
</Typography>
<List disablePadding>
{openChecks.map((cl, idx) => {
const color = getDueColor(cl.next_due, cl.intervall);
const label = getDueLabel(cl.next_due, cl.intervall);
const param = cl.ausruestung_id
? `ausruestung=${cl.ausruestung_id}`
: cl.targetType === 'ausruestung'
? `ausruestung=${cl.targetId}`
: `fahrzeug=${cl.targetId}`;
const primaryText = cl.targetName + ' — ' + cl.vorlage_name + (cl.ausruestung_name ? ` (${cl.ausruestung_name})` : '');
const secondaryText = cl.letzte_ausfuehrung_am
? `Letzte Prüfung: ${formatDate(cl.letzte_ausfuehrung_am)}`
: 'Noch nie geprüft';
return (
<ListItemButton
key={`${cl.targetId}-${cl.vorlage_id}-${cl.ausruestung_id ?? ''}`}
onClick={() => canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
sx={{
py: 1,
px: 2,
bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent',
cursor: canExecute ? 'pointer' : 'default',
'&:hover': canExecute ? undefined : { bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent' },
}}
>
<ListItemText
primary={primaryText}
secondary={secondaryText}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Chip
label={label}
color={color}
size="small"
variant="outlined"
sx={{ ml: 1, pointerEvents: 'none' }}
/>
{canExecute && <PlayArrow fontSize="small" color="action" sx={{ ml: 1, opacity: 0.5 }} />}
</ListItemButton>
);
})}
</List>
</Box>
);
}
// ── Normal accordion view (no open checks) ──
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung', index: number) => {
const color = getDueColor(cl.next_due, cl.intervall);
const label = getDueLabel(cl.next_due, cl.intervall);
const color = getDueColor(cl.next_due, cl.intervall, cl.ist_faellig);
const label = getDueLabel(cl.next_due, cl.intervall, cl.ist_faellig);
const param = cl.ausruestung_id
? `ausruestung=${cl.ausruestung_id}`
: type === 'fahrzeug'
@@ -358,7 +296,7 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
variant="outlined"
sx={{ ml: 1, pointerEvents: 'none' }}
/>
{canExecute && <PlayArrow fontSize="small" color="action" sx={{ ml: 1, opacity: 0.5 }} />}
{canExecute && cl.ist_faellig && <PlayArrow fontSize="small" color="action" sx={{ ml: 1, opacity: 0.5 }} />}
</ListItemButton>
);
};
@@ -376,16 +314,16 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
{icon} {title}
</Typography>
{items.map((item) => {
const totalDue = item.checklists.length;
const hasOverdue = item.checklists.some((cl) => getDueColor(cl.next_due, cl.intervall) === 'error');
const badgeColor = hasOverdue ? 'error' : item.checklists.some((cl) => getDueColor(cl.next_due, cl.intervall) === 'warning') ? 'warning' : 'default';
const openCount = item.checklists.filter((cl) => cl.ist_faellig).length;
const hasOverdue = item.checklists.some((cl) => getDueColor(cl.next_due, cl.intervall, cl.ist_faellig) === 'error');
const badgeColor = hasOverdue ? 'error' : openCount > 0 ? 'warning' : 'success';
return (
<Accordion key={item.id} variant="outlined" disableGutters>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%' }}>
<Typography variant="subtitle1">{item.name}</Typography>
<Badge
badgeContent={totalDue}
badgeContent={openCount > 0 ? openCount : undefined}
color={badgeColor as any}
sx={{ ml: 'auto', mr: 2 }}
/>