Files
zentral/frontend/src/pages/ReportTemplatesPage.tsx
2025-11-28 11:51:29 +01:00

538 lines
16 KiB
TypeScript

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Card,
CardContent,
CardMedia,
CardActions,
Grid,
Typography,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Tooltip,
CircularProgress,
useMediaQuery,
useTheme,
Stack,
Fab,
Zoom,
} from "@mui/material";
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
ContentCopy as CopyIcon,
Download as DownloadIcon,
Upload as UploadIcon,
Description as DescriptionIcon,
} from "@mui/icons-material";
import { reportTemplateService, downloadBlob } from "../services/reportService";
import type { ReportTemplateDto } from "../types/report";
export default function ReportTemplatesPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const theme = useTheme();
// Breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [filterCategoria, setFilterCategoria] = useState<string>("");
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
template: ReportTemplateDto | null;
}>({
open: false,
template: null,
});
const [importDialog, setImportDialog] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const { data: templates = [], isLoading } = useQuery({
queryKey: ["report-templates", filterCategoria],
queryFn: () => reportTemplateService.getAll(filterCategoria || undefined),
});
const { data: categories = [] } = useQuery({
queryKey: ["report-template-categories"],
queryFn: () => reportTemplateService.getCategories(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => reportTemplateService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["report-templates"] });
setDeleteDialog({ open: false, template: null });
},
});
const cloneMutation = useMutation({
mutationFn: (id: number) => reportTemplateService.clone(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["report-templates"] });
},
});
const importMutation = useMutation({
mutationFn: (file: File) => reportTemplateService.import(file),
onSuccess: (newTemplate) => {
queryClient.invalidateQueries({ queryKey: ["report-templates"] });
setImportDialog(false);
setImportFile(null);
navigate(`/report-editor/${newTemplate.id}`);
},
});
const handleExport = async (template: ReportTemplateDto) => {
const blob = await reportTemplateService.export(template.id);
downloadBlob(blob, `${template.nome.replace(/\s+/g, "_")}.aprt`);
};
const handleImport = () => {
if (importFile) {
importMutation.mutate(importFile);
}
};
const getCategoriaColor = (
categoria: string,
):
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "error"
| "info" => {
const colors: Record<
string,
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "error"
| "info"
> = {
Evento: "primary",
Cliente: "secondary",
Articoli: "success",
Generale: "default",
Importato: "warning",
};
return colors[categoria] || "default";
};
if (isLoading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="400px"
>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ pb: isMobile ? 10 : 0 }}>
{/* Header */}
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: { xs: "stretch", sm: "center" },
gap: 2,
mb: 3,
}}
>
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 600 }}>
Template Report
</Typography>
{/* Desktop/Tablet buttons */}
{!isMobile && (
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => setImportDialog(true)}
>
Importa
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate("/report-editor")}
>
Nuovo Template
</Button>
</Stack>
)}
</Box>
{/* Filter */}
<Box
sx={{
mb: 3,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "center" },
}}
>
<FormControl size="small" sx={{ minWidth: { xs: "100%", sm: 200 } }}>
<InputLabel>Filtra per categoria</InputLabel>
<Select
value={filterCategoria}
label="Filtra per categoria"
onChange={(e) => setFilterCategoria(e.target.value)}
>
<MenuItem value="">Tutte</MenuItem>
{categories.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</Select>
</FormControl>
{/* Mobile import button inline with filter */}
{isMobile && (
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => setImportDialog(true)}
fullWidth
>
Importa Template
</Button>
)}
</Box>
{/* Empty State */}
{templates.length === 0 ? (
<Card>
<CardContent
sx={{
textAlign: "center",
py: { xs: 4, sm: 6 },
px: { xs: 2, sm: 3 },
}}
>
<DescriptionIcon
sx={{
fontSize: { xs: 48, sm: 64 },
color: "text.secondary",
mb: 2,
}}
/>
<Typography variant="h6" color="text.secondary" gutterBottom>
Nessun template trovato
</Typography>
<Typography color="text.secondary" mb={3}>
Crea il tuo primo template di report o importane uno esistente
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
justifyContent="center"
>
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => setImportDialog(true)}
fullWidth={isMobile}
>
Importa Template
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate("/report-editor")}
fullWidth={isMobile}
>
Crea Template
</Button>
</Stack>
</CardContent>
</Card>
) : (
/* Template Grid */
<Grid container spacing={{ xs: 2, sm: 3 }}>
{templates.map((template) => (
<Grid
size={{
xs: 12,
sm: 6,
md: 4,
lg: 3,
xl: 2,
}}
key={template.id}
>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
transition: "transform 0.2s, box-shadow 0.2s",
"&:hover": {
transform: { sm: "translateY(-4px)" },
boxShadow: { sm: 4 },
},
}}
>
{/* Thumbnail */}
{template.thumbnailBase64 ? (
<CardMedia
component="img"
height={isMobile ? 120 : 160}
image={`data:image/png;base64,${template.thumbnailBase64}`}
alt={template.nome}
sx={{ objectFit: "contain", bgcolor: "grey.100" }}
/>
) : (
<Box
sx={{
height: isMobile ? 120 : 160,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
<DescriptionIcon
sx={{ fontSize: { xs: 48, sm: 64 }, color: "grey.400" }}
/>
</Box>
)}
{/* Content */}
<CardContent sx={{ flexGrow: 1, p: { xs: 1.5, sm: 2 } }}>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
mb={1}
gap={1}
>
<Typography
variant={isMobile ? "body1" : "h6"}
noWrap
sx={{
flex: 1,
fontWeight: 600,
}}
>
{template.nome}
</Typography>
<Chip
label={template.categoria}
size="small"
color={getCategoriaColor(template.categoria)}
sx={{ flexShrink: 0 }}
/>
</Box>
{template.descrizione && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 1,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{template.descrizione}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{template.pageSize} -{" "}
{template.orientation === "portrait"
? "Verticale"
: "Orizzontale"}
</Typography>
</CardContent>
{/* Actions */}
<CardActions
sx={{
justifyContent: "space-between",
px: { xs: 1, sm: 1.5 },
py: 1,
borderTop: 1,
borderColor: "divider",
}}
>
<Box>
<Tooltip title="Modifica">
<IconButton
size="small"
onClick={() =>
navigate(`/report-editor/${template.id}`)
}
>
<EditIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton>
</Tooltip>
<Tooltip title="Duplica">
<IconButton
size="small"
onClick={() => cloneMutation.mutate(template.id)}
>
<CopyIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton>
</Tooltip>
<Tooltip title="Esporta">
<IconButton
size="small"
onClick={() => handleExport(template)}
>
<DownloadIcon
fontSize={isMobile ? "small" : "medium"}
/>
</IconButton>
</Tooltip>
</Box>
<Tooltip title="Elimina">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialog({ open: true, template })}
>
<DeleteIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton>
</Tooltip>
</CardActions>
</Card>
</Grid>
))}
</Grid>
)}
{/* Mobile FAB */}
{isMobile && (
<Zoom in>
<Fab
color="primary"
aria-label="Nuovo template"
onClick={() => navigate("/report-editor")}
sx={{
position: "fixed",
bottom: 16,
right: 16,
zIndex: 1000,
}}
>
<AddIcon />
</Fab>
</Zoom>
)}
{/* Delete Dialog */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, template: null })}
fullWidth
maxWidth="xs"
fullScreen={isMobile}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare il template "
{deleteDialog.template?.nome}"?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button
onClick={() => setDeleteDialog({ open: false, template: null })}
fullWidth={isMobile}
>
Annulla
</Button>
<Button
color="error"
variant="contained"
onClick={() =>
deleteDialog.template &&
deleteMutation.mutate(deleteDialog.template.id)
}
disabled={deleteMutation.isPending}
fullWidth={isMobile}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
{/* Import Dialog */}
<Dialog
open={importDialog}
onClose={() => {
setImportDialog(false);
setImportFile(null);
}}
fullWidth
maxWidth="xs"
fullScreen={isMobile}
>
<DialogTitle>Importa Template</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" mb={2}>
Seleziona un file .aprt da importare
</Typography>
<Button variant="outlined" component="label" fullWidth>
{importFile ? importFile.name : "Seleziona File"}
<input
type="file"
hidden
accept=".aprt,.json"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
/>
</Button>
</DialogContent>
<DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button
onClick={() => {
setImportDialog(false);
setImportFile(null);
}}
fullWidth={isMobile}
>
Annulla
</Button>
<Button
variant="contained"
onClick={handleImport}
disabled={!importFile || importMutation.isPending}
fullWidth={isMobile}
>
{importMutation.isPending ? "Importazione..." : "Importa"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}