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