This commit is contained in:
Matthias Hochmeister
2026-03-13 15:22:58 +01:00
parent 165acfbece
commit ff72daa55e
4 changed files with 251 additions and 2 deletions

View File

@@ -0,0 +1,238 @@
import React from 'react';
import {
Box,
Card,
CardContent,
CircularProgress,
Chip,
Divider,
Link,
List,
ListItem,
Typography,
} from '@mui/material';
import { DirectionsCar as DirectionsCarIcon } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { bookingApi } from '../../services/bookings';
import type { FahrzeugBuchungListItem } from '../../types/booking.types';
import { BUCHUNGS_ART_COLORS, BUCHUNGS_ART_LABELS } from '../../types/booking.types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const WEEKDAY_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
function formatDateShort(isoString: string): string {
const d = new Date(isoString);
const weekday = WEEKDAY_SHORT[d.getDay()];
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
return `${weekday} ${day}.${month}.`;
}
function formatTime(isoString: string): string {
const d = new Date(isoString);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} Uhr`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const FETCH_LIMIT = 10;
const DISPLAY_LIMIT = 5;
const VehicleBookingListWidget: React.FC = () => {
const { data: rawItems = [], isLoading, isError } = useQuery<FahrzeugBuchungListItem[]>({
queryKey: ['upcoming-vehicle-bookings'],
queryFn: () => bookingApi.getUpcoming(FETCH_LIMIT),
});
const items = React.useMemo(
() => rawItems.filter((b) => !b.abgesagt).slice(0, DISPLAY_LIMIT),
[rawItems],
);
// ── Loading state ─────────────────────────────────────────────────────────
if (isLoading) {
return (
<Card sx={{ height: '100%' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
<CircularProgress size={18} />
<Typography variant="body2" color="text.secondary">
Buchungen werden geladen...
</Typography>
</CardContent>
</Card>
);
}
// ── Error state ───────────────────────────────────────────────────────────
if (isError) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<DirectionsCarIcon fontSize="small" color="action" />
<Typography variant="h6">Nächste Fahrzeugbuchungen</Typography>
</Box>
<Typography variant="body2" color="error">
Buchungen konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
// ── Main render ───────────────────────────────────────────────────────────
return (
<Card
sx={{
height: '100%',
transition: 'box-shadow 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent sx={{ pb: '8px !important' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="div">
Nächste Fahrzeugbuchungen
</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* Empty state */}
{items.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Buchungen
</Typography>
</Box>
) : (
<List disablePadding>
{items.map((booking, index) => {
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
return (
<React.Fragment key={booking.id}>
<ListItem
disableGutters
sx={{
py: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(booking.beginn)}
{' '}&middot;{' '}
{formatTime(booking.beginn)}
</Typography>
<Chip
label={label}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${color}22`,
color: color,
border: `1px solid ${color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{booking.titel}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{booking.fahrzeug_name}
{booking.fahrzeug_kennzeichen ? ` · ${booking.fahrzeug_kennzeichen}` : ''}
</Typography>
</Box>
</ListItem>
{index < items.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
);
})}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
<Box sx={{ textAlign: 'right' }}>
<Link
component={RouterLink}
to="/kalender?tab=1"
underline="hover"
variant="body2"
sx={{ fontWeight: 500 }}
>
Alle Buchungen
</Link>
</Box>
</CardContent>
</Card>
);
};
export default VehicleBookingListWidget;

View File

@@ -10,6 +10,7 @@ export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
export { default as AdminStatusWidget } from './AdminStatusWidget';
export { default as VehicleBookingListWidget } from './VehicleBookingListWidget';
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
export { default as AnnouncementBanner } from './AnnouncementBanner';