538 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|