feat: add tool inventory cards, hero and reservation modal

This commit is contained in:
MrFrostDev
2026-05-23 13:58:27 +02:00
parent df8d3940c5
commit 8aa47f1ff7
4 changed files with 840 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
'use client';
import {
Dialog,
Button,
TextField,
Typography,
Box,
Divider,
Chip,
Paper,
IconButton,
Stack,
useMediaQuery,
CircularProgress,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { useState, useEffect, Suspense, lazy, useMemo } from 'react';
import { Tool, User } from '../types';
import { useReservationStore } from '../services/reservation-store';
import { useI18n } from '../services/i18n';
import { Calendar, Info, ShieldCheck, X, Check, MapPin, Clock } from 'lucide-react';
const AvailabilityCalendar = lazy(() => import('./AvailabilityCalendar'));
interface ReservationModalProps {
tool: Tool | null;
user: User;
open: boolean;
onClose: () => void;
}
const pillSx = {
height: 22,
fontWeight: 600,
fontSize: '0.7rem',
borderRadius: 1.25,
'& .MuiChip-icon': { marginLeft: '6px', marginRight: '-2px' },
'& .MuiChip-label': { px: 0.75 },
} as const;
export default function ReservationModal({
tool,
user,
open,
onClose,
}: ReservationModalProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { t } = useI18n();
const reservations = useReservationStore((s) => s.reservations);
const addReservation = useReservationStore((s) => s.addReservation);
const today = useMemo(() => new Date().toISOString().split('T')[0], []);
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState('');
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (open) {
setStartDate(today);
setEndDate('');
setSubmitted(false);
}
}, [open, today]);
if (!tool) return null;
const toolEvents = reservations
.filter((r) => r.toolId === tool.id && r.status !== 'completed')
.map((r) => ({
title: 'Belegt',
start: r.startDate,
end: r.endDate,
display: 'background' as const,
color: '#F59E0B',
}));
const duration = (() => {
if (!startDate || !endDate) return 0;
const ms = new Date(endDate).getTime() - new Date(startDate).getTime();
return Math.max(0, Math.round(ms / 86_400_000) + 1);
})();
const exceedsMax = duration > tool.maxRentalDays;
const invalidRange = Boolean(endDate) && endDate < startDate;
const hasTraining =
!tool.trainingRequired || user.trainings.includes(tool.requiredTrainingId!);
const canReserve =
!!startDate &&
!!endDate &&
!exceedsMax &&
!invalidRange &&
(hasTraining || user.role === 'admin');
const handleSubmit = () => {
if (!canReserve) return;
addReservation({ userId: user.id, toolId: tool.id, startDate, endDate });
setSubmitted(true);
setTimeout(onClose, 1100);
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
fullScreen={isMobile}
PaperProps={{
sx: {
borderRadius: { xs: 0, md: 3 },
overflow: 'hidden',
maxHeight: { md: '90vh' },
},
}}
>
{/* Top bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: { xs: 2, md: 3 },
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1.5,
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
color: 'white',
}}
>
<Calendar size={15} />
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Reservierung
</Typography>
</Box>
<IconButton onClick={onClose} size="small" aria-label="Schließen">
<X size={18} />
</IconButton>
</Box>
{/* Body */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '5fr 7fr' },
flexGrow: 1,
minHeight: 0,
overflowY: 'auto',
}}
>
{/* Form panel */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
borderRight: { md: '1px solid' },
borderBottom: { xs: '1px solid', md: 'none' },
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Box sx={{ p: { xs: 2.5, md: 3 }, display: 'flex', flexDirection: 'column', gap: 2.25 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, mb: 1, letterSpacing: '-0.015em' }}>
{tool.name}
</Typography>
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ rowGap: 0.75 }}>
<Chip
icon={<ShieldCheck size={12} />}
label={hasTraining ? 'Zertifiziert' : 'Einweisung fehlt'}
color={hasTraining ? 'success' : 'warning'}
size="small"
sx={pillSx}
/>
<Chip
icon={<MapPin size={12} />}
label={tool.location}
size="small"
variant="outlined"
sx={pillSx}
/>
<Chip
icon={<Clock size={12} />}
label={`max. ${tool.maxRentalDays} Tage`}
size="small"
variant="outlined"
sx={pillSx}
/>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, lineHeight: 1.55 }}>
{tool.description}
</Typography>
</Box>
<Divider />
<Stack spacing={2}>
<TextField
label={t('from')}
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{ min: today }}
fullWidth
size="small"
/>
<TextField
label={t('to')}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{ min: startDate || today }}
fullWidth
size="small"
error={Boolean(invalidRange) || exceedsMax}
helperText={
invalidRange
? 'Enddatum liegt vor Startdatum.'
: exceedsMax
? `Maximale Leihdauer: ${tool.maxRentalDays} Tage.`
: duration > 0
? `${duration} ${duration === 1 ? 'Tag' : 'Tage'} gesamt`
: ' '
}
/>
<Paper
variant="outlined"
sx={{
p: 1.5,
display: 'flex',
gap: 1.25,
alignItems: 'flex-start',
bgcolor: 'action.hover',
borderStyle: 'dashed',
borderRadius: 2,
}}
>
<Info size={16} style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }} />
<Typography variant="caption" sx={{ lineHeight: 1.5 }}>
Maximale Ausleihdauer: <strong>{tool.maxRentalDays} Tage</strong>. Bitte
bringe das Werkzeug rechtzeitig und unbeschädigt zurück.
</Typography>
</Paper>
</Stack>
</Box>
{/* Sticky action bar */}
<Box
sx={{
mt: 'auto',
position: 'sticky',
bottom: 0,
px: { xs: 2.5, md: 3 },
py: 2,
display: 'flex',
gap: 1.25,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Button onClick={onClose} variant="outlined" fullWidth color="inherit">
Abbrechen
</Button>
<Button
onClick={handleSubmit}
variant="contained"
fullWidth
disabled={!canReserve || submitted}
startIcon={submitted ? <Check size={16} /> : null}
>
{submitted ? 'Reserviert!' : 'Reservieren'}
</Button>
</Box>
</Box>
{/* Calendar panel */}
<Box
sx={{
p: { xs: 2.5, md: 3 },
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
minHeight: { xs: 360, md: 'auto' },
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Verfügbarkeit
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<LegendDot color="#F59E0B" label="Reserviert" />
<LegendDot label="Frei" outline />
</Box>
</Box>
<Paper
variant="outlined"
sx={{
p: { xs: 1.25, md: 1.75 },
borderRadius: 2.5,
flexGrow: 1,
}}
>
<Suspense
fallback={
<Box sx={{ display: 'grid', placeItems: 'center', height: 280 }}>
<CircularProgress size={24} />
</Box>
}
>
<AvailabilityCalendar events={toolEvents} />
</Suspense>
</Paper>
</Box>
</Box>
</Dialog>
);
}
function LegendDot({
color,
label,
outline,
}: {
color?: string;
label: string;
outline?: boolean;
}) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: 0.75,
bgcolor: color || 'transparent',
border: outline ? '1px solid' : 'none',
borderColor: 'divider',
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{label}
</Typography>
</Box>
);
}