feat: add admin dashboard with CRUD and reservation approvals

This commit is contained in:
MrFrostDev
2026-05-23 13:58:27 +02:00
parent e4f3ccfaaf
commit 14ede6aab0
2 changed files with 928 additions and 0 deletions
+112
View File
@@ -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>
);
}
+816
View File
@@ -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>
);
}