From 8aa47f1ff713ce7bfaf6eba22291a540e6e1c0bc Mon Sep 17 00:00:00 2001 From: MrFrostDev <25389627+MrFrostDev@users.noreply.github.com> Date: Sat, 23 May 2026 13:58:27 +0200 Subject: [PATCH] feat: add tool inventory cards, hero and reservation modal --- src/components/AvailabilityCalendar.tsx | 36 +++ src/components/HeroSection.tsx | 234 +++++++++++++++ src/components/ReservationModal.tsx | 370 ++++++++++++++++++++++++ src/components/ToolCard.tsx | 200 +++++++++++++ 4 files changed, 840 insertions(+) create mode 100644 src/components/AvailabilityCalendar.tsx create mode 100644 src/components/HeroSection.tsx create mode 100644 src/components/ReservationModal.tsx create mode 100644 src/components/ToolCard.tsx diff --git a/src/components/AvailabilityCalendar.tsx b/src/components/AvailabilityCalendar.tsx new file mode 100644 index 0000000..2626ab2 --- /dev/null +++ b/src/components/AvailabilityCalendar.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx new file mode 100644 index 0000000..53ae226 --- /dev/null +++ b/src/components/HeroSection.tsx @@ -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 ( + + + + + + + + + + } + 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 }, + }} + /> + + + + + {t('heroTitlePart1')}{' '} + + {t('heroTitlePart2')} + + + + + + + + {t('heroSubtitle')} + + + + + + + + + + + + } label="Inventar" value="Live" /> + } label="KI-Assistent" value="Gemini" /> + } label="Antwort" value="< 2s" /> + + + + + ); +} + +function MiniStat({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx new file mode 100644 index 0000000..7f5bdae --- /dev/null +++ b/src/components/ReservationModal.tsx @@ -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 ( + + {/* Top bar */} + + + + + + + Reservierung + + + + + + + + {/* Body */} + + {/* Form panel */} + + + + + {tool.name} + + + } + label={hasTraining ? 'Zertifiziert' : 'Einweisung fehlt'} + color={hasTraining ? 'success' : 'warning'} + size="small" + sx={pillSx} + /> + } + label={tool.location} + size="small" + variant="outlined" + sx={pillSx} + /> + } + label={`max. ${tool.maxRentalDays} Tage`} + size="small" + variant="outlined" + sx={pillSx} + /> + + + {tool.description} + + + + + + + setStartDate(e.target.value)} + InputLabelProps={{ shrink: true }} + inputProps={{ min: today }} + fullWidth + size="small" + /> + 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` + : ' ' + } + /> + + + + + Maximale Ausleihdauer: {tool.maxRentalDays} Tage. Bitte + bringe das Werkzeug rechtzeitig und unbeschädigt zurück. + + + + + + {/* Sticky action bar */} + + + + + + + {/* Calendar panel */} + + + + Verfügbarkeit + + + + + + + + + + + } + > + + + + + + + ); +} + +function LegendDot({ + color, + label, + outline, +}: { + color?: string; + label: string; + outline?: boolean; +}) { + return ( + + + + {label} + + + ); +} diff --git a/src/components/ToolCard.tsx b/src/components/ToolCard.tsx new file mode 100644 index 0000000..36219bd --- /dev/null +++ b/src/components/ToolCard.tsx @@ -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 ( + + + + {/* Header strip with emoji + category */} + + + {tool.emoji} + + + + + + + {tool.name} + + + {tool.description} + + + + {tool.trainingRequired ? ( + : } + label={hasTraining ? t('certified') : t('trainingMissing')} + color={hasTraining ? 'success' : 'warning'} + size="small" + sx={pillSx} + /> + ) : ( + + )} + {tool.homeAllowed && ( + } + label={t('homeUse')} + size="small" + variant="outlined" + sx={pillSx} + /> + )} + + + + + + {tool.location} + + + + max. {tool.maxRentalDays} {t('days')} + + + + + + + ); +}