feat: add tool inventory cards, hero and reservation modal
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import FullCalendar from '@fullcalendar/react';
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
display?: 'background' | 'auto';
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AvailabilityCalendar({ events }: { events: CalendarEvent[] }) {
|
||||||
|
return (
|
||||||
|
<Box className="pp-calendar-compact" sx={{ width: '100%' }}>
|
||||||
|
<FullCalendar
|
||||||
|
plugins={[dayGridPlugin, interactionPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={{ left: 'prev,next', center: 'title', right: '' }}
|
||||||
|
events={events}
|
||||||
|
height="auto"
|
||||||
|
contentHeight="auto"
|
||||||
|
aspectRatio={1.35}
|
||||||
|
firstDay={1}
|
||||||
|
locale="de"
|
||||||
|
buttonText={{ today: 'Heute' }}
|
||||||
|
dayHeaderFormat={{ weekday: 'short' }}
|
||||||
|
fixedWeekCount={false}
|
||||||
|
showNonCurrentDates={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Typography, Container, Chip } from '@mui/material';
|
||||||
|
import { Sparkles, Wrench, Bot, Zap } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useI18n } from '../services/i18n';
|
||||||
|
import AIInput from './AIInput';
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
onAISend: (message: string, files?: File[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.55, delay: 0.1 + i * 0.08, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeroSection({ onAISend }: HeroSectionProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
pt: { xs: 6, sm: 8, md: 12 },
|
||||||
|
pb: { xs: 4, sm: 6, md: 8 },
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{ position: 'absolute', inset: 0, zIndex: -1, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.15, 1],
|
||||||
|
opacity: [0.32, 0.5, 0.32],
|
||||||
|
x: [0, 30, 0],
|
||||||
|
y: [0, -20, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 14, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-15%',
|
||||||
|
left: '5%',
|
||||||
|
width: '50%',
|
||||||
|
height: '70%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, rgba(255,107,0,0.18) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(80px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
opacity: [0.18, 0.36, 0.18],
|
||||||
|
x: [0, -30, 0],
|
||||||
|
y: [0, 30, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 18, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-15%',
|
||||||
|
right: '5%',
|
||||||
|
width: '50%',
|
||||||
|
height: '70%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, rgba(45,90,39,0.18) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box className="pp-grid-bg" sx={{ position: 'absolute', inset: 0, opacity: 0.22 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Container maxWidth="md" sx={{ position: 'relative', px: { xs: 2.5, sm: 3 } }}>
|
||||||
|
<motion.div custom={0} initial="hidden" animate="show" variants={fadeUp}>
|
||||||
|
<Chip
|
||||||
|
icon={<Sparkles size={14} />}
|
||||||
|
label={t('heroBadge')}
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: 'var(--pp-shadow-brand)',
|
||||||
|
mb: { xs: 3, md: 4 },
|
||||||
|
px: 1,
|
||||||
|
height: 30,
|
||||||
|
'& .MuiChip-icon': { color: 'white', ml: 0.5 },
|
||||||
|
'& .MuiChip-label': { px: 1.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div custom={1} initial="hidden" animate="show" variants={fadeUp}>
|
||||||
|
<Typography
|
||||||
|
variant="h1"
|
||||||
|
sx={{
|
||||||
|
fontSize: { xs: '2.25rem', sm: '3rem', md: '4.25rem' },
|
||||||
|
mb: { xs: 2, md: 2.5 },
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: '-0.035em',
|
||||||
|
lineHeight: 1.05,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('heroTitlePart1')}{' '}
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, #FF6B00 0%, #E55F00 50%, #2D5A27 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('heroTitlePart2')}
|
||||||
|
<motion.span
|
||||||
|
initial={{ scaleX: 0, transformOrigin: '0% 50%' }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.9, delay: 0.9, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 4,
|
||||||
|
height: 6,
|
||||||
|
background: 'rgba(255,107,0,0.18)',
|
||||||
|
zIndex: -1,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div custom={2} initial="hidden" animate="show" variants={fadeUp}>
|
||||||
|
<Typography
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
mb: { xs: 4, md: 5 },
|
||||||
|
maxWidth: 620,
|
||||||
|
mx: 'auto',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: { xs: '1rem', sm: '1.08rem', md: '1.15rem' },
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('heroSubtitle')}
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div custom={3} initial="hidden" animate="show" variants={fadeUp}>
|
||||||
|
<Box sx={{ maxWidth: 720, mx: 'auto', textAlign: 'left' }}>
|
||||||
|
<AIInput onSend={onAISend} />
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div custom={4} initial="hidden" animate="show" variants={fadeUp}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: { xs: 3.5, md: 5 },
|
||||||
|
display: 'flex',
|
||||||
|
gap: { xs: 1, sm: 1.5, md: 2 },
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MiniStat icon={<Wrench size={15} />} label="Inventar" value="Live" />
|
||||||
|
<MiniStat icon={<Bot size={15} />} label="KI-Assistent" value="Gemini" />
|
||||||
|
<MiniStat icon={<Zap size={15} />} label="Antwort" value="< 2s" />
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
pl: 1.25,
|
||||||
|
pr: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
boxShadow: 'var(--pp-shadow-soft)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
color: 'primary.main',
|
||||||
|
display: 'inline-grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 1.25,
|
||||||
|
bgcolor: 'rgba(255,107,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.78rem' }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, Typography, Box, Chip, Button } from '@mui/material';
|
||||||
|
import { MapPin, Clock, ShieldCheck, AlertTriangle, Home } from 'lucide-react';
|
||||||
|
import { Tool, User, CATEGORY_LABEL } from '../types';
|
||||||
|
import { useI18n } from '../services/i18n';
|
||||||
|
|
||||||
|
interface ToolCardProps {
|
||||||
|
tool: Tool;
|
||||||
|
user: User;
|
||||||
|
onReserve: (tool: Tool) => 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 ToolCard({ tool, user, onReserve }: ToolCardProps) {
|
||||||
|
const hasTraining =
|
||||||
|
!tool.trainingRequired || user.trainings.includes(tool.requiredTrainingId!);
|
||||||
|
const isLocked = !hasTraining && user.role !== 'admin';
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: 'var(--pp-shadow-elevated)',
|
||||||
|
},
|
||||||
|
'&:hover .pp-tool-emoji': { transform: 'scale(1.08) rotate(-4deg)' },
|
||||||
|
'&:hover .pp-tool-shine': { transform: 'translateX(220%)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="pp-tool-shine"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '60%',
|
||||||
|
height: '100%',
|
||||||
|
background:
|
||||||
|
'linear-gradient(120deg, transparent 30%, rgba(255,255,255,0.18) 50%, transparent 70%)',
|
||||||
|
transform: 'translateX(-220%)',
|
||||||
|
transition: 'transform 0.8s ease',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header strip with emoji + category */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
height: 92,
|
||||||
|
background: 'linear-gradient(135deg, rgba(255,107,0,0.12), rgba(45,90,39,0.08))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 2.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="pp-tool-emoji"
|
||||||
|
sx={{
|
||||||
|
fontSize: 40,
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
lineHeight: 1,
|
||||||
|
filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.15))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.emoji}
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={CATEGORY_LABEL[tool.category]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
...pillSx,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
color: 'text.primary',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<CardContent
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 800, mb: 0.75, letterSpacing: '-0.01em', fontSize: '1.05rem' }}>
|
||||||
|
{tool.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
flexGrow: 1,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||||
|
{tool.trainingRequired ? (
|
||||||
|
<Chip
|
||||||
|
icon={hasTraining ? <ShieldCheck size={11} /> : <AlertTriangle size={11} />}
|
||||||
|
label={hasTraining ? t('certified') : t('trainingMissing')}
|
||||||
|
color={hasTraining ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
sx={pillSx}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label={t('freeAccess')}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={pillSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tool.homeAllowed && (
|
||||||
|
<Chip
|
||||||
|
icon={<Home size={11} />}
|
||||||
|
label={t('homeUse')}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={pillSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1.5,
|
||||||
|
mb: 2,
|
||||||
|
pt: 1.5,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
{tool.location}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
<Clock size={12} />
|
||||||
|
max. {tool.maxRentalDays} {t('days')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant={isLocked ? 'outlined' : 'contained'}
|
||||||
|
color={isLocked ? 'inherit' : 'primary'}
|
||||||
|
onClick={() => onReserve(tool)}
|
||||||
|
disabled={isLocked}
|
||||||
|
sx={{ py: 1, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{isLocked ? t('trainingRequired') : t('reserve')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user