This commit is contained in:
Matthias Hochmeister
2026-03-13 19:23:39 +01:00
parent 02d9d808b2
commit bc6d09200a
15 changed files with 610 additions and 74 deletions

View File

@@ -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"
/>
)}

View File

@@ -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

View File

@@ -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 && (

View File

@@ -50,9 +50,9 @@ export const bookingApi = {
fahrzeugId: string,
from: Date,
to: Date
): Promise<{ available: boolean }> {
): Promise<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }> {
return api
.get<ApiResponse<{ available: boolean }>>('/api/bookings/availability', {
.get<ApiResponse<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }>>('/api/bookings/availability', {
params: {
fahrzeugId,
from: from.toISOString(),

View File

@@ -9,6 +9,7 @@ import type {
UpdateFahrzeugPayload,
UpdateStatusPayload,
CreateWartungslogPayload,
StatusUpdateResponse,
} from '../types/vehicle.types';
async function unwrap<T>(
@@ -68,8 +69,12 @@ export const vehiclesApi = {
await api.delete(`/api/vehicles/${id}`);
},
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
await api.patch(`/api/vehicles/${id}/status`, payload);
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<StatusUpdateResponse> {
const response = await api.patch<{ success: boolean; data: StatusUpdateResponse }>(
`/api/vehicles/${id}/status`,
payload
);
return response.data?.data ?? { overlappingBookings: [] };
},
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {

View File

@@ -1,4 +1,4 @@
export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges';
export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges' | 'lehrgang';
export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
intern: 'Intern',
@@ -6,6 +6,7 @@ export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
wartung: 'Wartung/Service',
reservierung: 'Reservierung',
sonstiges: 'Sonstiges',
lehrgang: 'Lehrgang',
};
export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
@@ -14,6 +15,7 @@ export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
wartung: '#616161',
reservierung: '#7b1fa2',
sonstiges: '#00695c',
lehrgang: '#0288d1',
};
export interface FahrzeugBuchungListItem {
@@ -45,6 +47,8 @@ export interface Fahrzeug {
kurzname: string | null;
amtliches_kennzeichen: string | null;
status: string;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
}
export interface CreateBuchungInput {

View File

@@ -6,14 +6,12 @@ export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
}
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
};
export type WartungslogArt =
@@ -23,6 +21,12 @@ export type WartungslogArt =
// ── API Response Shapes ───────────────────────────────────────────────────────
export interface AktiverLehrgang {
titel: string;
beginn: string;
ende: string;
}
export interface FahrzeugListItem {
id: string;
bezeichnung: string;
@@ -33,12 +37,15 @@ export interface FahrzeugListItem {
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
bild_url: string | null;
paragraph57a_faellig_am: string | null;
paragraph57a_tage_bis_faelligkeit: number | null;
naechste_wartung_am: string | null;
wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null;
aktiver_lehrgang: AktiverLehrgang | null;
}
export interface FahrzeugWartungslog {
@@ -67,6 +74,8 @@ export interface FahrzeugDetail {
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
standort: string;
bild_url: string | null;
created_at: string;
@@ -76,6 +85,7 @@ export interface FahrzeugDetail {
naechste_wartung_am: string | null;
wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null;
aktiver_lehrgang: AktiverLehrgang | null;
wartungslog: FahrzeugWartungslog[];
}
@@ -123,8 +133,22 @@ export type UpdateFahrzeugPayload = {
};
export interface UpdateStatusPayload {
status: FahrzeugStatus;
bemerkung?: string;
status: FahrzeugStatus;
bemerkung?: string;
ausserDienstVon?: string; // ISO datetime, required when status is ausser_dienst_*
ausserDienstBis?: string; // ISO datetime, required when status is ausser_dienst_*
}
export interface OverlappingBooking {
id: string;
titel: string;
beginn: string;
ende: string;
gebucht_von_name: string;
}
export interface StatusUpdateResponse {
overlappingBookings: OverlappingBooking[];
}
export interface CreateWartungslogPayload {