This commit is contained in:
2025-11-28 11:51:29 +01:00
parent bb22213d19
commit 30cd0c51f5
14 changed files with 3622 additions and 1011 deletions

113
CLAUDE.md
View File

@@ -46,12 +46,56 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
## Quick Start - Session Recovery ## Quick Start - Session Recovery
**Ultima sessione:** 28 Novembre 2025 (sera) **Ultima sessione:** 28 Novembre 2025 (tarda notte)
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso **Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**Lavoro completato nell'ultima sessione:** **Lavoro completato nell'ultima sessione:**
- **NUOVA FEATURE: Toolbar Report Designer Migliorata Drasticamente** - COMPLETATO
- Design moderno stile Canva/Figma con gradient buttons e animazioni fluide
- **Sezioni etichettate** su desktop (INSERISCI, MODIFICA, CRONOLOGIA, VISTA, ZOOM, PAGINA)
- **Toolbar contestuale** dinamica che appare quando un elemento è selezionato:
- Per testo: formattazione (grassetto, corsivo, sottolineato), allineamento, color picker
- Per forme/linee: color picker riempimento/bordo, spessore bordo
- Per immagini: indicatore mantieni proporzioni
- Mostra tipo elemento, nome e dimensioni in tempo reale
- **Color Picker integrato** con palette di 20 colori predefiniti
- **Indicatore stato salvataggio** visivo: Salvato (verde), Non salvato (arancione), Salvataggio... (spinner)
- **Badge** sul pulsante Snap che mostra quante opzioni sono attive
- **Zoom esteso** fino al 300% con pulsante "Adatta alla finestra"
- Pannello scorciatoie tastiera ampliato
- Aggiunto `textDecoration` a `AprtStyle` per supportare sottolineato
- **FIX: Indicatore "Non Salvato" errato** - RISOLTO
- Prima usava `canUndo` che indicava solo presenza di history
- Ora usa `undoCount` confrontato con `lastSavedUndoCount` per tracking accurato
- **NUOVA FEATURE: Auto-Save con Toggle** - COMPLETATO
- Salvataggio automatico dopo 1 secondo di inattività (debounce)
- **Abilitato di default**
- Toggle nella toolbar (icona AutoMode) per attivare/disattivare
- Quando auto-save è attivo, il pulsante "Salva" manuale è nascosto
- Quando auto-save è disattivo, il pulsante "Salva" appare
- Non si attiva per template nuovi (richiede primo salvataggio manuale)
- Non si attiva durante salvataggio in corso
**Lavoro completato nelle sessioni precedenti (28 Novembre 2025 notte):**
- **NUOVA FEATURE: Responsive Design Completo** - COMPLETATO
- Tutta l'applicazione è ora responsive per mobile, tablet e desktop
- Breakpoints: mobile (<600px), tablet (600-900px), desktop (>900px)
- **Layout.tsx**: Sidebar collassata con icone su tablet, drawer mobile
- **ReportTemplatesPage**: Header stackato su mobile, FAB per nuovo template, dialog fullScreen
- **ReportEditorPage**: BottomNavigation + SwipeableDrawer (70vh) per pannelli su mobile, auto-zoom
- **EditorToolbar**: 3 varianti (mobile compatta con riga collassabile, tablet media, desktop completa)
- **Pannelli laterali** (DataBindingPanel, PropertiesPanel, PageNavigator): larghezza piena su mobile
- **DatasetSelector**: Header collassabile su mobile
- **PreviewDialog**: fullScreen su mobile con navigazione step-by-step (dataset → entity)
- **ImageUploadDialog**: fullScreen su mobile, area drag-drop ottimizzata, bottoni stacked
**Lavoro completato nelle sessioni precedenti (28 Novembre 2025 sera):**
- **FIX: Variabili Globali Report ({{$pageNumber}}, {{$totalPages}}, ecc.)** - RISOLTO - **FIX: Variabili Globali Report ({{$pageNumber}}, {{$totalPages}}, ecc.)** - RISOLTO
- Le variabili speciali ora vengono correttamente risolte nel PDF finale - Le variabili speciali ora vengono correttamente risolte nel PDF finale
- Aggiunta classe `PageContext` per passare numero pagina e totale pagine durante il rendering - Aggiunta classe `PageContext` per passare numero pagina e totale pagine durante il rendering
@@ -625,9 +669,9 @@ Formato JSON esportabile/importabile per portabilità template:
- [x] Editor visuale drag-and-drop con Fabric.js - [x] Editor visuale drag-and-drop con Fabric.js
- [x] Supporto elementi: testo, forme, linee, tabelle, immagini (placeholder) - [x] Supporto elementi: testo, forme, linee, tabelle, immagini (placeholder)
- [x] Gestione zoom (25% - 200%) - [x] Gestione zoom (25% - 300%)
- [x] Griglia e snap to grid - [x] Griglia e snap to grid
- [x] Undo/Redo (max 20 stati) - [x] Undo/Redo (max 100 stati)
- [x] Shortcuts tastiera (Ctrl+Z, Ctrl+Y, Ctrl+S, Delete) - [x] Shortcuts tastiera (Ctrl+Z, Ctrl+Y, Ctrl+S, Delete)
- [x] Pannello proprietà con posizione, stile, contenuto - [x] Pannello proprietà con posizione, stile, contenuto
- [x] Data binding con browser campi disponibili - [x] Data binding con browser campi disponibili
@@ -638,6 +682,12 @@ Formato JSON esportabile/importabile per portabilità template:
- [x] Clone template - [x] Clone template
- [x] Generazione PDF default per eventi - [x] Generazione PDF default per eventi
- [x] Formattazione campi (valuta, data, numero, percentuale) - [x] Formattazione campi (valuta, data, numero, percentuale)
- [x] **Responsive design completo** (mobile, tablet, desktop)
- [x] **Toolbar professionale** stile Canva/Figma con sezioni etichettate
- [x] **Toolbar contestuale** per formattazione rapida (testo, forme, immagini)
- [x] **Color picker integrato** con palette preset
- [x] **Auto-save** con toggle (abilitato di default, 1s debounce)
- [x] **Indicatore stato salvataggio** accurato (Salvato/Non salvato/Salvataggio...)
### Cosa Manca per Completare ### Cosa Manca per Completare
@@ -700,6 +750,7 @@ Formato JSON esportabile/importabile per portabilità template:
- [x] Keyboard shortcuts - [x] Keyboard shortcuts
- [x] PageNavigator (gestione multi-pagina) - [x] PageNavigator (gestione multi-pagina)
- [x] Navigazione pagine in toolbar - [x] Navigazione pagine in toolbar
- [x] **Responsive design** (mobile/tablet/desktop)
- [ ] Upload e gestione immagini nell'editor - [ ] Upload e gestione immagini nell'editor
- [ ] Editor tabelle avanzato (colonne, binding dati) - [ ] Editor tabelle avanzato (colonne, binding dati)
- [ ] UI relazioni tra dataset - [ ] UI relazioni tra dataset
@@ -943,6 +994,62 @@ frontend/src/
``` ```
- **File:** `ReportGeneratorService.cs` - Metodi `GeneratePdfAsync()`, `RenderContentToBitmap()`, `RenderElementToCanvas()`, `RenderTextToCanvas()`, `ResolveContent()`, `ResolveBindingWithFormat()`, `ResolveBinding()`, `ResolveExpression()`, `ResolveBindingPath()` - **File:** `ReportGeneratorService.cs` - Metodi `GeneratePdfAsync()`, `RenderContentToBitmap()`, `RenderElementToCanvas()`, `RenderTextToCanvas()`, `ResolveContent()`, `ResolveBindingWithFormat()`, `ResolveBinding()`, `ResolveExpression()`, `ResolveBindingPath()`
16. **Responsive Design Completo (IMPLEMENTATO 28/11/2025 notte):**
- **Obiettivo:** Rendere tutta l'applicazione responsive per mobile, tablet e desktop
- **Breakpoints MUI utilizzati:**
- Mobile: `theme.breakpoints.down("sm")` → < 600px
- Tablet: `theme.breakpoints.between("sm", "md")` → 600-900px
- Desktop: `theme.breakpoints.up("md")` → > 900px
- **Pattern principale per Report Editor su mobile:**
- `BottomNavigation` per switch tra pannelli (Pagine, Dati, Proprietà)
- `SwipeableDrawer` con `anchor="bottom"` e altezza 70vh per contenuto pannelli
- Auto-zoom canvas: 0.5 mobile, 0.75 tablet, 1 desktop
- **Pattern per toolbar mobile:**
- Riga primaria con azioni essenziali sempre visibili
- Riga secondaria collassabile con `<Collapse>` per azioni secondarie
- **Pattern per dialog mobile:**
- `fullScreen` prop su Dialog
- AppBar con pulsante back invece di DialogTitle
- Navigazione step-by-step invece di layout side-by-side
- **File modificati:**
- `Layout.tsx` - Sidebar collassata su tablet
- `ReportTemplatesPage.tsx` - FAB, fullScreen dialogs
- `ReportEditorPage.tsx` - BottomNavigation + SwipeableDrawer
- `EditorToolbar.tsx` - 3 varianti layout (mobile/tablet/desktop)
- `DataBindingPanel.tsx`, `PropertiesPanel.tsx`, `PageNavigator.tsx` - Width responsive
- `DatasetSelector.tsx` - Header collapsible
- `PreviewDialog.tsx`, `ImageUploadDialog.tsx` - fullScreen + step navigation
17. **Indicatore "Non Salvato" Errato (FIX 28/11/2025 tarda notte):**
- **Problema:** Dopo il salvataggio, l'indicatore continuava a mostrare "Non salvato"
- **Causa:** `hasUnsavedChanges` era basato su `templateHistory.canUndo` che indica solo se c'è history disponibile, non se ci sono modifiche non salvate
- **Soluzione:** Introdotto `lastSavedUndoCount` che viene aggiornato dopo ogni salvataggio riuscito. `hasUnsavedChanges` ora confronta `templateHistory.undoCount !== lastSavedUndoCount`
- **File:** `ReportEditorPage.tsx`
18. **Auto-Save Feature (IMPLEMENTATO 28/11/2025 tarda notte):**
- **Funzionalità:** Salvataggio automatico dopo 1 secondo di inattività
- **Implementazione:**
- Stato `autoSaveEnabled` (default: true) in `ReportEditorPage.tsx`
- `useEffect` con debounce di 1000ms che triggera `saveMutation.mutate()`
- Non si attiva se: `isNew`, `!hasUnsavedChanges`, `saveMutation.isPending`
- Toggle nella toolbar con icona `AutoSaveIcon` (da @mui/icons-material)
- Pulsante "Salva" nascosto quando auto-save è attivo
- **Props toolbar:** `autoSaveEnabled`, `onAutoSaveToggle`
- **File:** `ReportEditorPage.tsx`, `EditorToolbar.tsx`
19. **Toolbar Migliorata Stile Canva/Figma (IMPLEMENTATO 28/11/2025 tarda notte):**
- **Miglioramenti:**
- Design moderno con gradient buttons e animazioni fluide
- Sezioni etichettate su desktop (INSERISCI, MODIFICA, CRONOLOGIA, VISTA, ZOOM, PAGINA)
- Toolbar contestuale dinamica basata su tipo elemento selezionato
- Color picker integrato con 20 colori preset
- Indicatore stato salvataggio visivo
- Badge su pulsante Snap
- Zoom esteso fino a 300%
- **Componenti aggiunti:** `ToolbarSection`, `StyledIconButton`, `ColorPickerButton`
- **Type aggiunto:** `textDecoration` in `AprtStyle`
- **File:** `EditorToolbar.tsx`, `types/report.ts`
### Schema Database Report System ### Schema Database Report System
Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`): Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`):

View File

@@ -13,6 +13,8 @@ import {
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { import {
Menu as MenuIcon, Menu as MenuIcon,
@@ -24,9 +26,11 @@ import {
Person as PersonIcon, Person as PersonIcon,
CalendarMonth as CalendarIcon, CalendarMonth as CalendarIcon,
Print as PrintIcon, Print as PrintIcon,
Close as CloseIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
const drawerWidth = 240; const DRAWER_WIDTH = 240;
const DRAWER_WIDTH_COLLAPSED = 64;
const menuItems = [ const menuItems = [
{ text: "Dashboard", icon: <DashboardIcon />, path: "/" }, { text: "Dashboard", icon: <DashboardIcon />, path: "/" },
@@ -43,53 +47,97 @@ export default function Layout() {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const theme = useTheme();
// Breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "md")); // 600-900px
// Drawer width based on screen size
const drawerWidth = isTablet ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
const handleDrawerToggle = () => { const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen); setMobileOpen(!mobileOpen);
}; };
const drawer = ( const drawer = (
<div> <Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Toolbar> <Toolbar
<Typography sx={{
variant="h6" justifyContent: isTablet ? "center" : "space-between",
noWrap minHeight: { xs: 56, sm: 64 },
component="div" }}
sx={{ fontWeight: "bold" }} >
> {!isTablet && (
Apollinare <Typography
</Typography> variant="h6"
noWrap
component="div"
sx={{ fontWeight: "bold" }}
>
Apollinare
</Typography>
)}
{isMobile && (
<IconButton onClick={handleDrawerToggle} sx={{ ml: "auto" }}>
<CloseIcon />
</IconButton>
)}
</Toolbar> </Toolbar>
<Divider /> <Divider />
<List> <List sx={{ flex: 1, py: 1 }}>
{menuItems.map((item) => ( {menuItems.map((item) => (
<ListItem key={item.text} disablePadding> <ListItem key={item.text} disablePadding sx={{ px: 1 }}>
<ListItemButton <ListItemButton
selected={location.pathname === item.path} selected={location.pathname === item.path}
onClick={() => { onClick={() => {
navigate(item.path); navigate(item.path);
setMobileOpen(false); if (isMobile) setMobileOpen(false);
}}
sx={{
borderRadius: 1,
minHeight: 48,
justifyContent: isTablet ? "center" : "flex-start",
px: isTablet ? 1 : 2,
}} }}
> >
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon
<ListItemText primary={item.text} /> sx={{
minWidth: isTablet ? 0 : 40,
justifyContent: "center",
}}
>
{item.icon}
</ListItemIcon>
{!isTablet && <ListItemText primary={item.text} />}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
))} ))}
</List> </List>
</div> {!isTablet && (
<Box sx={{ p: 2, borderTop: 1, borderColor: "divider" }}>
<Typography variant="caption" color="text.secondary">
© 2025 Apollinare
</Typography>
</Box>
)}
</Box>
); );
return ( return (
<Box sx={{ display: "flex" }}> <Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar <AppBar
position="fixed" position="fixed"
sx={{ sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` }, width: {
xs: "100%",
sm: `calc(100% - ${drawerWidth}px)`,
},
ml: { sm: `${drawerWidth}px` }, ml: { sm: `${drawerWidth}px` },
boxShadow: 1,
}} }}
> >
<Toolbar> <Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }}>
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="open drawer" aria-label="open drawer"
@@ -99,14 +147,27 @@ export default function Layout() {
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" noWrap component="div"> <Typography
Catering & Banqueting Management variant="h6"
noWrap
component="div"
sx={{
fontSize: { xs: "1rem", sm: "1.25rem" },
flexGrow: 1,
}}
>
{isMobile ? "Apollinare" : "Catering & Banqueting Management"}
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
{/* Mobile Drawer */}
<Box <Box
component="nav" component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }} sx={{
width: { sm: drawerWidth },
flexShrink: { sm: 0 },
}}
> >
<Drawer <Drawer
variant="temporary" variant="temporary"
@@ -117,12 +178,14 @@ export default function Layout() {
display: { xs: "block", sm: "none" }, display: { xs: "block", sm: "none" },
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
boxSizing: "border-box", boxSizing: "border-box",
width: drawerWidth, width: DRAWER_WIDTH,
}, },
}} }}
> >
{drawer} {drawer}
</Drawer> </Drawer>
{/* Desktop/Tablet Drawer */}
<Drawer <Drawer
variant="permanent" variant="permanent"
sx={{ sx={{
@@ -130,6 +193,10 @@ export default function Layout() {
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
boxSizing: "border-box", boxSizing: "border-box",
width: drawerWidth, width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}, },
}} }}
open open
@@ -137,16 +204,34 @@ export default function Layout() {
{drawer} {drawer}
</Drawer> </Drawer>
</Box> </Box>
{/* Main Content */}
<Box <Box
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
p: 3, width: {
width: { sm: `calc(100% - ${drawerWidth}px)` }, xs: "100%",
mt: 8, sm: `calc(100% - ${drawerWidth}px)`,
},
minHeight: "100vh",
display: "flex",
flexDirection: "column",
}} }}
> >
<Outlet /> {/* Toolbar spacer */}
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }} />
{/* Content */}
<Box
sx={{
flexGrow: 1,
p: { xs: 1.5, sm: 2, md: 3 },
overflow: "auto",
}}
>
<Outlet />
</Box>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -15,6 +15,8 @@ import {
Tooltip, Tooltip,
Divider, Divider,
alpha, alpha,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandLess, ExpandLess,
@@ -59,6 +61,9 @@ export default function DataBindingPanel({
onInsertBinding, onInsertBinding,
onRemoveDataset, onRemoveDataset,
}: DataBindingPanelProps) { }: DataBindingPanelProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [expanded, setExpanded] = useState<string[]>(["special"]); const [expanded, setExpanded] = useState<string[]>(["special"]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [copiedField, setCopiedField] = useState<string | null>(null); const [copiedField, setCopiedField] = useState<string | null>(null);
@@ -200,12 +205,16 @@ export default function DataBindingPanel({
return count; return count;
}, [search, schemas]); }, [search, schemas]);
// Panel width based on context (full width in mobile drawer)
const panelWidth = isMobile ? "100%" : 300;
if (selectedDatasets.length === 0) { if (selectedDatasets.length === 0) {
return ( return (
<Box <Box
sx={{ sx={{
width: 300, width: panelWidth,
borderRight: 1, minWidth: isMobile ? undefined : 300,
borderRight: isMobile ? 0 : 1,
borderColor: "divider", borderColor: "divider",
p: 3, p: 3,
bgcolor: "grey.50", bgcolor: "grey.50",
@@ -214,6 +223,7 @@ export default function DataBindingPanel({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
textAlign: "center", textAlign: "center",
height: isMobile ? "100%" : undefined,
}} }}
> >
<TableIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} /> <TableIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
@@ -231,24 +241,28 @@ export default function DataBindingPanel({
return ( return (
<Box <Box
sx={{ sx={{
width: 300, width: panelWidth,
borderRight: 1, minWidth: isMobile ? undefined : 300,
borderRight: isMobile ? 0 : 1,
borderColor: "divider", borderColor: "divider",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
bgcolor: "background.paper", bgcolor: "background.paper",
height: isMobile ? "100%" : undefined,
}} }}
> >
{/* Header con ricerca */} {/* Header con ricerca */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}> <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
<Typography {!isMobile && (
variant="subtitle2" <Typography
color="primary" variant="subtitle2"
gutterBottom color="primary"
fontWeight={600} gutterBottom
> fontWeight={600}
Campi Disponibili >
</Typography> Campi Disponibili
</Typography>
)}
<TextField <TextField
placeholder="Cerca campo..." placeholder="Cerca campo..."
size="small" size="small"
@@ -447,7 +461,9 @@ export default function DataBindingPanel({
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={field.label} primary={field.label}
secondary={field.name} secondary={
isMobile ? undefined : field.name
}
primaryTypographyProps={{ primaryTypographyProps={{
variant: "body2", variant: "body2",
fontSize: "0.8rem", fontSize: "0.8rem",
@@ -496,7 +512,9 @@ export default function DataBindingPanel({
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={collection.label} primary={collection.label}
secondary={collection.description} secondary={
isMobile ? undefined : collection.description
}
primaryTypographyProps={{ primaryTypographyProps={{
variant: "body2", variant: "body2",
fontWeight: 500, fontWeight: 500,
@@ -592,7 +610,7 @@ export default function DataBindingPanel({
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={field.label} primary={field.label}
secondary={fieldPath} secondary={isMobile ? undefined : fieldPath}
primaryTypographyProps={{ primaryTypographyProps={{
variant: "body2", variant: "body2",
fontSize: "0.8rem", fontSize: "0.8rem",
@@ -682,7 +700,11 @@ export default function DataBindingPanel({
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={item.label} primary={item.label}
secondary={item.binding.replace(/[{}]/g, "")} secondary={
isMobile
? undefined
: item.binding.replace(/[{}]/g, "")
}
primaryTypographyProps={{ variant: "body2" }} primaryTypographyProps={{ variant: "body2" }}
secondaryTypographyProps={{ secondaryTypographyProps={{
variant: "caption", variant: "caption",
@@ -711,8 +733,9 @@ export default function DataBindingPanel({
}} }}
> >
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Clicca su un campo per inserirlo nell'elemento selezionato, oppure usa {isMobile
l'icona copia per copiare il binding negli appunti. ? "Tocca un campo per inserirlo"
: "Clicca su un campo per inserirlo nell'elemento selezionato, oppure usa l'icona copia per copiare il binding negli appunti."}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@@ -13,6 +13,9 @@ import {
ListSubheader, ListSubheader,
Badge, Badge,
Button, Button,
useMediaQuery,
useTheme,
Collapse,
} from "@mui/material"; } from "@mui/material";
import { import {
Add as AddIcon, Add as AddIcon,
@@ -30,6 +33,8 @@ import {
Info as InfoIcon, Info as InfoIcon,
Settings as SettingsIcon, Settings as SettingsIcon,
Dataset as DatasetIcon, Dataset as DatasetIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import type { DatasetTypeDto } from "../../types/report"; import type { DatasetTypeDto } from "../../types/report";
@@ -48,10 +53,15 @@ export default function DatasetSelector({
onRemoveDataset, onRemoveDataset,
onOpenDatasetManager, onOpenDatasetManager,
}: DatasetSelectorProps) { }: DatasetSelectorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "md"));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [hoveredDataset, setHoveredDataset] = useState<DatasetTypeDto | null>( const [hoveredDataset, setHoveredDataset] = useState<DatasetTypeDto | null>(
null, null,
); );
const [expanded, setExpanded] = useState(!isMobile);
const handleOpenMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -152,18 +162,169 @@ export default function DatasetSelector({
return [...baseCategories, ...additionalCategories]; return [...baseCategories, ...additionalCategories];
}, [groupedDatasets]); }, [groupedDatasets]);
// Compact mobile view
if (isMobile) {
return (
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
bgcolor: "grey.50",
}}
>
{/* Header - always visible */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 1,
cursor: "pointer",
}}
onClick={() => setExpanded(!expanded)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<DatasetIcon fontSize="small" color="primary" />
<Typography variant="body2" fontWeight={500}>
Dataset ({selectedDatasets.length})
</Typography>
</Box>
<IconButton size="small">
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
{/* Expandable content */}
<Collapse in={expanded}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1,
pb: 1,
flexWrap: "wrap",
}}
>
{selectedDatasets.map((dataset) => (
<Chip
key={dataset.id}
icon={getDatasetIcon(dataset.icon)}
label={dataset.name}
size="small"
color={getCategoryColor(dataset.category, dataset.isVirtual)}
variant="filled"
onDelete={() => onRemoveDataset(dataset.id)}
sx={{ fontWeight: 500 }}
/>
))}
{selectedDatasets.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
fontStyle="italic"
>
Nessun dataset selezionato
</Typography>
)}
{unselectedDatasets.length > 0 && (
<IconButton size="small" onClick={handleOpenMenu} color="primary">
<AddIcon fontSize="small" />
</IconButton>
)}
</Box>
</Collapse>
{/* Menu - shared */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
PaperProps={{
sx: { minWidth: 280, maxHeight: "70vh" },
}}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle2" color="primary">
Seleziona Dataset
</Typography>
</Box>
<Divider />
{categoryOrder.map((category) => {
const datasets = groupedDatasets[category];
if (!datasets || datasets.length === 0) return null;
return (
<Box key={category}>
<ListSubheader
sx={{
bgcolor: "grey.100",
lineHeight: "28px",
fontSize: "0.7rem",
fontWeight: 600,
}}
>
{category}
</ListSubheader>
{datasets.map((dataset) => (
<MenuItem
key={dataset.id}
onClick={() => handleSelectDataset(dataset)}
sx={{ py: 1 }}
>
<ListItemIcon
sx={{
color: `${getCategoryColor(dataset.category, dataset.isVirtual)}.main`,
}}
>
{getDatasetIcon(dataset.icon)}
</ListItemIcon>
<ListItemText
primary={dataset.name}
primaryTypographyProps={{ variant: "body2" }}
/>
</MenuItem>
))}
</Box>
);
})}
{onOpenDatasetManager && [
<Divider key="divider" />,
<MenuItem
key="manager"
onClick={() => {
handleCloseMenu();
onOpenDatasetManager();
}}
>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Gestisci Dataset" />
</MenuItem>,
]}
</Menu>
</Box>
);
}
// Tablet/Desktop view
return ( return (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 1, gap: 1,
p: 1.5, p: isTablet ? 1 : 1.5,
borderBottom: 1, borderBottom: 1,
borderColor: "divider", borderColor: "divider",
bgcolor: "grey.50", bgcolor: "grey.50",
flexWrap: "wrap", flexWrap: "wrap",
minHeight: 52, minHeight: isTablet ? 44 : 52,
}} }}
> >
<Typography <Typography
@@ -360,7 +521,7 @@ export default function DatasetSelector({
)} )}
{/* Dataset Manager Button */} {/* Dataset Manager Button */}
{onOpenDatasetManager && ( {onOpenDatasetManager && !isTablet && (
<Tooltip title="Gestisci Dataset Virtuali"> <Tooltip title="Gestisci Dataset Virtuali">
<Button <Button
size="small" size="small"
@@ -382,6 +543,19 @@ export default function DatasetSelector({
</Tooltip> </Tooltip>
)} )}
{/* Tablet: Compact manager button */}
{onOpenDatasetManager && isTablet && (
<Tooltip title="Gestisci Dataset Virtuali">
<IconButton
size="small"
onClick={onOpenDatasetManager}
sx={{ ml: "auto" }}
>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* Info sui dataset selezionati */} {/* Info sui dataset selezionati */}
{selectedDatasets.length > 0 && !onOpenDatasetManager && ( {selectedDatasets.length > 0 && !onOpenDatasetManager && (
<Tooltip <Tooltip

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,17 @@ import {
CircularProgress, CircularProgress,
Alert, Alert,
IconButton, IconButton,
useMediaQuery,
useTheme,
AppBar,
Toolbar,
} from "@mui/material"; } from "@mui/material";
import { import {
CloudUpload as UploadIcon, CloudUpload as UploadIcon,
Link as LinkIcon, Link as LinkIcon,
Close as CloseIcon, Close as CloseIcon,
Image as ImageIcon, Image as ImageIcon,
ArrowBack as ArrowBackIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
interface ImageUploadDialogProps { interface ImageUploadDialogProps {
@@ -54,6 +59,9 @@ export default function ImageUploadDialog({
onClose, onClose,
onImageSelected, onImageSelected,
}: ImageUploadDialogProps) { }: ImageUploadDialogProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -132,7 +140,7 @@ export default function ImageUploadDialog({
setLoading(false); setLoading(false);
} }
}, },
[processFile] [processFile],
); );
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -151,7 +159,7 @@ export default function ImageUploadDialog({
setIsDragging(false); setIsDragging(false);
handleFileSelect(e.dataTransfer.files); handleFileSelect(e.dataTransfer.files);
}, },
[handleFileSelect] [handleFileSelect],
); );
const handleUrlLoad = useCallback(async () => { const handleUrlLoad = useCallback(async () => {
@@ -220,7 +228,7 @@ export default function ImageUploadDialog({
setError( setError(
innerErr instanceof Error innerErr instanceof Error
? innerErr.message ? innerErr.message
: "Errore nel caricamento dell'immagine" : "Errore nel caricamento dell'immagine",
); );
} }
} finally { } finally {
@@ -236,20 +244,46 @@ export default function ImageUploadDialog({
}, [preview, onImageSelected, handleClose]); }, [preview, onImageSelected, handleClose]);
return ( return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> <Dialog
<DialogTitle> open={open}
<Box display="flex" justifyContent="space-between" alignItems="center"> onClose={handleClose}
<Box display="flex" alignItems="center" gap={1}> maxWidth="sm"
<ImageIcon color="primary" /> fullWidth
<Typography variant="h6">Inserisci Immagine</Typography> fullScreen={isMobile}
>
{isMobile ? (
// Mobile: AppBar header
<AppBar position="static" color="default" elevation={0}>
<Toolbar>
<IconButton edge="start" onClick={handleClose} sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
<ImageIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Inserisci Immagine
</Typography>
</Toolbar>
</AppBar>
) : (
// Desktop: Standard DialogTitle
<DialogTitle>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Box display="flex" alignItems="center" gap={1}>
<ImageIcon color="primary" />
<Typography variant="h6">Inserisci Immagine</Typography>
</Box>
<IconButton size="small" onClick={handleClose}>
<CloseIcon />
</IconButton>
</Box> </Box>
<IconButton size="small" onClick={handleClose}> </DialogTitle>
<CloseIcon /> )}
</IconButton>
</Box>
</DialogTitle>
<DialogContent> <DialogContent sx={{ p: isMobile ? 2 : 3 }}>
<Tabs <Tabs
value={tab} value={tab}
onChange={(_, v) => { onChange={(_, v) => {
@@ -258,9 +292,20 @@ export default function ImageUploadDialog({
setPreview(null); setPreview(null);
}} }}
sx={{ borderBottom: 1, borderColor: "divider" }} sx={{ borderBottom: 1, borderColor: "divider" }}
variant={isMobile ? "fullWidth" : "standard"}
> >
<Tab icon={<UploadIcon />} label="Carica File" iconPosition="start" /> <Tab
<Tab icon={<LinkIcon />} label="Da URL" iconPosition="start" /> icon={<UploadIcon />}
label={isMobile ? "Carica" : "Carica File"}
iconPosition="start"
sx={{ minHeight: isMobile ? 56 : 48 }}
/>
<Tab
icon={<LinkIcon />}
label={isMobile ? "URL" : "Da URL"}
iconPosition="start"
sx={{ minHeight: isMobile ? 56 : 48 }}
/>
</Tabs> </Tabs>
{error && ( {error && (
@@ -277,11 +322,16 @@ export default function ImageUploadDialog({
borderStyle: "dashed", borderStyle: "dashed",
borderColor: isDragging ? "primary.main" : "divider", borderColor: isDragging ? "primary.main" : "divider",
borderRadius: 2, borderRadius: 2,
p: 4, p: isMobile ? 3 : 4,
textAlign: "center", textAlign: "center",
bgcolor: isDragging ? "primary.50" : "grey.50", bgcolor: isDragging ? "primary.50" : "grey.50",
cursor: "pointer", cursor: "pointer",
transition: "all 0.2s", transition: "all 0.2s",
minHeight: isMobile ? 150 : "auto",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
"&:hover": { "&:hover": {
borderColor: "primary.light", borderColor: "primary.light",
bgcolor: "primary.50", bgcolor: "primary.50",
@@ -304,16 +354,30 @@ export default function ImageUploadDialog({
) : ( ) : (
<> <>
<UploadIcon <UploadIcon
sx={{ fontSize: 48, color: "text.secondary", mb: 1 }} sx={{
fontSize: isMobile ? 40 : 48,
color: "text.secondary",
mb: 1,
}}
/> />
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
Trascina un'immagine qui {isMobile
? "Tocca per selezionare"
: "Trascina un'immagine qui"}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> {!isMobile && (
oppure clicca per selezionare un file <Typography variant="body2" color="text.secondary">
</Typography> oppure clicca per selezionare un file
<Typography variant="caption" color="text.secondary" mt={1}> </Typography>
Formati supportati: JPG, PNG, GIF, WebP, SVG (max 10MB) )}
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1 }}
>
{isMobile
? "JPG, PNG, GIF, WebP (max 10MB)"
: "Formati supportati: JPG, PNG, GIF, WebP, SVG (max 10MB)"}
</Typography> </Typography>
</> </>
)} )}
@@ -330,6 +394,7 @@ export default function ImageUploadDialog({
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
fullWidth fullWidth
disabled={loading} disabled={loading}
size={isMobile ? "medium" : "small"}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
handleUrlLoad(); handleUrlLoad();
@@ -340,7 +405,11 @@ export default function ImageUploadDialog({
variant="outlined" variant="outlined"
onClick={handleUrlLoad} onClick={handleUrlLoad}
disabled={loading || !url.trim()} disabled={loading || !url.trim()}
startIcon={loading ? <CircularProgress size={20} /> : <LinkIcon />} startIcon={
loading ? <CircularProgress size={20} /> : <LinkIcon />
}
size={isMobile ? "large" : "medium"}
fullWidth={isMobile}
> >
{loading ? "Caricamento..." : "Carica da URL"} {loading ? "Caricamento..." : "Carica da URL"}
</Button> </Button>
@@ -378,17 +447,32 @@ export default function ImageUploadDialog({
alt="Preview" alt="Preview"
style={{ style={{
maxWidth: "100%", maxWidth: "100%",
maxHeight: 200, maxHeight: isMobile ? 150 : 200,
objectFit: "contain", objectFit: "contain",
}} }}
/> />
</Box> </Box>
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box
display="flex"
flexDirection={isMobile ? "column" : "row"}
justifyContent="space-between"
alignItems={isMobile ? "flex-start" : "center"}
gap={isMobile ? 0.5 : 0}
>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{preview.originalWidth} × {preview.originalHeight} px {preview.originalWidth} × {preview.originalHeight} px
</Typography> </Typography>
{preview.fileName && ( {preview.fileName && (
<Typography variant="caption" color="text.secondary"> <Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: isMobile ? "100%" : 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{preview.fileName} {preview.fileName}
</Typography> </Typography>
)} )}
@@ -397,16 +481,46 @@ export default function ImageUploadDialog({
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions
<Button onClick={handleClose}>Annulla</Button> sx={{
<Button px: isMobile ? 2 : 3,
variant="contained" pb: isMobile ? 3 : 2,
onClick={handleConfirm} pt: isMobile ? 1 : 0,
disabled={!preview} flexDirection: isMobile ? "column" : "row",
startIcon={<ImageIcon />} gap: isMobile ? 1 : 0,
> }}
Inserisci Immagine >
</Button> {isMobile ? (
// Mobile: Stacked full-width buttons
<>
<Button
variant="contained"
onClick={handleConfirm}
disabled={!preview}
startIcon={<ImageIcon />}
fullWidth
size="large"
>
Inserisci Immagine
</Button>
<Button onClick={handleClose} fullWidth>
Annulla
</Button>
</>
) : (
// Desktop: Standard layout
<>
<Button onClick={handleClose}>Annulla</Button>
<Button
variant="contained"
onClick={handleConfirm}
disabled={!preview}
startIcon={<ImageIcon />}
>
Inserisci Immagine
</Button>
</>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -19,6 +19,8 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
TextField, TextField,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { import {
Add as AddIcon, Add as AddIcon,
@@ -54,6 +56,9 @@ export default function PageNavigator({
onRenamePage, onRenamePage,
onMovePage, onMovePage,
}: PageNavigatorProps) { }: PageNavigatorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [menuAnchor, setMenuAnchor] = useState<{ const [menuAnchor, setMenuAnchor] = useState<{
element: HTMLElement; element: HTMLElement;
pageId: string; pageId: string;
@@ -153,15 +158,20 @@ export default function PageNavigator({
const currentMenuIndex = menuAnchor ? getPageIndex(menuAnchor.pageId) : -1; const currentMenuIndex = menuAnchor ? getPageIndex(menuAnchor.pageId) : -1;
// Width based on context
const panelWidth = isMobile ? "100%" : 220;
return ( return (
<Box <Box
sx={{ sx={{
width: 220, width: panelWidth,
borderRight: 1, minWidth: isMobile ? undefined : 180,
borderRight: isMobile ? 0 : 1,
borderColor: "divider", borderColor: "divider",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
bgcolor: "#fafafa", bgcolor: "#fafafa",
height: isMobile ? "100%" : undefined,
}} }}
> >
{/* Header */} {/* Header */}
@@ -212,7 +222,7 @@ export default function PageNavigator({
onClick={() => onSelectPage(page.id)} onClick={() => onSelectPage(page.id)}
sx={{ sx={{
borderRadius: 1, borderRadius: 1,
py: 1, py: isMobile ? 1.5 : 1,
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: "primary.light", bgcolor: "primary.light",
color: "primary.contrastText", color: "primary.contrastText",
@@ -230,8 +240,8 @@ export default function PageNavigator({
> >
<Box <Box
sx={{ sx={{
width: 32, width: isMobile ? 28 : 32,
height: 40, height: isMobile ? 36 : 40,
mr: 1.5, mr: 1.5,
borderRadius: 0.5, borderRadius: 0.5,
bgcolor: isSelected ? "primary.dark" : "white", bgcolor: isSelected ? "primary.dark" : "white",
@@ -243,6 +253,7 @@ export default function PageNavigator({
fontSize: "0.75rem", fontSize: "0.75rem",
fontWeight: 600, fontWeight: 600,
color: isSelected ? "white" : "text.secondary", color: isSelected ? "white" : "text.secondary",
flexShrink: 0,
}} }}
> >
{index + 1} {index + 1}
@@ -286,7 +297,7 @@ export default function PageNavigator({
<Button <Button
fullWidth fullWidth
variant="outlined" variant="outlined"
size="small" size={isMobile ? "medium" : "small"}
startIcon={<AddIcon />} startIcon={<AddIcon />}
onClick={onAddPage} onClick={onAddPage}
sx={{ textTransform: "none" }} sx={{ textTransform: "none" }}
@@ -358,6 +369,7 @@ export default function PageNavigator({
} }
maxWidth="xs" maxWidth="xs"
fullWidth fullWidth
fullScreen={isMobile}
> >
<DialogTitle>Rinomina Pagina</DialogTitle> <DialogTitle>Rinomina Pagina</DialogTitle>
<DialogContent> <DialogContent>
@@ -380,11 +392,12 @@ export default function PageNavigator({
sx={{ mt: 1 }} sx={{ mt: 1 }}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ p: isMobile ? 2 : 1 }}>
<Button <Button
onClick={() => onClick={() =>
setRenameDialog({ open: false, pageId: "", currentName: "" }) setRenameDialog({ open: false, pageId: "", currentName: "" })
} }
fullWidth={isMobile}
> >
Annulla Annulla
</Button> </Button>
@@ -392,6 +405,7 @@ export default function PageNavigator({
variant="contained" variant="contained"
onClick={handleRenameConfirm} onClick={handleRenameConfirm}
disabled={!renameDialog.currentName.trim()} disabled={!renameDialog.currentName.trim()}
fullWidth={isMobile}
> >
Rinomina Rinomina
</Button> </Button>
@@ -405,6 +419,7 @@ export default function PageNavigator({
setDeleteConfirm({ open: false, pageId: "", pageName: "" }) setDeleteConfirm({ open: false, pageId: "", pageName: "" })
} }
maxWidth="xs" maxWidth="xs"
fullScreen={isMobile}
> >
<DialogTitle>Elimina Pagina</DialogTitle> <DialogTitle>Elimina Pagina</DialogTitle>
<DialogContent> <DialogContent>
@@ -415,11 +430,12 @@ export default function PageNavigator({
Tutti gli elementi sulla pagina verranno eliminati. Tutti gli elementi sulla pagina verranno eliminati.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ p: isMobile ? 2 : 1 }}>
<Button <Button
onClick={() => onClick={() =>
setDeleteConfirm({ open: false, pageId: "", pageName: "" }) setDeleteConfirm({ open: false, pageId: "", pageName: "" })
} }
fullWidth={isMobile}
> >
Annulla Annulla
</Button> </Button>
@@ -427,6 +443,7 @@ export default function PageNavigator({
variant="contained" variant="contained"
color="error" color="error"
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
fullWidth={isMobile}
> >
Elimina Elimina
</Button> </Button>

View File

@@ -21,6 +21,10 @@ import {
IconButton, IconButton,
Tooltip, Tooltip,
alpha, alpha,
useMediaQuery,
useTheme,
AppBar,
Toolbar,
} from "@mui/material"; } from "@mui/material";
import { import {
Search as SearchIcon, Search as SearchIcon,
@@ -36,6 +40,8 @@ import {
TableChart as TableIcon, TableChart as TableIcon,
Check as CheckIcon, Check as CheckIcon,
Clear as ClearIcon, Clear as ClearIcon,
Close as CloseIcon,
ArrowBack as BackIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { reportGeneratorService } from "../../services/reportService"; import { reportGeneratorService } from "../../services/reportService";
@@ -60,11 +66,15 @@ export default function PreviewDialog({
onGeneratePreview, onGeneratePreview,
isGenerating, isGenerating,
}: PreviewDialogProps) { }: PreviewDialogProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [selections, setSelections] = useState<Record<string, number | null>>( const [selections, setSelections] = useState<Record<string, number | null>>(
{}, {},
); );
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({}); const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const [activeDataset, setActiveDataset] = useState<string | null>(null); const [activeDataset, setActiveDataset] = useState<string | null>(null);
const [mobileShowList, setMobileShowList] = useState(true); // Mobile: show dataset list or entity list
// Fetch entities for each selected dataset // Fetch entities for each selected dataset
const entityQueries = useQueries({ const entityQueries = useQueries({
@@ -90,6 +100,7 @@ export default function PreviewDialog({
setActiveDataset( setActiveDataset(
selectedDatasets.length > 0 ? selectedDatasets[0].id : null, selectedDatasets.length > 0 ? selectedDatasets[0].id : null,
); );
setMobileShowList(true);
} }
}, [open, selectedDatasets]); }, [open, selectedDatasets]);
@@ -99,6 +110,11 @@ export default function PreviewDialog({
[datasetId]: entityId, [datasetId]: entityId,
})); }));
// On mobile, go back to list after selection
if (isMobile) {
setMobileShowList(true);
}
// Passa al prossimo dataset non selezionato // Passa al prossimo dataset non selezionato
const currentIndex = selectedDatasets.findIndex( const currentIndex = selectedDatasets.findIndex(
(ds) => ds.id === datasetId, (ds) => ds.id === datasetId,
@@ -108,6 +124,9 @@ export default function PreviewDialog({
); );
if (nextUnselected) { if (nextUnselected) {
setActiveDataset(nextUnselected.id); setActiveDataset(nextUnselected.id);
if (isMobile) {
setTimeout(() => setMobileShowList(false), 300);
}
} }
}; };
@@ -142,6 +161,13 @@ export default function PreviewDialog({
onGeneratePreview(dataSources); onGeneratePreview(dataSources);
}; };
const handleSelectDataset = (datasetId: string) => {
setActiveDataset(datasetId);
if (isMobile) {
setMobileShowList(false);
}
};
const getDatasetIcon = (icon: string) => { const getDatasetIcon = (icon: string) => {
switch (icon) { switch (icon) {
case "event": case "event":
@@ -205,31 +231,318 @@ export default function PreviewDialog({
searchTerms[activeDataset || ""] || "", searchTerms[activeDataset || ""] || "",
); );
// Render dataset list (sidebar on desktop, main view on mobile)
const renderDatasetList = () => (
<List disablePadding>
{selectedDatasets.map((dataset, index) => {
const isSelected = selections[dataset.id] !== null;
const isActive = activeDataset === dataset.id;
const query = entityQueries[index];
const selectedEntity = isSelected
? ((query.data as EntityListItemDto[]) || []).find(
(e) => e.id === selections[dataset.id],
)
: null;
return (
<ListItemButton
key={dataset.id}
selected={isActive}
onClick={() => handleSelectDataset(dataset.id)}
sx={{
borderBottom: 1,
borderColor: "divider",
bgcolor: isSelected
? (theme) => alpha(theme.palette.success.main, 0.08)
: "inherit",
py: isMobile ? 1.5 : 1,
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{isSelected ? (
<CheckIcon color="success" />
) : (
getDatasetIcon(dataset.icon)
)}
</ListItemIcon>
<ListItemText
primary={dataset.name}
secondary={
selectedEntity ? selectedEntity.label : "Non selezionato"
}
primaryTypographyProps={{
variant: "body2",
fontWeight: isActive ? 600 : 400,
}}
secondaryTypographyProps={{
variant: "caption",
noWrap: true,
color: isSelected ? "success.main" : "text.secondary",
}}
/>
{isSelected && (
<Tooltip title="Rimuovi selezione">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearSelection(dataset.id);
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</ListItemButton>
);
})}
</List>
);
// Render entity list for active dataset
const renderEntityList = () => {
if (!activeDatasetObj) return null;
return (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
height: "100%",
}}
>
{/* Header dataset attivo */}
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: "divider",
bgcolor: "grey.50",
}}
>
{isMobile && (
<Box display="flex" alignItems="center" gap={1} mb={1}>
<IconButton size="small" onClick={() => setMobileShowList(true)}>
<BackIcon />
</IconButton>
<Typography variant="subtitle2">Seleziona</Typography>
</Box>
)}
<Box display="flex" alignItems="center" gap={1} mb={1}>
{getDatasetIcon(activeDatasetObj.icon)}
<Typography variant="subtitle1" fontWeight={600}>
{activeDatasetObj.name}
</Typography>
{!isMobile && (
<Chip
label={activeDatasetObj.category}
size="small"
variant="outlined"
sx={{ ml: "auto" }}
/>
)}
</Box>
{!isMobile && (
<Typography
variant="caption"
color="text.secondary"
display="block"
mb={1.5}
>
{activeDatasetObj.description}
</Typography>
)}
{/* Ricerca */}
<TextField
placeholder={`Cerca...`}
size="small"
fullWidth
value={searchTerms[activeDataset || ""] || ""}
onChange={(e) => handleSearchChange(activeDataset!, e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: searchTerms[activeDataset || ""] && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => handleSearchChange(activeDataset!, "")}
>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
{/* Lista entità */}
<Box sx={{ flex: 1, overflow: "auto" }}>
{activeQuery?.isLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : filteredEntities.length === 0 ? (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
{searchTerms[activeDataset || ""]
? "Nessun risultato trovato"
: "Nessuna entità disponibile"}
</Typography>
</Box>
) : (
<List disablePadding>
{filteredEntities.map((entity) => {
const isEntitySelected =
selections[activeDataset!] === entity.id;
return (
<ListItemButton
key={entity.id}
onClick={() =>
handleSelectionChange(activeDataset!, entity.id)
}
selected={isEntitySelected}
sx={{
borderBottom: 1,
borderColor: "divider",
bgcolor: isEntitySelected
? (theme) => alpha(theme.palette.primary.main, 0.12)
: "inherit",
py: isMobile ? 1.5 : 1,
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{isEntitySelected ? (
<CheckIcon color="primary" />
) : (
<Box sx={{ width: 24 }} />
)}
</ListItemIcon>
<ListItemText
primary={entity.label}
secondary={
<Box component="span">
{entity.description}
{entity.secondaryInfo && !isMobile && (
<Typography
component="span"
variant="caption"
color="text.disabled"
sx={{ display: "block" }}
>
{entity.secondaryInfo}
</Typography>
)}
</Box>
}
primaryTypographyProps={{
variant: "body2",
fontWeight: isEntitySelected ? 600 : 400,
}}
secondaryTypographyProps={{
variant: "caption",
}}
/>
{entity.status && (
<Chip
label={entity.status}
size="small"
color={
entity.status === "Confermato"
? "success"
: entity.status === "Preventivo"
? "warning"
: "default"
}
variant="outlined"
sx={{ ml: 1 }}
/>
)}
</ListItemButton>
);
})}
</List>
)}
</Box>
{/* Footer con conteggio */}
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "grey.50",
}}
>
<Typography variant="caption" color="text.secondary">
{filteredEntities.length} risultati
</Typography>
</Box>
</Box>
);
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
maxWidth="md" maxWidth="md"
fullWidth fullWidth
PaperProps={{ sx: { height: "80vh", maxHeight: 700 } }} fullScreen={isMobile}
PaperProps={{
sx: {
height: isMobile ? "100%" : "80vh",
maxHeight: isMobile ? "100%" : 700,
},
}}
> >
<DialogTitle sx={{ pb: 1 }}> {/* Mobile AppBar */}
<Box display="flex" alignItems="center" justifyContent="space-between"> {isMobile ? (
<Typography variant="h6">Anteprima Report</Typography> <AppBar position="static" color="default" elevation={0}>
<Chip <Toolbar>
label={`${selectedCount}/${selectedDatasets.length} selezionati`} <IconButton edge="start" onClick={onClose}>
color={allSelected ? "success" : "default"} <CloseIcon />
size="small" </IconButton>
/> <Typography variant="h6" sx={{ flex: 1, ml: 1 }}>
</Box> Anteprima Report
<Typography variant="body2" color="text.secondary"> </Typography>
Seleziona un'entità per ogni dataset da utilizzare nell'anteprima <Chip
</Typography> label={`${selectedCount}/${selectedDatasets.length}`}
</DialogTitle> color={allSelected ? "success" : "default"}
size="small"
/>
</Toolbar>
</AppBar>
) : (
<DialogTitle sx={{ pb: 1 }}>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Typography variant="h6">Anteprima Report</Typography>
<Chip
label={`${selectedCount}/${selectedDatasets.length} selezionati`}
color={allSelected ? "success" : "default"}
size="small"
/>
</Box>
<Typography variant="body2" color="text.secondary">
Seleziona un'entità per ogni dataset da utilizzare nell'anteprima
</Typography>
</DialogTitle>
)}
<Divider /> <Divider />
<DialogContent sx={{ p: 0, display: "flex" }}> <DialogContent sx={{ p: 0, display: "flex", overflow: "hidden" }}>
{hasError && ( {hasError && (
<Alert severity="error" sx={{ m: 2 }}> <Alert severity="error" sx={{ m: 2 }}>
Errore nel caricamento dei dati disponibili Errore nel caricamento dei dati disponibili
@@ -243,7 +556,38 @@ export default function PreviewDialog({
almeno un dataset per poter generare l'anteprima. almeno un dataset per poter generare l'anteprima.
</Alert> </Alert>
</Box> </Box>
) : isMobile ? (
// Mobile: Switch between dataset list and entity list
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{mobileShowList ? (
<Box sx={{ flex: 1, overflow: "auto" }}>
<Box
sx={{
p: 2,
bgcolor: "grey.50",
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" color="text.secondary">
Seleziona un'entità per ogni dataset
</Typography>
</Box>
{renderDatasetList()}
</Box>
) : (
renderEntityList()
)}
</Box>
) : ( ) : (
// Desktop: Side by side
<> <>
{/* Lista dataset (sidebar) */} {/* Lista dataset (sidebar) */}
<Paper <Paper
@@ -256,261 +600,21 @@ export default function PreviewDialog({
flexShrink: 0, flexShrink: 0,
}} }}
> >
<List disablePadding> {renderDatasetList()}
{selectedDatasets.map((dataset, index) => {
const isSelected = selections[dataset.id] !== null;
const isActive = activeDataset === dataset.id;
const query = entityQueries[index];
const selectedEntity = isSelected
? ((query.data as EntityListItemDto[]) || []).find(
(e) => e.id === selections[dataset.id],
)
: null;
return (
<ListItemButton
key={dataset.id}
selected={isActive}
onClick={() => setActiveDataset(dataset.id)}
sx={{
borderBottom: 1,
borderColor: "divider",
bgcolor: isSelected
? (theme) => alpha(theme.palette.success.main, 0.08)
: "inherit",
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{isSelected ? (
<CheckIcon color="success" />
) : (
getDatasetIcon(dataset.icon)
)}
</ListItemIcon>
<ListItemText
primary={dataset.name}
secondary={
selectedEntity
? selectedEntity.label
: "Non selezionato"
}
primaryTypographyProps={{
variant: "body2",
fontWeight: isActive ? 600 : 400,
}}
secondaryTypographyProps={{
variant: "caption",
noWrap: true,
color: isSelected ? "success.main" : "text.secondary",
}}
/>
{isSelected && (
<Tooltip title="Rimuovi selezione">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearSelection(dataset.id);
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</ListItemButton>
);
})}
</List>
</Paper> </Paper>
{/* Area principale con lista entità */} {/* Area principale con lista entità */}
<Box {renderEntityList()}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{activeDatasetObj && (
<>
{/* Header dataset attivo */}
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: "divider",
bgcolor: "grey.50",
}}
>
<Box display="flex" alignItems="center" gap={1} mb={1}>
{getDatasetIcon(activeDatasetObj.icon)}
<Typography variant="subtitle1" fontWeight={600}>
{activeDatasetObj.name}
</Typography>
<Chip
label={activeDatasetObj.category}
size="small"
variant="outlined"
sx={{ ml: "auto" }}
/>
</Box>
<Typography
variant="caption"
color="text.secondary"
display="block"
mb={1.5}
>
{activeDatasetObj.description}
</Typography>
{/* Ricerca */}
<TextField
placeholder={`Cerca in ${activeDatasetObj.name}...`}
size="small"
fullWidth
value={searchTerms[activeDataset || ""] || ""}
onChange={(e) =>
handleSearchChange(activeDataset!, e.target.value)
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: searchTerms[activeDataset || ""] && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() =>
handleSearchChange(activeDataset!, "")
}
>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
{/* Lista entità */}
<Box sx={{ flex: 1, overflow: "auto" }}>
{activeQuery?.isLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : filteredEntities.length === 0 ? (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
{searchTerms[activeDataset || ""]
? "Nessun risultato trovato per la ricerca"
: "Nessuna entità disponibile"}
</Typography>
</Box>
) : (
<List disablePadding>
{filteredEntities.map((entity) => {
const isEntitySelected =
selections[activeDataset!] === entity.id;
return (
<ListItemButton
key={entity.id}
onClick={() =>
handleSelectionChange(activeDataset!, entity.id)
}
selected={isEntitySelected}
sx={{
borderBottom: 1,
borderColor: "divider",
bgcolor: isEntitySelected
? (theme) =>
alpha(theme.palette.primary.main, 0.12)
: "inherit",
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{isEntitySelected ? (
<CheckIcon color="primary" />
) : (
<Box sx={{ width: 24 }} />
)}
</ListItemIcon>
<ListItemText
primary={entity.label}
secondary={
<Box component="span">
{entity.description}
{entity.secondaryInfo && (
<Typography
component="span"
variant="caption"
color="text.disabled"
sx={{ display: "block" }}
>
{entity.secondaryInfo}
</Typography>
)}
</Box>
}
primaryTypographyProps={{
variant: "body2",
fontWeight: isEntitySelected ? 600 : 400,
}}
secondaryTypographyProps={{
variant: "caption",
}}
/>
{entity.status && (
<Chip
label={entity.status}
size="small"
color={
entity.status === "Confermato"
? "success"
: entity.status === "Preventivo"
? "warning"
: "default"
}
variant="outlined"
sx={{ ml: 1 }}
/>
)}
</ListItemButton>
);
})}
</List>
)}
</Box>
{/* Footer con conteggio */}
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "grey.50",
}}
>
<Typography variant="caption" color="text.secondary">
{filteredEntities.length} risultati
{searchTerms[activeDataset || ""] &&
` per "${searchTerms[activeDataset || ""]}"`}
</Typography>
</Box>
</>
)}
</Box>
</> </>
)} )}
</DialogContent> </DialogContent>
<Divider /> <Divider />
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: isMobile ? 2 : 3, py: 2 }}>
<Button onClick={onClose}>Annulla</Button> <Button onClick={onClose} fullWidth={isMobile}>
Annulla
</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleGenerate} onClick={handleGenerate}
@@ -518,8 +622,13 @@ export default function PreviewDialog({
!allSelected || isGenerating || selectedDatasets.length === 0 !allSelected || isGenerating || selectedDatasets.length === 0
} }
startIcon={isGenerating ? <CircularProgress size={16} /> : null} startIcon={isGenerating ? <CircularProgress size={16} /> : null}
fullWidth={isMobile}
> >
{isGenerating ? "Generazione..." : "Genera Anteprima PDF"} {isGenerating
? "Generazione..."
: isMobile
? "Genera PDF"
: "Genera Anteprima PDF"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -22,6 +22,8 @@ import {
ListItemSecondaryAction, ListItemSecondaryAction,
Button, Button,
Chip, Chip,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandMore as ExpandMoreIcon, ExpandMore as ExpandMoreIcon,
@@ -92,6 +94,9 @@ export default function PropertiesPanel({
currentPage, currentPage,
onUpdateCurrentPage, onUpdateCurrentPage,
}: PropertiesPanelProps) { }: PropertiesPanelProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [expanded, setExpanded] = useState<string[]>([ const [expanded, setExpanded] = useState<string[]>([
"position", "position",
"style", "style",
@@ -175,23 +180,37 @@ export default function PropertiesPanel({
} }
}; };
// Panel width based on context
const panelWidth = isMobile ? "100%" : 280;
if (!element) { if (!element) {
// Show page settings when no element selected // Show page settings when no element selected
return ( return (
<Box <Box
sx={{ sx={{
width: 280, width: panelWidth,
borderLeft: 1, minWidth: isMobile ? undefined : 280,
borderLeft: isMobile ? 0 : 1,
borderColor: "divider", borderColor: "divider",
p: 2, p: 2,
overflow: "auto", overflow: "auto",
height: isMobile ? "100%" : undefined,
}} }}
> >
<Typography variant="subtitle2" color="primary" gutterBottom> {!isMobile && (
{currentPage ? `Pagina: ${currentPage.name}` : "Impostazioni Pagina"} <Typography variant="subtitle2" color="primary" gutterBottom>
</Typography> {currentPage
? `Pagina: ${currentPage.name}`
: "Impostazioni Pagina"}
</Typography>
)}
<Box display="flex" flexDirection="column" gap={2} mt={2}> <Box
display="flex"
flexDirection="column"
gap={2}
mt={isMobile ? 0 : 2}
>
{/* Page name (only if currentPage is available) */} {/* Page name (only if currentPage is available) */}
{currentPage && onUpdateCurrentPage && ( {currentPage && onUpdateCurrentPage && (
<> <>
@@ -354,10 +373,12 @@ export default function PropertiesPanel({
return ( return (
<Box <Box
sx={{ sx={{
width: 280, width: panelWidth,
borderLeft: 1, minWidth: isMobile ? undefined : 280,
borderLeft: isMobile ? 0 : 1,
borderColor: "divider", borderColor: "divider",
overflow: "auto", overflow: "auto",
height: isMobile ? "100%" : undefined,
}} }}
> >
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}> <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
@@ -373,11 +394,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("position")} expanded={expanded.includes("position")}
onChange={handleAccordion("position")} onChange={handleAccordion("position")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Posizione</Typography> <Typography variant="subtitle2">Posizione</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<Box display="grid" gridTemplateColumns="1fr 1fr" gap={1}> <Box display="grid" gridTemplateColumns="1fr 1fr" gap={1}>
<TextField <TextField
label="X" label="X"
@@ -449,11 +472,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("style")} expanded={expanded.includes("style")}
onChange={handleAccordion("style")} onChange={handleAccordion("style")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Stile</Typography> <Typography variant="subtitle2">Stile</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<Box display="flex" flexDirection="column" gap={2}> <Box display="flex" flexDirection="column" gap={2}>
{element.type === "text" && ( {element.type === "text" && (
<> <>
@@ -474,7 +499,12 @@ export default function PropertiesPanel({
</Select> </Select>
</FormControl> </FormControl>
<Box display="flex" gap={1} alignItems="center"> <Box
display="flex"
gap={1}
alignItems="center"
flexWrap="wrap"
>
<TextField <TextField
label="Dimensione" label="Dimensione"
type="number" type="number"
@@ -483,7 +513,7 @@ export default function PropertiesPanel({
onChange={(e) => onChange={(e) =>
updateStyle("fontSize", Number(e.target.value)) updateStyle("fontSize", Number(e.target.value))
} }
sx={{ width: 100 }} sx={{ width: isMobile ? "100%" : 100 }}
/> />
<ToggleButtonGroup size="small"> <ToggleButtonGroup size="small">
<ToggleButton <ToggleButton
@@ -594,7 +624,7 @@ export default function PropertiesPanel({
/> />
</Box> </Box>
<Box display="flex" gap={1}> <Box display="flex" gap={1} flexWrap="wrap">
<TextField <TextField
label="Bordo" label="Bordo"
type="number" type="number"
@@ -605,7 +635,7 @@ export default function PropertiesPanel({
} }
sx={{ width: 80 }} sx={{ width: 80 }}
/> />
<Box flex={1}> <Box flex={1} minWidth={80}>
<Typography variant="caption">Colore Bordo</Typography> <Typography variant="caption">Colore Bordo</Typography>
<input <input
type="color" type="color"
@@ -630,11 +660,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("image")} expanded={expanded.includes("image")}
onChange={handleAccordion("image")} onChange={handleAccordion("image")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Immagine</Typography> <Typography variant="subtitle2">Immagine</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<Box display="flex" flexDirection="column" gap={2}> <Box display="flex" flexDirection="column" gap={2}>
{/* Image Preview or Upload Button */} {/* Image Preview or Upload Button */}
{element.imageSettings?.src ? ( {element.imageSettings?.src ? (
@@ -642,7 +674,7 @@ export default function PropertiesPanel({
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
height: 120, height: isMobile ? 100 : 120,
bgcolor: "grey.100", bgcolor: "grey.100",
borderRadius: 1, borderRadius: 1,
overflow: "hidden", overflow: "hidden",
@@ -724,10 +756,12 @@ export default function PropertiesPanel({
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
flexWrap="wrap"
gap={1}
> >
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<AspectRatioIcon fontSize="small" color="action" /> <AspectRatioIcon fontSize="small" color="action" />
<Typography variant="body2">Mantieni proporzioni</Typography> <Typography variant="body2">Proporzioni</Typography>
</Box> </Box>
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
@@ -750,51 +784,6 @@ export default function PropertiesPanel({
</ToggleButtonGroup> </ToggleButtonGroup>
</Box> </Box>
{/* Alignment */}
<Box>
<Typography variant="caption" gutterBottom>
Allineamento Orizzontale
</Typography>
<ToggleButtonGroup
value={element.imageSettings?.horizontalAlign || "center"}
exclusive
onChange={(_, value) =>
value && updateImageSettings("horizontalAlign", value)
}
size="small"
fullWidth
>
<ToggleButton value="left">
<AlignLeftIcon />
</ToggleButton>
<ToggleButton value="center">
<AlignCenterIcon />
</ToggleButton>
<ToggleButton value="right">
<AlignRightIcon />
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box>
<Typography variant="caption" gutterBottom>
Allineamento Verticale
</Typography>
<ToggleButtonGroup
value={element.imageSettings?.verticalAlign || "center"}
exclusive
onChange={(_, value) =>
value && updateImageSettings("verticalAlign", value)
}
size="small"
fullWidth
>
<ToggleButton value="top">Alto</ToggleButton>
<ToggleButton value="center">Centro</ToggleButton>
<ToggleButton value="bottom">Basso</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Opacity */} {/* Opacity */}
<Box> <Box>
<Typography variant="caption"> <Typography variant="caption">
@@ -878,11 +867,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("content")} expanded={expanded.includes("content")}
onChange={handleAccordion("content")} onChange={handleAccordion("content")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Contenuto</Typography> <Typography variant="subtitle2">Contenuto</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<Box display="flex" flexDirection="column" gap={2}> <Box display="flex" flexDirection="column" gap={2}>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Tipo</InputLabel> <InputLabel>Tipo</InputLabel>
@@ -915,7 +906,7 @@ export default function PropertiesPanel({
placeholder="{{evento.codice}}" placeholder="{{evento.codice}}"
value={element.content?.expression || ""} value={element.content?.expression || ""}
onChange={(e) => updateContent("expression", e.target.value)} onChange={(e) => updateContent("expression", e.target.value)}
helperText="Es: {{evento.codice}}, {{cliente.ragioneSociale}}" helperText={isMobile ? undefined : "Es: {{evento.codice}}"}
/> />
)} )}
@@ -959,11 +950,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("section")} expanded={expanded.includes("section")}
onChange={handleAccordion("section")} onChange={handleAccordion("section")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Sezione</Typography> <Typography variant="subtitle2">Sezione</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Sezione</InputLabel> <InputLabel>Sezione</InputLabel>
<Select <Select
@@ -988,11 +981,13 @@ export default function PropertiesPanel({
<Accordion <Accordion
expanded={expanded.includes("table")} expanded={expanded.includes("table")}
onChange={handleAccordion("table")} onChange={handleAccordion("table")}
disableGutters
sx={{ "&:before": { display: "none" } }}
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Configurazione Tabella</Typography> <Typography variant="subtitle2">Configurazione Tabella</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails sx={{ pt: 0 }}>
<Box display="flex" flexDirection="column" gap={2}> <Box display="flex" flexDirection="column" gap={2}>
{/* Data Source Selection */} {/* Data Source Selection */}
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">

View File

@@ -23,7 +23,21 @@ import {
MenuItem, MenuItem,
Alert, Alert,
Snackbar, Snackbar,
useMediaQuery,
useTheme,
IconButton,
BottomNavigation,
BottomNavigationAction,
Paper,
SwipeableDrawer,
Typography,
} from "@mui/material"; } from "@mui/material";
import {
Storage as DataIcon,
Settings as SettingsIcon,
Description as PageIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import EditorCanvas, { import EditorCanvas, {
type ContextMenuEvent, type ContextMenuEvent,
} from "../components/reportEditor/EditorCanvas"; } from "../components/reportEditor/EditorCanvas";
@@ -67,11 +81,20 @@ import {
defaultPage, defaultPage,
} from "../types/report"; } from "../types/report";
// Panel types for mobile navigation
type MobilePanel = "pages" | "data" | "properties" | null;
export default function ReportEditorPage() { export default function ReportEditorPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isNew = !id; const isNew = !id;
const theme = useTheme();
// Responsive breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "lg")); // 600-1200px
const isDesktop = useMediaQuery(theme.breakpoints.up("lg")); // > 1200px
// Template state with robust undo/redo (100 states history) // Template state with robust undo/redo (100 states history)
const [templateHistory, historyActions] = useHistory<AprtTemplate>( const [templateHistory, historyActions] = useHistory<AprtTemplate>(
@@ -102,7 +125,7 @@ export default function ReportEditorPage() {
const [selectedElementId, setSelectedElementId] = useState<string | null>( const [selectedElementId, setSelectedElementId] = useState<string | null>(
null, null,
); );
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(isMobile ? 0.5 : 1);
const [showGrid, setShowGrid] = useState(true); const [showGrid, setShowGrid] = useState(true);
const [snapOptions, setSnapOptions] = useState<SnapOptions>({ const [snapOptions, setSnapOptions] = useState<SnapOptions>({
grid: false, grid: false,
@@ -113,6 +136,9 @@ export default function ReportEditorPage() {
}); });
const [gridSize] = useState(5); // 5mm grid const [gridSize] = useState(5); // 5mm grid
// Mobile panel state
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// UI state // UI state
const [saveDialog, setSaveDialog] = useState(false); const [saveDialog, setSaveDialog] = useState(false);
const [previewDialog, setPreviewDialog] = useState(false); const [previewDialog, setPreviewDialog] = useState(false);
@@ -139,6 +165,24 @@ export default function ReportEditorPage() {
}); });
const [clipboard, setClipboard] = useState<AprtElement | null>(null); const [clipboard, setClipboard] = useState<AprtElement | null>(null);
// Track unsaved changes - reset when saved, set when modified
const [lastSavedUndoCount, setLastSavedUndoCount] = useState(0);
const hasUnsavedChanges = templateHistory.undoCount !== lastSavedUndoCount;
// Auto-save feature - enabled by default
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
// Update zoom on screen size change
useEffect(() => {
if (isMobile) {
setZoom(0.5);
} else if (isTablet) {
setZoom(0.75);
} else {
setZoom(1);
}
}, [isMobile, isTablet]);
// Load existing template // Load existing template
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({ const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
queryKey: ["report-template", id], queryKey: ["report-template", id],
@@ -301,6 +345,8 @@ export default function ReportEditorPage() {
severity: "success", severity: "success",
}); });
setSaveDialog(false); setSaveDialog(false);
// Mark current state as saved
setLastSavedUndoCount(templateHistory.undoCount);
if (isNew) { if (isNew) {
navigate(`/report-editor/${result.id}`, { replace: true }); navigate(`/report-editor/${result.id}`, { replace: true });
} }
@@ -531,8 +577,13 @@ export default function ReportEditorPage() {
elements: [...prev.elements, newElement], elements: [...prev.elements, newElement],
})); }));
setSelectedElementId(newElement.id); setSelectedElementId(newElement.id);
// On mobile, open properties panel after adding element
if (isMobile) {
setMobilePanel("properties");
}
}, },
[historyActions, currentPageId], [historyActions, currentPageId, isMobile],
); );
// Update element without history (for continuous updates like dragging) // Update element without history (for continuous updates like dragging)
@@ -1176,6 +1227,31 @@ export default function ReportEditorPage() {
selectedElementId, selectedElementId,
]); ]);
// Auto-save effect - saves after 1 second of inactivity when there are unsaved changes
useEffect(() => {
if (
!autoSaveEnabled ||
!hasUnsavedChanges ||
isNew ||
saveMutation.isPending
) {
return;
}
const timeoutId = setTimeout(() => {
saveMutation.mutate({ template, info: templateInfo });
}, 1000); // 1 second debounce
return () => clearTimeout(timeoutId);
}, [
autoSaveEnabled,
hasUnsavedChanges,
isNew,
template,
templateInfo,
saveMutation,
]);
if (isLoadingTemplate && id) { if (isLoadingTemplate && id) {
return ( return (
<Box <Box
@@ -1189,24 +1265,107 @@ export default function ReportEditorPage() {
); );
} }
// Render panels based on screen size
const renderPageNavigator = () => (
<PageNavigator
pages={template.pages}
elements={template.elements}
currentPageId={currentPageId}
onSelectPage={handleSelectPage}
onAddPage={handleAddPage}
onDuplicatePage={handleDuplicatePage}
onDeletePage={handleDeletePage}
onRenamePage={handleRenamePage}
onMovePage={handleMovePage}
/>
);
const renderDataBindingPanel = () => (
<DataBindingPanel
schemas={schemas}
selectedDatasets={selectedDatasets}
onInsertBinding={handleInsertBinding}
onRemoveDataset={handleRemoveDataset}
/>
);
const renderPropertiesPanel = () => (
<PropertiesPanel
element={selectedElement || null}
onUpdateElement={handleUpdateSelectedElement}
pageSize={(currentPage?.pageSize as PageSize) || template.meta.pageSize}
orientation={
(currentPage?.orientation as PageOrientation) ||
template.meta.orientation
}
margins={currentPage?.margins || template.meta.margins}
onUpdatePage={handleUpdatePage}
fontFamilies={fontFamilies}
availableDatasets={availableDatasets}
dataSchemas={dataSchemaMap}
onOpenImageUpload={() => setImageUploadDialog(true)}
// Page-specific props
currentPage={currentPage}
onUpdateCurrentPage={(updates) => {
if (!currentPage) return;
historyActions.set((prev) => ({
...prev,
pages: prev.pages.map((p) =>
p.id === currentPage.id ? { ...p, ...updates } : p,
),
}));
}}
/>
);
// Mobile drawer content
const renderMobileDrawerContent = () => {
switch (mobilePanel) {
case "pages":
return renderPageNavigator();
case "data":
return renderDataBindingPanel();
case "properties":
return renderPropertiesPanel();
default:
return null;
}
};
const getMobilePanelTitle = () => {
switch (mobilePanel) {
case "pages":
return "Pagine";
case "data":
return "Campi Dati";
case "properties":
return "Proprietà";
default:
return "";
}
};
return ( return (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "calc(100vh - 64px)", height: { xs: "calc(100vh - 56px)", sm: "calc(100vh - 64px)" },
mx: -3, mx: { xs: -1.5, sm: -2, md: -3 },
mt: -3, mt: { xs: -1.5, sm: -2, md: -3 },
overflow: "hidden",
}} }}
> >
{/* Dataset Selector */} {/* Dataset Selector - hide on mobile, show in compact mode on tablet */}
<DatasetSelector {!isMobile && (
availableDatasets={availableDatasets} <DatasetSelector
selectedDatasets={selectedDatasets} availableDatasets={availableDatasets}
onAddDataset={handleAddDataset} selectedDatasets={selectedDatasets}
onRemoveDataset={handleRemoveDataset} onAddDataset={handleAddDataset}
onOpenDatasetManager={() => setDatasetManagerDialog(true)} onRemoveDataset={handleRemoveDataset}
/> onOpenDatasetManager={() => setDatasetManagerDialog(true)}
/>
)}
{/* Toolbar */} {/* Toolbar */}
<EditorToolbar <EditorToolbar
@@ -1244,30 +1403,40 @@ export default function ReportEditorPage() {
setSelectedElementId(null); setSelectedElementId(null);
} }
}} }}
// New props for enhanced toolbar
selectedElement={selectedElement}
onUpdateSelectedElement={handleUpdateSelectedElement}
hasUnsavedChanges={hasUnsavedChanges}
// Auto-save props
autoSaveEnabled={autoSaveEnabled}
onAutoSaveToggle={setAutoSaveEnabled}
/> />
{/* Main Editor Area */} {/* Main Editor Area */}
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}> <Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Page Navigator */} {/* Desktop: Show all panels */}
<PageNavigator {isDesktop && (
pages={template.pages} <>
elements={template.elements} {renderPageNavigator()}
currentPageId={currentPageId} {renderDataBindingPanel()}
onSelectPage={handleSelectPage} </>
onAddPage={handleAddPage} )}
onDuplicatePage={handleDuplicatePage}
onDeletePage={handleDeletePage}
onRenamePage={handleRenamePage}
onMovePage={handleMovePage}
/>
{/* Data Binding Panel */} {/* Tablet: Show page navigator and data panel in collapsible sidebars */}
<DataBindingPanel {isTablet && (
schemas={schemas} <Box
selectedDatasets={selectedDatasets} sx={{
onInsertBinding={handleInsertBinding} width: 180,
onRemoveDataset={handleRemoveDataset} borderRight: 1,
/> borderColor: "divider",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{renderPageNavigator()}
</Box>
)}
{/* Canvas - show only elements for current page */} {/* Canvas - show only elements for current page */}
<EditorCanvas <EditorCanvas
@@ -1286,7 +1455,13 @@ export default function ReportEditorPage() {
}, },
}} }}
selectedElementId={selectedElementId} selectedElementId={selectedElementId}
onSelectElement={setSelectedElementId} onSelectElement={(id) => {
setSelectedElementId(id);
// On mobile, auto-open properties when selecting element
if (isMobile && id) {
setMobilePanel("properties");
}
}}
onUpdateElement={handleUpdateElementWithoutHistory} onUpdateElement={handleUpdateElementWithoutHistory}
onUpdateElementComplete={historyActions.commit} onUpdateElementComplete={historyActions.commit}
zoom={zoom} zoom={zoom}
@@ -1296,43 +1471,103 @@ export default function ReportEditorPage() {
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
/> />
{/* Properties Panel */} {/* Desktop/Tablet: Properties Panel */}
<PropertiesPanel {!isMobile && renderPropertiesPanel()}
element={selectedElement || null}
onUpdateElement={handleUpdateSelectedElement}
pageSize={
(currentPage?.pageSize as PageSize) || template.meta.pageSize
}
orientation={
(currentPage?.orientation as PageOrientation) ||
template.meta.orientation
}
margins={currentPage?.margins || template.meta.margins}
onUpdatePage={handleUpdatePage}
fontFamilies={fontFamilies}
availableDatasets={availableDatasets}
dataSchemas={dataSchemaMap}
onOpenImageUpload={() => setImageUploadDialog(true)}
// Page-specific props
currentPage={currentPage}
onUpdateCurrentPage={(updates) => {
if (!currentPage) return;
historyActions.set((prev) => ({
...prev,
pages: prev.pages.map((p) =>
p.id === currentPage.id ? { ...p, ...updates } : p,
),
}));
}}
/>
</Box> </Box>
{/* Mobile Bottom Navigation */}
{isMobile && (
<Paper
sx={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 1100,
borderTop: 1,
borderColor: "divider",
}}
elevation={3}
>
<BottomNavigation
value={mobilePanel}
onChange={(_, newValue) => {
setMobilePanel(newValue === mobilePanel ? null : newValue);
}}
showLabels
>
<BottomNavigationAction
label="Pagine"
value="pages"
icon={<PageIcon />}
/>
<BottomNavigationAction
label="Dati"
value="data"
icon={<DataIcon />}
/>
<BottomNavigationAction
label="Proprietà"
value="properties"
icon={<SettingsIcon />}
/>
</BottomNavigation>
</Paper>
)}
{/* Mobile Panel Drawer */}
<SwipeableDrawer
anchor="bottom"
open={isMobile && mobilePanel !== null}
onClose={() => setMobilePanel(null)}
onOpen={() => {}}
disableSwipeToOpen
PaperProps={{
sx: {
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
{/* Drawer Header */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="h6">{getMobilePanelTitle()}</Typography>
<IconButton onClick={() => setMobilePanel(null)}>
<CloseIcon />
</IconButton>
</Box>
{/* Drawer Content */}
<Box sx={{ flex: 1, overflow: "auto" }}>
{renderMobileDrawerContent()}
</Box>
</Box>
</SwipeableDrawer>
{/* Save Dialog for new templates */} {/* Save Dialog for new templates */}
<Dialog <Dialog
open={saveDialog} open={saveDialog}
onClose={() => setSaveDialog(false)} onClose={() => setSaveDialog(false)}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
fullScreen={isMobile}
> >
<DialogTitle>Salva Template</DialogTitle> <DialogTitle>Salva Template</DialogTitle>
<DialogContent> <DialogContent>
@@ -1379,14 +1614,17 @@ export default function ReportEditorPage() {
</FormControl> </FormControl>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button onClick={() => setSaveDialog(false)}>Annulla</Button> <Button onClick={() => setSaveDialog(false)} fullWidth={isMobile}>
Annulla
</Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => onClick={() =>
saveMutation.mutate({ template, info: templateInfo }) saveMutation.mutate({ template, info: templateInfo })
} }
disabled={!templateInfo.nome || saveMutation.isPending} disabled={!templateInfo.nome || saveMutation.isPending}
fullWidth={isMobile}
> >
{saveMutation.isPending ? "Salvataggio..." : "Salva"} {saveMutation.isPending ? "Salvataggio..." : "Salva"}
</Button> </Button>

View File

@@ -22,6 +22,11 @@ import {
Chip, Chip,
Tooltip, Tooltip,
CircularProgress, CircularProgress,
useMediaQuery,
useTheme,
Stack,
Fab,
Zoom,
} from "@mui/material"; } from "@mui/material";
import { import {
Add as AddIcon, Add as AddIcon,
@@ -38,6 +43,11 @@ import type { ReportTemplateDto } from "../types/report";
export default function ReportTemplatesPage() { export default function ReportTemplatesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const theme = useTheme();
// Breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [filterCategoria, setFilterCategoria] = useState<string>(""); const [filterCategoria, setFilterCategoria] = useState<string>("");
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{
open: boolean; open: boolean;
@@ -138,34 +148,54 @@ export default function ReportTemplatesPage() {
} }
return ( return (
<Box> <Box sx={{ pb: isMobile ? 10 : 0 }}>
{/* Header */}
<Box <Box
display="flex" sx={{
justifyContent="space-between" display: "flex",
alignItems="center" flexDirection: { xs: "column", sm: "row" },
mb={3} justifyContent: "space-between",
alignItems: { xs: "stretch", sm: "center" },
gap: 2,
mb: 3,
}}
> >
<Typography variant="h4">Template Report</Typography> <Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 600 }}>
<Box display="flex" gap={2}> Template Report
<Button </Typography>
variant="outlined"
startIcon={<UploadIcon />} {/* Desktop/Tablet buttons */}
onClick={() => setImportDialog(true)} {!isMobile && (
> <Stack direction="row" spacing={2}>
Importa <Button
</Button> variant="outlined"
<Button startIcon={<UploadIcon />}
variant="contained" onClick={() => setImportDialog(true)}
startIcon={<AddIcon />} >
onClick={() => navigate("/report-editor")} Importa
> </Button>
Nuovo Template <Button
</Button> variant="contained"
</Box> startIcon={<AddIcon />}
onClick={() => navigate("/report-editor")}
>
Nuovo Template
</Button>
</Stack>
)}
</Box> </Box>
<Box mb={3}> {/* Filter */}
<FormControl size="small" sx={{ minWidth: 200 }}> <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> <InputLabel>Filtra per categoria</InputLabel>
<Select <Select
value={filterCategoria} value={filterCategoria}
@@ -180,13 +210,36 @@ export default function ReportTemplatesPage() {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
{/* Mobile import button inline with filter */}
{isMobile && (
<Button
variant="outlined"
startIcon={<UploadIcon />}
onClick={() => setImportDialog(true)}
fullWidth
>
Importa Template
</Button>
)}
</Box> </Box>
{/* Empty State */}
{templates.length === 0 ? ( {templates.length === 0 ? (
<Card> <Card>
<CardContent sx={{ textAlign: "center", py: 6 }}> <CardContent
sx={{
textAlign: "center",
py: { xs: 4, sm: 6 },
px: { xs: 2, sm: 3 },
}}
>
<DescriptionIcon <DescriptionIcon
sx={{ fontSize: 64, color: "text.secondary", mb: 2 }} sx={{
fontSize: { xs: 48, sm: 64 },
color: "text.secondary",
mb: 2,
}}
/> />
<Typography variant="h6" color="text.secondary" gutterBottom> <Typography variant="h6" color="text.secondary" gutterBottom>
Nessun template trovato Nessun template trovato
@@ -194,11 +247,16 @@ export default function ReportTemplatesPage() {
<Typography color="text.secondary" mb={3}> <Typography color="text.secondary" mb={3}>
Crea il tuo primo template di report o importane uno esistente Crea il tuo primo template di report o importane uno esistente
</Typography> </Typography>
<Box display="flex" gap={2} justifyContent="center"> <Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
justifyContent="center"
>
<Button <Button
variant="outlined" variant="outlined"
startIcon={<UploadIcon />} startIcon={<UploadIcon />}
onClick={() => setImportDialog(true)} onClick={() => setImportDialog(true)}
fullWidth={isMobile}
> >
Importa Template Importa Template
</Button> </Button>
@@ -206,27 +264,44 @@ export default function ReportTemplatesPage() {
variant="contained" variant="contained"
startIcon={<AddIcon />} startIcon={<AddIcon />}
onClick={() => navigate("/report-editor")} onClick={() => navigate("/report-editor")}
fullWidth={isMobile}
> >
Crea Template Crea Template
</Button> </Button>
</Box> </Stack>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Grid container spacing={3}> /* Template Grid */
<Grid container spacing={{ xs: 2, sm: 3 }}>
{templates.map((template) => ( {templates.map((template) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={template.id}> <Grid
size={{
xs: 12,
sm: 6,
md: 4,
lg: 3,
xl: 2,
}}
key={template.id}
>
<Card <Card
sx={{ sx={{
height: "100%", height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
transition: "transform 0.2s, box-shadow 0.2s",
"&:hover": {
transform: { sm: "translateY(-4px)" },
boxShadow: { sm: 4 },
},
}} }}
> >
{/* Thumbnail */}
{template.thumbnailBase64 ? ( {template.thumbnailBase64 ? (
<CardMedia <CardMedia
component="img" component="img"
height="160" height={isMobile ? 120 : 160}
image={`data:image/png;base64,${template.thumbnailBase64}`} image={`data:image/png;base64,${template.thumbnailBase64}`}
alt={template.nome} alt={template.nome}
sx={{ objectFit: "contain", bgcolor: "grey.100" }} sx={{ objectFit: "contain", bgcolor: "grey.100" }}
@@ -234,37 +309,56 @@ export default function ReportTemplatesPage() {
) : ( ) : (
<Box <Box
sx={{ sx={{
height: 160, height: isMobile ? 120 : 160,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
bgcolor: "grey.100", bgcolor: "grey.100",
}} }}
> >
<DescriptionIcon sx={{ fontSize: 64, color: "grey.400" }} /> <DescriptionIcon
sx={{ fontSize: { xs: 48, sm: 64 }, color: "grey.400" }}
/>
</Box> </Box>
)} )}
<CardContent sx={{ flexGrow: 1 }}>
{/* Content */}
<CardContent sx={{ flexGrow: 1, p: { xs: 1.5, sm: 2 } }}>
<Box <Box
display="flex" display="flex"
justifyContent="space-between" justifyContent="space-between"
alignItems="flex-start" alignItems="flex-start"
mb={1} mb={1}
gap={1}
> >
<Typography variant="h6" noWrap sx={{ maxWidth: "70%" }}> <Typography
variant={isMobile ? "body1" : "h6"}
noWrap
sx={{
flex: 1,
fontWeight: 600,
}}
>
{template.nome} {template.nome}
</Typography> </Typography>
<Chip <Chip
label={template.categoria} label={template.categoria}
size="small" size="small"
color={getCategoriaColor(template.categoria)} color={getCategoriaColor(template.categoria)}
sx={{ flexShrink: 0 }}
/> />
</Box> </Box>
{template.descrizione && ( {template.descrizione && (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ mb: 1 }} sx={{
mb: 1,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
> >
{template.descrizione} {template.descrizione}
</Typography> </Typography>
@@ -276,7 +370,17 @@ export default function ReportTemplatesPage() {
: "Orizzontale"} : "Orizzontale"}
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: "space-between" }}>
{/* Actions */}
<CardActions
sx={{
justifyContent: "space-between",
px: { xs: 1, sm: 1.5 },
py: 1,
borderTop: 1,
borderColor: "divider",
}}
>
<Box> <Box>
<Tooltip title="Modifica"> <Tooltip title="Modifica">
<IconButton <IconButton
@@ -285,7 +389,7 @@ export default function ReportTemplatesPage() {
navigate(`/report-editor/${template.id}`) navigate(`/report-editor/${template.id}`)
} }
> >
<EditIcon /> <EditIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Duplica"> <Tooltip title="Duplica">
@@ -293,7 +397,7 @@ export default function ReportTemplatesPage() {
size="small" size="small"
onClick={() => cloneMutation.mutate(template.id)} onClick={() => cloneMutation.mutate(template.id)}
> >
<CopyIcon /> <CopyIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Esporta"> <Tooltip title="Esporta">
@@ -301,7 +405,9 @@ export default function ReportTemplatesPage() {
size="small" size="small"
onClick={() => handleExport(template)} onClick={() => handleExport(template)}
> >
<DownloadIcon /> <DownloadIcon
fontSize={isMobile ? "small" : "medium"}
/>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
@@ -311,7 +417,7 @@ export default function ReportTemplatesPage() {
color="error" color="error"
onClick={() => setDeleteDialog({ open: true, template })} onClick={() => setDeleteDialog({ open: true, template })}
> >
<DeleteIcon /> <DeleteIcon fontSize={isMobile ? "small" : "medium"} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</CardActions> </CardActions>
@@ -321,10 +427,32 @@ export default function ReportTemplatesPage() {
</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 */} {/* Delete Dialog */}
<Dialog <Dialog
open={deleteDialog.open} open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, template: null })} onClose={() => setDeleteDialog({ open: false, template: null })}
fullWidth
maxWidth="xs"
fullScreen={isMobile}
> >
<DialogTitle>Conferma Eliminazione</DialogTitle> <DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent> <DialogContent>
@@ -336,9 +464,10 @@ export default function ReportTemplatesPage() {
Questa azione non può essere annullata. Questa azione non può essere annullata.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button <Button
onClick={() => setDeleteDialog({ open: false, template: null })} onClick={() => setDeleteDialog({ open: false, template: null })}
fullWidth={isMobile}
> >
Annulla Annulla
</Button> </Button>
@@ -350,6 +479,7 @@ export default function ReportTemplatesPage() {
deleteMutation.mutate(deleteDialog.template.id) deleteMutation.mutate(deleteDialog.template.id)
} }
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
fullWidth={isMobile}
> >
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"} {deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button> </Button>
@@ -363,6 +493,9 @@ export default function ReportTemplatesPage() {
setImportDialog(false); setImportDialog(false);
setImportFile(null); setImportFile(null);
}} }}
fullWidth
maxWidth="xs"
fullScreen={isMobile}
> >
<DialogTitle>Importa Template</DialogTitle> <DialogTitle>Importa Template</DialogTitle>
<DialogContent> <DialogContent>
@@ -379,12 +512,13 @@ export default function ReportTemplatesPage() {
/> />
</Button> </Button>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button <Button
onClick={() => { onClick={() => {
setImportDialog(false); setImportDialog(false);
setImportFile(null); setImportFile(null);
}} }}
fullWidth={isMobile}
> >
Annulla Annulla
</Button> </Button>
@@ -392,6 +526,7 @@ export default function ReportTemplatesPage() {
variant="contained" variant="contained"
onClick={handleImport} onClick={handleImport}
disabled={!importFile || importMutation.isPending} disabled={!importFile || importMutation.isPending}
fullWidth={isMobile}
> >
{importMutation.isPending ? "Importazione..." : "Importa"} {importMutation.isPending ? "Importazione..." : "Importa"}
</Button> </Button>

View File

@@ -125,6 +125,7 @@ export interface AprtStyle {
fontSize: number; fontSize: number;
fontWeight: "normal" | "bold"; fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic"; fontStyle: "normal" | "italic";
textDecoration?: "none" | "underline" | "line-through";
color: string; color: string;
backgroundColor?: string; backgroundColor?: string;
textAlign: "left" | "center" | "right" | "justify"; textAlign: "left" | "center" | "right" | "justify";

Binary file not shown.

Binary file not shown.