feat: add tool inventory cards, hero and reservation modal
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user