update
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
IosShare,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Block,
|
||||
} from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
isToday,
|
||||
parseISO,
|
||||
isSameDay,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
@@ -156,6 +158,18 @@ function FahrzeugBuchungen() {
|
||||
});
|
||||
};
|
||||
|
||||
const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => {
|
||||
if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false;
|
||||
try {
|
||||
return isWithinInterval(day, {
|
||||
start: parseISO(vehicle.ausser_dienst_von),
|
||||
end: parseISO(vehicle.ausser_dienst_bis),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Create / Edit dialog ──────────────────────────────────────────────────
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingBooking, setEditingBooking] =
|
||||
@@ -163,7 +177,11 @@ function FahrzeugBuchungen() {
|
||||
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
|
||||
const [dialogLoading, setDialogLoading] = useState(false);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [availability, setAvailability] = useState<boolean | null>(null);
|
||||
const [availability, setAvailability] = useState<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
ausserDienstBis?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Check availability whenever the relevant form fields change
|
||||
useEffect(() => {
|
||||
@@ -178,8 +196,8 @@ function FahrzeugBuchungen() {
|
||||
new Date(form.beginn),
|
||||
new Date(form.ende)
|
||||
)
|
||||
.then(({ available }) => {
|
||||
if (!cancelled) setAvailability(available);
|
||||
.then((result) => {
|
||||
if (!cancelled) setAvailability(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailability(null);
|
||||
@@ -227,9 +245,14 @@ function FahrzeugBuchungen() {
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { status?: number }; message?: string };
|
||||
const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
|
||||
if (axiosError?.response?.status === 409) {
|
||||
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||
const reason = axiosError?.response?.data?.reason;
|
||||
if (reason === 'out_of_service') {
|
||||
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
|
||||
} else {
|
||||
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||
}
|
||||
} else {
|
||||
setDialogError(axiosError?.message || 'Fehler beim Speichern');
|
||||
}
|
||||
@@ -437,7 +460,8 @@ function FahrzeugBuchungen() {
|
||||
</TableCell>
|
||||
{weekDays.map((day) => {
|
||||
const cellBookings = getBookingsForCell(vehicle.id, day);
|
||||
const isFree = cellBookings.length === 0;
|
||||
const oos = isOutOfService(vehicle, day);
|
||||
const isFree = cellBookings.length === 0 && !oos;
|
||||
return (
|
||||
<TableCell
|
||||
key={day.toISOString()}
|
||||
@@ -445,8 +469,12 @@ function FahrzeugBuchungen() {
|
||||
isFree ? handleCellClick(vehicle.id, day) : undefined
|
||||
}
|
||||
sx={{
|
||||
bgcolor: isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined,
|
||||
cursor: isFree && canCreate ? 'pointer' : 'default',
|
||||
bgcolor: oos
|
||||
? (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50'
|
||||
: isFree
|
||||
? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50'
|
||||
: undefined,
|
||||
cursor: isFree && canCreate ? 'pointer' : oos ? 'not-allowed' : 'default',
|
||||
'&:hover': isFree && canCreate
|
||||
? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' }
|
||||
: {},
|
||||
@@ -454,6 +482,18 @@ function FahrzeugBuchungen() {
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
{oos && (
|
||||
<Tooltip title="Fahrzeug außer Dienst">
|
||||
<Chip
|
||||
icon={<Block fontSize="small" />}
|
||||
label="Außer Dienst"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.6rem', height: 18, mb: 0.25, width: '100%' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{cellBookings.map((b) => (
|
||||
<Tooltip
|
||||
key={b.id}
|
||||
@@ -519,6 +559,19 @@ function FahrzeugBuchungen() {
|
||||
/>
|
||||
<Typography variant="caption">Frei</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50',
|
||||
border: '1px solid',
|
||||
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption">Außer Dienst</Typography>
|
||||
</Box>
|
||||
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
|
||||
([art, label]) => (
|
||||
<Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
@@ -702,15 +755,29 @@ function FahrzeugBuchungen() {
|
||||
Verfügbarkeit wird geprüft...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : availability.available ? (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Fahrzeug verfügbar"
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
) : availability.reason === 'out_of_service' ? (
|
||||
<Chip
|
||||
icon={<Block />}
|
||||
label={
|
||||
availability.ausserDienstBis
|
||||
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
|
||||
: 'Fahrzeug ist außer Dienst'
|
||||
}
|
||||
color="error"
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
icon={availability ? <CheckCircle /> : <Warning />}
|
||||
label={
|
||||
availability
|
||||
? 'Fahrzeug verfügbar'
|
||||
: 'Konflikt: bereits gebucht'
|
||||
}
|
||||
color={availability ? 'success' : 'error'}
|
||||
icon={<Warning />}
|
||||
label="Konflikt: bereits gebucht"
|
||||
color="error"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
CreateWartungslogPayload,
|
||||
UpdateStatusPayload,
|
||||
WartungslogArt,
|
||||
OverlappingBooking,
|
||||
} from '../types/vehicle.types';
|
||||
import type { AusruestungListItem } from '../types/equipment.types';
|
||||
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
|
||||
@@ -92,14 +93,12 @@ const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />,
|
||||
[FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />,
|
||||
[FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />,
|
||||
[FahrzeugStatus.InLehrgang]: <School color="info" />,
|
||||
};
|
||||
|
||||
const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: 'success',
|
||||
[FahrzeugStatus.AusserDienstWartung]: 'warning',
|
||||
[FahrzeugStatus.AusserDienstSchaden]: 'error',
|
||||
[FahrzeugStatus.InLehrgang]: 'info',
|
||||
};
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -118,6 +117,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function fmtDatetime(iso: string | Date | null | undefined): string {
|
||||
return fmtDate(iso ? new Date(iso).toISOString() : null);
|
||||
}
|
||||
|
||||
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UebersichtTabProps {
|
||||
@@ -130,13 +133,30 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
||||
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
||||
const [ausserDienstVon, setAusserDienstVon] = useState(
|
||||
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
const [ausserDienstBis, setAusserDienstBis] = useState(
|
||||
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
|
||||
|
||||
const isAusserDienst = (s: FahrzeugStatus) =>
|
||||
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
const openDialog = () => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setAusserDienstVon(
|
||||
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
setAusserDienstBis(
|
||||
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
setSaveError(null);
|
||||
setOverlappingBookings([]);
|
||||
setStatusDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -149,9 +169,19 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
|
||||
await vehiclesApi.updateStatus(vehicle.id, payload);
|
||||
const payload: UpdateStatusPayload = {
|
||||
status: newStatus,
|
||||
bemerkung,
|
||||
...(isAusserDienst(newStatus) && ausserDienstVon && ausserDienstBis
|
||||
? {
|
||||
ausserDienstVon: new Date(ausserDienstVon).toISOString(),
|
||||
ausserDienstBis: new Date(ausserDienstBis).toISOString(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const result = await vehiclesApi.updateStatus(vehicle.id, payload);
|
||||
setStatusDialogOpen(false);
|
||||
setOverlappingBookings(result.overlappingBookings ?? []);
|
||||
onStatusUpdated();
|
||||
} catch (err: any) {
|
||||
setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.');
|
||||
@@ -160,6 +190,8 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = !isAusserDienst(newStatus) || (!!ausserDienstVon && !!ausserDienstBis);
|
||||
|
||||
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
// Inspection deadline badges
|
||||
@@ -177,6 +209,20 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overlappingBookings.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<strong>Folgende Buchungen überschneiden sich mit dem Außer-Dienst-Zeitraum:</strong>
|
||||
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2 }}>
|
||||
{overlappingBookings.map((b) => (
|
||||
<li key={b.id}>
|
||||
<strong>{b.titel}</strong> · {fmtDate(b.beginn as unknown as string)} – {fmtDate(b.ende as unknown as string)} · {b.gebucht_von_name}
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
Bitte prüfe, ob diese storniert werden müssen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status panel */}
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -184,11 +230,27 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
{STATUS_ICONS[vehicle.status]}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
|
||||
<Chip
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
size="small"
|
||||
/>
|
||||
{vehicle.aktiver_lehrgang && (
|
||||
<Chip
|
||||
icon={<School fontSize="small" />}
|
||||
label="In Lehrgang"
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{(vehicle.status === FahrzeugStatus.AusserDienstWartung || vehicle.status === FahrzeugStatus.AusserDienstSchaden) &&
|
||||
vehicle.ausser_dienst_von && vehicle.ausser_dienst_bis && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Außer Dienst: {fmtDatetime(vehicle.ausser_dienst_von)} – {fmtDatetime(vehicle.ausser_dienst_bis)} (geschätzt)
|
||||
</Typography>
|
||||
)}
|
||||
{vehicle.status_bemerkung && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{vehicle.status_bemerkung}
|
||||
@@ -279,6 +341,33 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isAusserDienst(newStatus) && (
|
||||
<>
|
||||
<TextField
|
||||
label="Außer Dienst von *"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
value={ausserDienstVon}
|
||||
onChange={(e) => setAusserDienstVon(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Geschätztes Ende *"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
sx={{ mb: 1 }}
|
||||
value={ausserDienstBis}
|
||||
onChange={(e) => setAusserDienstBis(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Zeitangabe ist eine Schätzung
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Bemerkung (optional)"
|
||||
fullWidth
|
||||
@@ -294,7 +383,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveStatus}
|
||||
disabled={saving}
|
||||
disabled={saving || !canSave}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Speichern
|
||||
|
||||
@@ -49,7 +49,6 @@ const STATUS_CONFIG: Record<
|
||||
[FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
|
||||
[FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
|
||||
[FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
|
||||
[FahrzeugStatus.InLehrgang]: { color: 'info', icon: <School fontSize="small" /> },
|
||||
};
|
||||
|
||||
// ── Inspection badge helpers ──────────────────────────────────────────────────
|
||||
@@ -177,13 +176,30 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
icon={statusCfg.icon}
|
||||
label={FahrzeugStatusLabel[status]}
|
||||
color={statusCfg.color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Chip
|
||||
icon={statusCfg.icon}
|
||||
label={FahrzeugStatusLabel[status]}
|
||||
color={statusCfg.color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{vehicle.aktiver_lehrgang && (
|
||||
<Chip
|
||||
icon={<School fontSize="small" />}
|
||||
label="In Lehrgang"
|
||||
color="info"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{(status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden) &&
|
||||
vehicle.ausser_dienst_bis && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Bis ca. {new Date(vehicle.ausser_dienst_bis).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{inspBadges.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user