feat: add admin dashboard with CRUD and reservation approvals
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Paper, Box, Typography } from '@mui/material';
|
||||
import { useReservationStore } from '../services/reservation-store';
|
||||
import { MOCK_TOOLS } from '../services/mock-data';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
requested: '#F59E0B',
|
||||
approved: '#22C55E',
|
||||
active: '#3B82F6',
|
||||
completed: '#9CA3AF',
|
||||
overdue: '#EF4444',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
requested: 'Angefragt',
|
||||
approved: 'Genehmigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Erledigt',
|
||||
overdue: 'Überfällig',
|
||||
};
|
||||
|
||||
export default function AdminCalendar() {
|
||||
const reservations = useReservationStore((state) => state.reservations);
|
||||
|
||||
const events = reservations.map((res) => {
|
||||
const tool = MOCK_TOOLS.find((t) => t.id === res.toolId);
|
||||
const color = STATUS_COLOR[res.status] ?? '#FF6B00';
|
||||
return {
|
||||
id: res.id,
|
||||
title: tool ? tool.name : 'Werkzeug',
|
||||
start: res.startDate,
|
||||
end: res.endDate,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
extendedProps: { status: res.status },
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
Reservierungskalender
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
{Object.entries(STATUS_LABEL).map(([key, label]) => (
|
||||
<Box key={key} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 0.75,
|
||||
bgcolor: STATUS_COLOR[key],
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: { xs: 1.5, md: 2 }, borderRadius: 3 }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
}}
|
||||
events={events}
|
||||
height="auto"
|
||||
firstDay={1}
|
||||
locale="de"
|
||||
buttonText={{
|
||||
today: 'Heute',
|
||||
month: 'Monat',
|
||||
week: 'Woche',
|
||||
day: 'Tag',
|
||||
}}
|
||||
dayHeaderFormat={{ weekday: 'short' }}
|
||||
eventContent={(eventInfo) => (
|
||||
<Box sx={{ px: 0.5, overflow: 'hidden' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.72rem',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{eventInfo.event.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Tab,
|
||||
Tabs,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
IconButton,
|
||||
Chip,
|
||||
useMediaQuery,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useState, lazy, Suspense, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
FileText,
|
||||
Users,
|
||||
Hammer,
|
||||
Calendar as CalendarIcon,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '../services/i18n';
|
||||
import { CATEGORY_LABEL, type Tool, type ToolCategory } from '../types';
|
||||
import { useToolsStore } from '../services/tools-store';
|
||||
import { useReservationStore } from '../services/reservation-store';
|
||||
import { MOCK_USERS } from '../services/mock-data';
|
||||
|
||||
const AdminCalendar = lazy(() => import('./AdminCalendar'));
|
||||
|
||||
const STATUS_COLOR: Record<string, 'default' | 'warning' | 'success' | 'info' | 'error'> = {
|
||||
requested: 'warning',
|
||||
approved: 'success',
|
||||
active: 'info',
|
||||
completed: 'default',
|
||||
overdue: 'error',
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const { t } = useI18n();
|
||||
|
||||
const tools = useToolsStore((s) => s.tools);
|
||||
const removeTool = useToolsStore((s) => s.removeTool);
|
||||
const addTool = useToolsStore((s) => s.addTool);
|
||||
const updateTool = useToolsStore((s) => s.updateTool);
|
||||
const manuals = useToolsStore((s) => s.manuals);
|
||||
const addManual = useToolsStore((s) => s.addManual);
|
||||
const removeManual = useToolsStore((s) => s.removeManual);
|
||||
|
||||
const reservations = useReservationStore((s) => s.reservations);
|
||||
const updateStatus = useReservationStore((s) => s.updateStatus);
|
||||
const removeReservation = useReservationStore((s) => s.removeReservation);
|
||||
|
||||
const [toolDialogOpen, setToolDialogOpen] = useState(false);
|
||||
const [editingTool, setEditingTool] = useState<Tool | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{
|
||||
kind: 'tool' | 'reservation';
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const [manualDialogOpen, setManualDialogOpen] = useState(false);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const active = reservations.filter(
|
||||
(r) => r.status === 'approved' || r.status === 'active'
|
||||
).length;
|
||||
const pending = reservations.filter((r) => r.status === 'requested').length;
|
||||
return { tools: tools.length, users: MOCK_USERS.length, active, pending };
|
||||
}, [reservations, tools]);
|
||||
|
||||
const pendingReservations = reservations.filter((r) => r.status === 'requested');
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingTool(null);
|
||||
setToolDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (tool: Tool) => {
|
||||
setEditingTool(tool);
|
||||
setToolDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = () => {
|
||||
if (!confirmDelete) return;
|
||||
if (confirmDelete.kind === 'tool') removeTool(confirmDelete.id);
|
||||
if (confirmDelete.kind === 'reservation') removeReservation(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 3, md: 5 } }}>
|
||||
<Box sx={{ mb: { xs: 3, md: 4 }, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2.5,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
|
||||
color: 'white',
|
||||
boxShadow: 'var(--pp-shadow-brand)',
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={20} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.02em', lineHeight: 1.1 }}>
|
||||
{t('adminDashboard')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('adminSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
|
||||
gap: { xs: 1.5, md: 2 },
|
||||
mb: { xs: 3, md: 4 },
|
||||
}}
|
||||
>
|
||||
<StatCard delay={0} icon={<Hammer size={18} />} label={t('statsTools')} value={stats.tools} accent="#FF6B00" />
|
||||
<StatCard delay={0.05} icon={<Users size={18} />} label={t('statsUsers')} value={stats.users} accent="#2D5A27" />
|
||||
<StatCard delay={0.1} icon={<Activity size={18} />} label={t('statsActive')} value={stats.active} accent="#3B82F6" />
|
||||
<StatCard delay={0.15} icon={<TrendingUp size={18} />} label={t('statsPending')} value={stats.pending} accent="#F59E0B" />
|
||||
</Box>
|
||||
|
||||
{/* Pending requests banner */}
|
||||
{pendingReservations.length > 0 && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: { xs: 3, md: 4 },
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 3,
|
||||
borderColor: 'warning.main',
|
||||
bgcolor: 'rgba(245, 158, 11, 0.06)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 1.5,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
bgcolor: 'warning.main',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<TrendingUp size={14} />
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('pendingReservations')} ({pendingReservations.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={1}>
|
||||
{pendingReservations.map((r) => {
|
||||
const tool = tools.find((tt) => tt.id === r.toolId);
|
||||
const requester = MOCK_USERS.find((u) => u.id === r.userId);
|
||||
return (
|
||||
<Paper
|
||||
key={r.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontSize: 24 }}>{tool?.emoji ?? '🔧'}</Box>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }} noWrap>
|
||||
{tool?.name ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('requestedBy')}: <strong>{requester?.username ?? '?'}</strong> · {r.startDate} → {r.endDate}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<Check size={14} />}
|
||||
onClick={() => updateStatus(r.id, 'approved')}
|
||||
>
|
||||
{t('approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<X size={14} />}
|
||||
onClick={() => updateStatus(r.id, 'completed')}
|
||||
>
|
||||
{t('reject')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, val) => setActiveTab(val)}
|
||||
variant={isSmall ? 'scrollable' : 'standard'}
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
>
|
||||
<Tab icon={<Hammer size={16} />} iconPosition="start" label={t('manageTools')} />
|
||||
<Tab icon={<CalendarIcon size={16} />} iconPosition="start" label={t('calendar')} />
|
||||
<Tab icon={<FileText size={16} />} iconPosition="start" label={t('manageRAG')} />
|
||||
<Tab icon={<Users size={16} />} iconPosition="start" label={t('manageUsers')} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{t('manageTools')}
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<Plus size={16} />} onClick={openAdd}>
|
||||
{t('addTool')}
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3, overflowX: 'auto' }}>
|
||||
<Table sx={{ minWidth: 640 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('name')}</TableCell>
|
||||
<TableCell>{t('category')}</TableCell>
|
||||
<TableCell>{t('location')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tools.map((tool) => (
|
||||
<TableRow key={tool.id} hover>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ fontSize: 20 }}>{tool.emoji}</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{tool.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={CATEGORY_LABEL[tool.category]}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 1.25, height: 22, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{tool.location}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={tool.trainingRequired ? t('trainingRequired') : t('available')}
|
||||
color={tool.trainingRequired ? 'warning' : 'success'}
|
||||
size="small"
|
||||
sx={{ borderRadius: 1.25, height: 22, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('edit')}>
|
||||
<IconButton size="small" onClick={() => openEdit(tool)}>
|
||||
<Edit2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() =>
|
||||
setConfirmDelete({ kind: 'tool', id: tool.id, label: tool.name })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'grid', placeItems: 'center', minHeight: 360 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('calendar')} …
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<AdminCalendar />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{t('manageRAG')}
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<Plus size={16} />} onClick={() => setManualDialogOpen(true)}>
|
||||
{t('addManual')}
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3, overflowX: 'auto' }}>
|
||||
<Table sx={{ minWidth: 600 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('forTool')}</TableCell>
|
||||
<TableCell>{t('manualPreview')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{manuals.map((manual, idx) => {
|
||||
const tool = tools.find((tt) => tt.id === manual.toolId);
|
||||
return (
|
||||
<TableRow key={idx} hover>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ fontSize: 18 }}>{tool?.emoji ?? '📄'}</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{manual.title ?? tool?.name ?? 'Allgemein'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', maxWidth: 320, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{manual.content}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeManual(manual.toolId, idx)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{t('manageUsers')}
|
||||
</Typography>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3, overflowX: 'auto' }}>
|
||||
<Table sx={{ minWidth: 600 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('username')}</TableCell>
|
||||
<TableCell>{t('role')}</TableCell>
|
||||
<TableCell>{t('certifications')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{MOCK_USERS.map((user) => (
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{user.username}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.role}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={user.role === 'admin' ? 'primary' : 'default'}
|
||||
sx={{ textTransform: 'capitalize', borderRadius: 1.25, height: 22, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.trainings.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{user.trainings.map((tr, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={tr}
|
||||
size="small"
|
||||
sx={{ borderRadius: 1.25, height: 20, fontSize: '0.68rem' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('none')}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small">
|
||||
<Edit2 size={14} />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<ToolEditorDialog
|
||||
open={toolDialogOpen}
|
||||
tool={editingTool}
|
||||
onClose={() => setToolDialogOpen(false)}
|
||||
onSave={(values) => {
|
||||
if (editingTool) {
|
||||
updateTool(editingTool.id, values);
|
||||
} else {
|
||||
addTool(values);
|
||||
}
|
||||
setToolDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ManualEditorDialog
|
||||
open={manualDialogOpen}
|
||||
tools={tools}
|
||||
onClose={() => setManualDialogOpen(false)}
|
||||
onSave={(manual) => {
|
||||
addManual(manual);
|
||||
setManualDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(confirmDelete)}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
PaperProps={{ sx: { borderRadius: 3 } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{t('confirmDelete')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{confirmDelete?.label}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDelete(null)} color="inherit">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteConfirmed} variant="contained" color="error">
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------ Tool Editor Dialog ------------ */
|
||||
|
||||
const EMPTY_TOOL: Omit<Tool, 'id'> = {
|
||||
name: '',
|
||||
description: '',
|
||||
location: '',
|
||||
category: 'hand',
|
||||
trainingRequired: false,
|
||||
requiredTrainingId: '',
|
||||
homeAllowed: true,
|
||||
maxRentalDays: 3,
|
||||
emoji: '🔧',
|
||||
};
|
||||
|
||||
function ToolEditorDialog({
|
||||
open,
|
||||
tool,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
tool: Tool | null;
|
||||
onClose: () => void;
|
||||
onSave: (values: Omit<Tool, 'id'>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [values, setValues] = useState<Omit<Tool, 'id'>>(EMPTY_TOOL);
|
||||
|
||||
// Reset when opening or switching tool
|
||||
useMemo(() => {
|
||||
if (open) {
|
||||
setValues(tool ? { ...tool } : EMPTY_TOOL);
|
||||
}
|
||||
}, [open, tool]);
|
||||
|
||||
const update = <K extends keyof Omit<Tool, 'id'>>(key: K, val: Omit<Tool, 'id'>[K]) =>
|
||||
setValues((v) => ({ ...v, [key]: val }));
|
||||
|
||||
const canSave = values.name.trim().length > 0 && values.location.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm" PaperProps={{ sx: { borderRadius: 3 } }}>
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>
|
||||
{tool ? t('editTool') : t('addTool')}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2} sx={{ pt: 1 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '80px 1fr', gap: 2 }}>
|
||||
<TextField
|
||||
label={t('emoji')}
|
||||
value={values.emoji}
|
||||
onChange={(e) => update('emoji', e.target.value)}
|
||||
size="small"
|
||||
inputProps={{ maxLength: 2, style: { fontSize: 24, textAlign: 'center' } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('name')}
|
||||
value={values.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label={t('description')}
|
||||
value={values.description}
|
||||
onChange={(e) => update('description', e.target.value)}
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '2fr 1fr' }, gap: 2 }}>
|
||||
<TextField
|
||||
label={t('location')}
|
||||
value={values.location}
|
||||
onChange={(e) => update('location', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Max. Tage"
|
||||
type="number"
|
||||
value={values.maxRentalDays}
|
||||
onChange={(e) => update('maxRentalDays', Math.max(1, Number(e.target.value)))}
|
||||
size="small"
|
||||
fullWidth
|
||||
inputProps={{ min: 1, max: 30 }}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
select
|
||||
label={t('category')}
|
||||
value={values.category}
|
||||
onChange={(e) => update('category', e.target.value as ToolCategory)}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{(Object.entries(CATEGORY_LABEL) as [ToolCategory, string][]).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={values.trainingRequired}
|
||||
onChange={(e) => update('trainingRequired', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('trainingRequired')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={values.homeAllowed}
|
||||
onChange={(e) => update('homeAllowed', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('homeUse')}
|
||||
/>
|
||||
</Stack>
|
||||
{values.trainingRequired && (
|
||||
<TextField
|
||||
label="Training-ID"
|
||||
value={values.requiredTrainingId ?? ''}
|
||||
onChange={(e) => update('requiredTrainingId', e.target.value)}
|
||||
size="small"
|
||||
helperText="z. B. drill, 3d-print, laser, soldering, cnc"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => canSave && onSave(values)}
|
||||
variant="contained"
|
||||
disabled={!canSave}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------ Manual Editor Dialog ------------ */
|
||||
|
||||
function ManualEditorDialog({
|
||||
open,
|
||||
tools,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
tools: Tool[];
|
||||
onClose: () => void;
|
||||
onSave: (manual: { toolId: string; title: string; content: string }) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [toolId, setToolId] = useState<string>('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
useMemo(() => {
|
||||
if (open) {
|
||||
setToolId(tools[0]?.id ?? '');
|
||||
setTitle('');
|
||||
setContent('');
|
||||
}
|
||||
}, [open, tools]);
|
||||
|
||||
const canSave = Boolean(toolId) && content.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm" PaperProps={{ sx: { borderRadius: 3 } }}>
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{t('addManual')}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2} sx={{ pt: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
label={t('forTool')}
|
||||
value={toolId}
|
||||
onChange={(e) => setToolId(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<MenuItem key={tool.id} value={tool.id}>
|
||||
{tool.emoji} {tool.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label={t('title')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('content')}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
size="small"
|
||||
multiline
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
fullWidth
|
||||
placeholder="Beschreibe Bedienung, Sicherheitshinweise, häufige Probleme…"
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => canSave && onSave({ toolId, title, content })}
|
||||
variant="contained"
|
||||
disabled={!canSave}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
delay,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
accent: string;
|
||||
delay: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay, ease: 'easeOut' }}
|
||||
>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': { transform: 'translateY(-2px)', boxShadow: 'var(--pp-shadow-elevated)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${accent}22, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 1.5,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
bgcolor: `${accent}1A`,
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.02em' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user