843 lines
28 KiB
TypeScript
843 lines
28 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogContentText,
|
|
DialogTitle,
|
|
Divider,
|
|
FormControl,
|
|
Grid,
|
|
IconButton,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Tab,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Tabs,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from '@mui/material';
|
|
import {
|
|
Add,
|
|
ArrowBack,
|
|
Build,
|
|
CheckCircle,
|
|
DeleteOutline,
|
|
Edit,
|
|
Error as ErrorIcon,
|
|
History,
|
|
MoreHoriz,
|
|
PauseCircle,
|
|
RemoveCircle,
|
|
Star,
|
|
Verified,
|
|
Warning,
|
|
} from '@mui/icons-material';
|
|
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
|
import { equipmentApi } from '../services/equipment';
|
|
import { fromGermanDate } from '../utils/dateInput';
|
|
import {
|
|
AusruestungDetail,
|
|
AusruestungWartungslog,
|
|
AusruestungWartungslogArt,
|
|
AusruestungStatus,
|
|
AusruestungStatusLabel,
|
|
UpdateAusruestungStatusPayload,
|
|
CreateAusruestungWartungslogPayload,
|
|
} from '../types/equipment.types';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useNotification } from '../contexts/NotificationContext';
|
|
|
|
// -- Tab Panel ----------------------------------------------------------------
|
|
|
|
interface TabPanelProps {
|
|
children?: React.ReactNode;
|
|
index: number;
|
|
value: number;
|
|
}
|
|
|
|
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
|
|
<div role="tabpanel" hidden={value !== index}>
|
|
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
|
</div>
|
|
);
|
|
|
|
// -- Status config ------------------------------------------------------------
|
|
|
|
const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = {
|
|
[AusruestungStatus.Einsatzbereit]: <CheckCircle color="success" />,
|
|
[AusruestungStatus.Beschaedigt]: <ErrorIcon color="error" />,
|
|
[AusruestungStatus.InWartung]: <PauseCircle color="warning" />,
|
|
[AusruestungStatus.AusserDienst]: <RemoveCircle color="action" />,
|
|
};
|
|
|
|
const STATUS_CHIP_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
|
|
[AusruestungStatus.Einsatzbereit]: 'success',
|
|
[AusruestungStatus.Beschaedigt]: 'error',
|
|
[AusruestungStatus.InWartung]: 'warning',
|
|
[AusruestungStatus.AusserDienst]: 'default',
|
|
};
|
|
|
|
// -- Date helpers -------------------------------------------------------------
|
|
|
|
function fmtDate(iso: string | null | undefined): string {
|
|
if (!iso) return '---';
|
|
return new Date(iso).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
});
|
|
}
|
|
|
|
function fmtDatetime(iso: string | null | undefined): string {
|
|
if (!iso) return '---';
|
|
return new Date(iso).toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
// -- Status History Section ---------------------------------------------------
|
|
|
|
const StatusHistorySection: React.FC<{ equipmentId: string }> = ({ equipmentId }) => {
|
|
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
equipmentApi.getStatusHistory(equipmentId)
|
|
.then(setHistory)
|
|
.catch(() => setHistory([]))
|
|
.finally(() => setLoading(false));
|
|
}, [equipmentId]);
|
|
|
|
if (loading || history.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<History fontSize="small" /> Status-Verlauf
|
|
</Typography>
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Datum</TableCell>
|
|
<TableCell>Von</TableCell>
|
|
<TableCell>Nach</TableCell>
|
|
<TableCell>Bemerkung</TableCell>
|
|
<TableCell>Geändert von</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{history.map((h, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={AusruestungStatusLabel[h.alter_status as AusruestungStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as AusruestungStatus] || 'default'} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={AusruestungStatusLabel[h.neuer_status as AusruestungStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as AusruestungStatus] || 'default'} />
|
|
</TableCell>
|
|
<TableCell>{h.bemerkung || '—'}</TableCell>
|
|
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// -- Wartungslog Art config ---------------------------------------------------
|
|
|
|
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
|
|
'Prüfung': 'info',
|
|
'Reparatur': 'warning',
|
|
'Sonstiges': 'default',
|
|
};
|
|
|
|
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
|
'Prüfung': <Verified color="info" />,
|
|
'Reparatur': <Build color="warning" />,
|
|
'Sonstiges': <MoreHoriz color="action" />,
|
|
default: <Build color="action" />,
|
|
};
|
|
|
|
const ERGEBNIS_CHIP_COLOR: Record<string, 'success' | 'warning' | 'error'> = {
|
|
bestanden: 'success',
|
|
bestanden_mit_maengeln: 'warning',
|
|
nicht_bestanden: 'error',
|
|
};
|
|
|
|
const ERGEBNIS_LABEL: Record<string, string> = {
|
|
bestanden: 'Bestanden',
|
|
bestanden_mit_maengeln: 'Bestanden (mit Mängeln)',
|
|
nicht_bestanden: 'Nicht bestanden',
|
|
};
|
|
|
|
// -- Uebersicht Tab -----------------------------------------------------------
|
|
|
|
interface UebersichtTabProps {
|
|
equipment: AusruestungDetail;
|
|
onStatusUpdated: () => void;
|
|
canChangeStatus: boolean;
|
|
}
|
|
|
|
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
|
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
|
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
|
|
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
|
|
const openDialog = () => {
|
|
setNewStatus(equipment.status);
|
|
setBemerkung(equipment.status_bemerkung ?? '');
|
|
setSaveError(null);
|
|
setStatusDialogOpen(true);
|
|
};
|
|
|
|
const closeDialog = () => {
|
|
setSaveError(null);
|
|
setStatusDialogOpen(false);
|
|
};
|
|
|
|
const handleSaveStatus = async () => {
|
|
try {
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
const payload: UpdateAusruestungStatusPayload = { status: newStatus, bemerkung };
|
|
await equipmentApi.updateStatus(equipment.id, payload);
|
|
setStatusDialogOpen(false);
|
|
onStatusUpdated();
|
|
} catch (err: any) {
|
|
setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const isBeschaedigt = equipment.status === AusruestungStatus.Beschaedigt;
|
|
|
|
// Inspection deadline
|
|
const pruefTage = equipment.pruefung_tage_bis_faelligkeit;
|
|
|
|
// Data grid fields
|
|
const dataFields: { label: string; value: React.ReactNode }[] = [
|
|
{ label: 'Kategorie', value: equipment.kategorie_name },
|
|
{ label: 'Seriennummer', value: equipment.seriennummer ?? '---' },
|
|
{ label: 'Inventarnummer', value: equipment.inventarnummer ?? '---' },
|
|
{ label: 'Hersteller', value: equipment.hersteller ?? '---' },
|
|
{ label: 'Baujahr', value: equipment.baujahr ?? '---' },
|
|
{
|
|
label: 'Fahrzeug',
|
|
value: equipment.fahrzeug_id ? (
|
|
<Typography
|
|
component={RouterLink}
|
|
to={`/fahrzeuge/${equipment.fahrzeug_id}`}
|
|
variant="body1"
|
|
sx={{ color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
|
>
|
|
{equipment.fahrzeug_bezeichnung}
|
|
</Typography>
|
|
) : '---',
|
|
},
|
|
...(!equipment.fahrzeug_id ? [{ label: 'Standort', value: equipment.standort || '---' }] : []),
|
|
{
|
|
label: 'Wichtig',
|
|
value: equipment.ist_wichtig ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<Star fontSize="small" color="warning" /> Ja
|
|
</Box>
|
|
) : 'Nein',
|
|
},
|
|
{
|
|
label: 'Prüfintervall',
|
|
value: equipment.pruef_intervall_monate
|
|
? `${equipment.pruef_intervall_monate} Monate`
|
|
: '---',
|
|
},
|
|
{ label: 'Letzte Prüfung', value: fmtDate(equipment.letzte_pruefung_am) },
|
|
{
|
|
label: 'Nächste Prüfung',
|
|
value: equipment.naechste_pruefung_am ? (
|
|
<Box>
|
|
<Typography variant="body1">{fmtDate(equipment.naechste_pruefung_am)}</Typography>
|
|
{pruefTage !== null && pruefTage < 0 && (
|
|
<Chip
|
|
size="small"
|
|
color="error"
|
|
icon={<Warning fontSize="small" />}
|
|
label={`${Math.abs(pruefTage)} Tage überfällig`}
|
|
sx={{ mt: 0.5 }}
|
|
/>
|
|
)}
|
|
{pruefTage !== null && pruefTage >= 0 && pruefTage <= 30 && (
|
|
<Chip
|
|
size="small"
|
|
color="warning"
|
|
label={`in ${pruefTage} Tagen`}
|
|
sx={{ mt: 0.5 }}
|
|
/>
|
|
)}
|
|
{pruefTage !== null && pruefTage > 30 && (
|
|
<Chip
|
|
size="small"
|
|
color="success"
|
|
label={`in ${pruefTage} Tagen`}
|
|
sx={{ mt: 0.5 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
) : (
|
|
<Chip size="small" color="default" label="Nicht festgelegt" />
|
|
),
|
|
},
|
|
{ label: 'Bemerkung', value: equipment.bemerkung ?? '---' },
|
|
];
|
|
|
|
return (
|
|
<Box>
|
|
{isBeschaedigt && (
|
|
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 2 }}>
|
|
<strong>Beschädigt</strong> --- dieses Gerät ist nicht einsatzbereit.
|
|
{equipment.status_bemerkung && ` Bemerkung: ${equipment.status_bemerkung}`}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Status panel */}
|
|
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
{STATUS_ICONS[equipment.status]}
|
|
<Box>
|
|
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
|
|
<Chip
|
|
label={AusruestungStatusLabel[equipment.status]}
|
|
color={STATUS_CHIP_COLOR[equipment.status]}
|
|
size="small"
|
|
/>
|
|
{equipment.status_bemerkung && (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
{equipment.status_bemerkung}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
{canChangeStatus && (
|
|
<Button variant="outlined" size="small" onClick={openDialog}>
|
|
Status ändern
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{/* Equipment data grid */}
|
|
<Grid container spacing={2}>
|
|
{dataFields.map(({ label, value }) => (
|
|
<Grid item xs={12} sm={6} md={4} key={label}>
|
|
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
|
{label}
|
|
</Typography>
|
|
{typeof value === 'string' || typeof value === 'number' ? (
|
|
<Typography variant="body1">{value}</Typography>
|
|
) : (
|
|
<Box>{value}</Box>
|
|
)}
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Status change dialog */}
|
|
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Gerätestatus ändern</DialogTitle>
|
|
<DialogContent>
|
|
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
|
<FormControl fullWidth sx={{ mb: 2, mt: 1 }}>
|
|
<InputLabel id="status-select-label">Neuer Status</InputLabel>
|
|
<Select
|
|
labelId="status-select-label"
|
|
label="Neuer Status"
|
|
value={newStatus}
|
|
onChange={(e) => setNewStatus(e.target.value as AusruestungStatus)}
|
|
>
|
|
{Object.values(AusruestungStatus).map((s) => (
|
|
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
label="Bemerkung (optional)"
|
|
fullWidth
|
|
multiline
|
|
rows={3}
|
|
value={bemerkung}
|
|
onChange={(e) => setBemerkung(e.target.value)}
|
|
placeholder="z.B. Gerät zur Reparatur eingeschickt, voraussichtlich ab 01.03. wieder einsatzbereit"
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeDialog}>Abbrechen</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSaveStatus}
|
|
disabled={saving}
|
|
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
|
>
|
|
Speichern
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<StatusHistorySection equipmentId={equipment.id} />
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// -- Wartung Tab --------------------------------------------------------------
|
|
|
|
interface WartungTabProps {
|
|
equipmentId: string;
|
|
wartungslog: AusruestungWartungslog[];
|
|
onAdded: () => void;
|
|
canWrite: boolean;
|
|
}
|
|
|
|
const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdded, canWrite }) => {
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
|
|
const emptyForm: CreateAusruestungWartungslogPayload = {
|
|
datum: '',
|
|
art: 'Prüfung' as AusruestungWartungslogArt,
|
|
beschreibung: '',
|
|
ergebnis: undefined,
|
|
kosten: undefined,
|
|
pruefende_stelle: undefined,
|
|
};
|
|
|
|
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.datum || !form.art || !form.beschreibung.trim()) {
|
|
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
|
|
return;
|
|
}
|
|
try {
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
await equipmentApi.addWartungslog(equipmentId, {
|
|
...form,
|
|
datum: fromGermanDate(form.datum) || form.datum,
|
|
pruefende_stelle: form.pruefende_stelle || undefined,
|
|
ergebnis: form.ergebnis || undefined,
|
|
});
|
|
setDialogOpen(false);
|
|
setForm(emptyForm);
|
|
onAdded();
|
|
} catch {
|
|
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Sort wartungslog by datum DESC
|
|
const sorted = [...wartungslog].sort(
|
|
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
|
|
);
|
|
|
|
return (
|
|
<Box>
|
|
{sorted.length === 0 ? (
|
|
<Typography color="text.secondary">Keine Wartungseinträge vorhanden.</Typography>
|
|
) : (
|
|
<Stack divider={<Divider />} spacing={0}>
|
|
{sorted.map((entry) => {
|
|
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
|
|
const ergebnisColor = entry.ergebnis ? ERGEBNIS_CHIP_COLOR[entry.ergebnis] : undefined;
|
|
const ergebnisLabel = entry.ergebnis ? ERGEBNIS_LABEL[entry.ergebnis] : undefined;
|
|
return (
|
|
<Box key={entry.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
|
<Box sx={{ mt: 0.25 }}>{artIcon}</Box>
|
|
<Box sx={{ flexGrow: 1 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
|
|
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
|
{entry.art && (
|
|
<Chip
|
|
label={entry.art}
|
|
size="small"
|
|
color={WARTUNG_ART_CHIP_COLOR[entry.art] ?? 'default'}
|
|
variant="outlined"
|
|
/>
|
|
)}
|
|
{ergebnisLabel && ergebnisColor && (
|
|
<Chip
|
|
label={ergebnisLabel}
|
|
size="small"
|
|
color={ergebnisColor}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Typography variant="body2">{entry.beschreibung}</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
|
{[
|
|
entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`,
|
|
entry.pruefende_stelle && entry.pruefende_stelle,
|
|
].filter(Boolean).join(' · ')}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
|
|
{canWrite && (
|
|
<ChatAwareFab
|
|
size="small"
|
|
aria-label="Wartung eintragen"
|
|
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
|
|
>
|
|
<Add />
|
|
</ChatAwareFab>
|
|
)}
|
|
|
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Wartung / Prüfung eintragen</DialogTitle>
|
|
<DialogContent>
|
|
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
label="Datum *"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.datum}
|
|
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Art *</InputLabel>
|
|
<Select
|
|
label="Art *"
|
|
value={form.art ?? ''}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
art: (e.target.value || undefined) as AusruestungWartungslogArt,
|
|
}))
|
|
}
|
|
>
|
|
<MenuItem value="">--- Bitte wählen ---</MenuItem>
|
|
{(['Prüfung', 'Reparatur', 'Sonstiges'] as AusruestungWartungslogArt[]).map((a) => (
|
|
<MenuItem key={a} value={a}>{a}</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<TextField
|
|
label="Beschreibung *"
|
|
fullWidth
|
|
multiline
|
|
rows={3}
|
|
value={form.beschreibung}
|
|
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Ergebnis</InputLabel>
|
|
<Select
|
|
label="Ergebnis"
|
|
value={form.ergebnis ?? ''}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
ergebnis: e.target.value || undefined,
|
|
}))
|
|
}
|
|
>
|
|
<MenuItem value="">--- Kein Ergebnis ---</MenuItem>
|
|
<MenuItem value="bestanden">Bestanden</MenuItem>
|
|
<MenuItem value="bestanden_mit_maengeln">Bestanden (mit Mängeln)</MenuItem>
|
|
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
label="Kosten (EUR)"
|
|
type="number"
|
|
fullWidth
|
|
value={form.kosten ?? ''}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
kosten: e.target.value ? Number(e.target.value) : undefined,
|
|
}))
|
|
}
|
|
inputProps={{ min: 0, step: 0.01 }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<TextField
|
|
label="Prüfende Stelle"
|
|
fullWidth
|
|
value={form.pruefende_stelle ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
|
|
placeholder="Name der prüfenden Stelle oder Person"
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSubmit}
|
|
disabled={saving}
|
|
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
|
>
|
|
Speichern
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// -- Main Page ----------------------------------------------------------------
|
|
|
|
function AusruestungDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { isAdmin, canManageCategory, canManageEquipmentMaintenance } = usePermissions();
|
|
const notification = useNotification();
|
|
|
|
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState(0);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
|
|
const fetchEquipment = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await equipmentApi.getById(id);
|
|
setEquipment(data);
|
|
} catch {
|
|
setError('Gerät konnte nicht geladen werden.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => { fetchEquipment(); }, [fetchEquipment]);
|
|
|
|
const handleDelete = async () => {
|
|
if (!id) return;
|
|
try {
|
|
setDeleteLoading(true);
|
|
await equipmentApi.delete(id);
|
|
notification.showSuccess('Gerät wurde erfolgreich gelöscht.');
|
|
navigate('/ausruestung');
|
|
} catch {
|
|
notification.showError('Gerät konnte nicht gelöscht werden.');
|
|
setDeleteDialogOpen(false);
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<DashboardLayout>
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
if (error || !equipment) {
|
|
return (
|
|
<DashboardLayout>
|
|
<Container maxWidth="lg">
|
|
<Alert severity="error">{error ?? 'Gerät nicht gefunden.'}</Alert>
|
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
|
|
Zurück zur Übersicht
|
|
</Button>
|
|
</Container>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
const hasOverdue =
|
|
equipment.pruefung_tage_bis_faelligkeit !== null &&
|
|
equipment.pruefung_tage_bis_faelligkeit < 0;
|
|
|
|
// Derive an inline category object so canManageCategory can do the motorisiert check
|
|
const equipmentKategorie = {
|
|
id: equipment.kategorie_id,
|
|
name: equipment.kategorie_name,
|
|
kurzname: equipment.kategorie_kurzname,
|
|
sortierung: 0,
|
|
motorisiert: equipment.kategorie_motorisiert,
|
|
};
|
|
const canWrite = canManageCategory(equipmentKategorie);
|
|
|
|
const subtitle = [
|
|
equipment.kategorie_name,
|
|
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
|
|
].filter(Boolean).join(' · ');
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Container maxWidth="lg">
|
|
<Button
|
|
startIcon={<ArrowBack />}
|
|
onClick={() => navigate('/ausruestung')}
|
|
sx={{ mb: 2 }}
|
|
size="small"
|
|
>
|
|
Ausrüstungsübersicht
|
|
</Button>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
|
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
|
|
<Box>
|
|
<Typography variant="h4" component="h1">
|
|
{equipment.bezeichnung}
|
|
</Typography>
|
|
{subtitle && (
|
|
<Typography variant="subtitle1" color="text.secondary">
|
|
{subtitle}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Chip
|
|
icon={STATUS_ICONS[equipment.status]}
|
|
label={AusruestungStatusLabel[equipment.status]}
|
|
color={STATUS_CHIP_COLOR[equipment.status]}
|
|
/>
|
|
{canWrite && (
|
|
<Tooltip title="Gerät bearbeiten">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
|
|
aria-label="Gerät bearbeiten"
|
|
>
|
|
<Edit />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
{isAdmin && (
|
|
<Tooltip title="Gerät löschen">
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
onClick={() => setDeleteDialogOpen(true)}
|
|
aria-label="Gerät löschen"
|
|
>
|
|
<DeleteOutline />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={(_, v) => setActiveTab(v)}
|
|
aria-label="Ausrüstung Detailansicht"
|
|
variant="scrollable"
|
|
scrollButtons="auto"
|
|
>
|
|
<Tab label="Übersicht" />
|
|
<Tab
|
|
label={
|
|
hasOverdue
|
|
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
Wartung <Warning color="error" fontSize="small" />
|
|
</Box>
|
|
: 'Wartung'
|
|
}
|
|
/>
|
|
</Tabs>
|
|
</Box>
|
|
|
|
<TabPanel value={activeTab} index={0}>
|
|
<UebersichtTab
|
|
equipment={equipment}
|
|
onStatusUpdated={fetchEquipment}
|
|
canChangeStatus={canWrite}
|
|
/>
|
|
</TabPanel>
|
|
|
|
<TabPanel value={activeTab} index={1}>
|
|
<WartungTab
|
|
equipmentId={equipment.id}
|
|
wartungslog={equipment.wartungslog ?? []}
|
|
onAdded={fetchEquipment}
|
|
canWrite={canManageEquipmentMaintenance}
|
|
/>
|
|
</TabPanel>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
|
|
<DialogTitle>Gerät löschen</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText>
|
|
Möchten Sie '{equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
|
|
</DialogContentText>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
disabled={deleteLoading}
|
|
autoFocus
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
color="error"
|
|
variant="contained"
|
|
onClick={handleDelete}
|
|
disabled={deleteLoading}
|
|
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
|
|
>
|
|
Löschen
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Container>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
export default AusruestungDetailPage;
|