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
**Ultima sessione:** 28 Novembre 2025 (sera)
**Ultima sessione:** 28 Novembre 2025 (tarda notte)
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**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
- Le variabili speciali ora vengono correttamente risolte nel PDF finale
- 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] 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] Undo/Redo (max 20 stati)
- [x] Undo/Redo (max 100 stati)
- [x] Shortcuts tastiera (Ctrl+Z, Ctrl+Y, Ctrl+S, Delete)
- [x] Pannello proprietà con posizione, stile, contenuto
- [x] Data binding con browser campi disponibili
@@ -638,6 +682,12 @@ Formato JSON esportabile/importabile per portabilità template:
- [x] Clone template
- [x] Generazione PDF default per eventi
- [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
@@ -700,6 +750,7 @@ Formato JSON esportabile/importabile per portabilità template:
- [x] Keyboard shortcuts
- [x] PageNavigator (gestione multi-pagina)
- [x] Navigazione pagine in toolbar
- [x] **Responsive design** (mobile/tablet/desktop)
- [ ] Upload e gestione immagini nell'editor
- [ ] Editor tabelle avanzato (colonne, binding dati)
- [ ] UI relazioni tra dataset
@@ -943,6 +994,62 @@ frontend/src/
```
- **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
Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`):

View File

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

View File

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

View File

@@ -13,6 +13,9 @@ import {
ListSubheader,
Badge,
Button,
useMediaQuery,
useTheme,
Collapse,
} from "@mui/material";
import {
Add as AddIcon,
@@ -30,6 +33,8 @@ import {
Info as InfoIcon,
Settings as SettingsIcon,
Dataset as DatasetIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from "@mui/icons-material";
import type { DatasetTypeDto } from "../../types/report";
@@ -48,10 +53,15 @@ export default function DatasetSelector({
onRemoveDataset,
onOpenDatasetManager,
}: 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 [hoveredDataset, setHoveredDataset] = useState<DatasetTypeDto | null>(
null,
);
const [expanded, setExpanded] = useState(!isMobile);
const handleOpenMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -152,18 +162,169 @@ export default function DatasetSelector({
return [...baseCategories, ...additionalCategories];
}, [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 (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
p: 1.5,
p: isTablet ? 1 : 1.5,
borderBottom: 1,
borderColor: "divider",
bgcolor: "grey.50",
flexWrap: "wrap",
minHeight: 52,
minHeight: isTablet ? 44 : 52,
}}
>
<Typography
@@ -360,7 +521,7 @@ export default function DatasetSelector({
)}
{/* Dataset Manager Button */}
{onOpenDatasetManager && (
{onOpenDatasetManager && !isTablet && (
<Tooltip title="Gestisci Dataset Virtuali">
<Button
size="small"
@@ -382,6 +543,19 @@ export default function DatasetSelector({
</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 */}
{selectedDatasets.length > 0 && !onOpenDatasetManager && (
<Tooltip

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -21,6 +21,10 @@ import {
IconButton,
Tooltip,
alpha,
useMediaQuery,
useTheme,
AppBar,
Toolbar,
} from "@mui/material";
import {
Search as SearchIcon,
@@ -36,6 +40,8 @@ import {
TableChart as TableIcon,
Check as CheckIcon,
Clear as ClearIcon,
Close as CloseIcon,
ArrowBack as BackIcon,
} from "@mui/icons-material";
import { useQueries } from "@tanstack/react-query";
import { reportGeneratorService } from "../../services/reportService";
@@ -60,11 +66,15 @@ export default function PreviewDialog({
onGeneratePreview,
isGenerating,
}: PreviewDialogProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [selections, setSelections] = useState<Record<string, number | null>>(
{},
);
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
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
const entityQueries = useQueries({
@@ -90,6 +100,7 @@ export default function PreviewDialog({
setActiveDataset(
selectedDatasets.length > 0 ? selectedDatasets[0].id : null,
);
setMobileShowList(true);
}
}, [open, selectedDatasets]);
@@ -99,6 +110,11 @@ export default function PreviewDialog({
[datasetId]: entityId,
}));
// On mobile, go back to list after selection
if (isMobile) {
setMobileShowList(true);
}
// Passa al prossimo dataset non selezionato
const currentIndex = selectedDatasets.findIndex(
(ds) => ds.id === datasetId,
@@ -108,6 +124,9 @@ export default function PreviewDialog({
);
if (nextUnselected) {
setActiveDataset(nextUnselected.id);
if (isMobile) {
setTimeout(() => setMobileShowList(false), 300);
}
}
};
@@ -142,6 +161,13 @@ export default function PreviewDialog({
onGeneratePreview(dataSources);
};
const handleSelectDataset = (datasetId: string) => {
setActiveDataset(datasetId);
if (isMobile) {
setMobileShowList(false);
}
};
const getDatasetIcon = (icon: string) => {
switch (icon) {
case "event":
@@ -205,31 +231,318 @@ export default function PreviewDialog({
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 (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{ sx: { height: "80vh", maxHeight: 700 } }}
fullScreen={isMobile}
PaperProps={{
sx: {
height: isMobile ? "100%" : "80vh",
maxHeight: isMobile ? "100%" : 700,
},
}}
>
<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>
{/* Mobile AppBar */}
{isMobile ? (
<AppBar position="static" color="default" elevation={0}>
<Toolbar>
<IconButton edge="start" onClick={onClose}>
<CloseIcon />
</IconButton>
<Typography variant="h6" sx={{ flex: 1, ml: 1 }}>
Anteprima Report
</Typography>
<Chip
label={`${selectedCount}/${selectedDatasets.length}`}
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 />
<DialogContent sx={{ p: 0, display: "flex" }}>
<DialogContent sx={{ p: 0, display: "flex", overflow: "hidden" }}>
{hasError && (
<Alert severity="error" sx={{ m: 2 }}>
Errore nel caricamento dei dati disponibili
@@ -243,7 +556,38 @@ export default function PreviewDialog({
almeno un dataset per poter generare l'anteprima.
</Alert>
</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) */}
<Paper
@@ -256,261 +600,21 @@ export default function PreviewDialog({
flexShrink: 0,
}}
>
<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={() => 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>
{renderDatasetList()}
</Paper>
{/* Area principale con lista entità */}
<Box
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>
{renderEntityList()}
</>
)}
</DialogContent>
<Divider />
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose}>Annulla</Button>
<DialogActions sx={{ px: isMobile ? 2 : 3, py: 2 }}>
<Button onClick={onClose} fullWidth={isMobile}>
Annulla
</Button>
<Button
variant="contained"
onClick={handleGenerate}
@@ -518,8 +622,13 @@ export default function PreviewDialog({
!allSelected || isGenerating || selectedDatasets.length === 0
}
startIcon={isGenerating ? <CircularProgress size={16} /> : null}
fullWidth={isMobile}
>
{isGenerating ? "Generazione..." : "Genera Anteprima PDF"}
{isGenerating
? "Generazione..."
: isMobile
? "Genera PDF"
: "Genera Anteprima PDF"}
</Button>
</DialogActions>
</Dialog>

View File

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

View File

@@ -23,7 +23,21 @@ import {
MenuItem,
Alert,
Snackbar,
useMediaQuery,
useTheme,
IconButton,
BottomNavigation,
BottomNavigationAction,
Paper,
SwipeableDrawer,
Typography,
} from "@mui/material";
import {
Storage as DataIcon,
Settings as SettingsIcon,
Description as PageIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import EditorCanvas, {
type ContextMenuEvent,
} from "../components/reportEditor/EditorCanvas";
@@ -67,11 +81,20 @@ import {
defaultPage,
} from "../types/report";
// Panel types for mobile navigation
type MobilePanel = "pages" | "data" | "properties" | null;
export default function ReportEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
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)
const [templateHistory, historyActions] = useHistory<AprtTemplate>(
@@ -102,7 +125,7 @@ export default function ReportEditorPage() {
const [selectedElementId, setSelectedElementId] = useState<string | null>(
null,
);
const [zoom, setZoom] = useState(1);
const [zoom, setZoom] = useState(isMobile ? 0.5 : 1);
const [showGrid, setShowGrid] = useState(true);
const [snapOptions, setSnapOptions] = useState<SnapOptions>({
grid: false,
@@ -113,6 +136,9 @@ export default function ReportEditorPage() {
});
const [gridSize] = useState(5); // 5mm grid
// Mobile panel state
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// UI state
const [saveDialog, setSaveDialog] = useState(false);
const [previewDialog, setPreviewDialog] = useState(false);
@@ -139,6 +165,24 @@ export default function ReportEditorPage() {
});
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
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
queryKey: ["report-template", id],
@@ -301,6 +345,8 @@ export default function ReportEditorPage() {
severity: "success",
});
setSaveDialog(false);
// Mark current state as saved
setLastSavedUndoCount(templateHistory.undoCount);
if (isNew) {
navigate(`/report-editor/${result.id}`, { replace: true });
}
@@ -531,8 +577,13 @@ export default function ReportEditorPage() {
elements: [...prev.elements, newElement],
}));
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)
@@ -1176,6 +1227,31 @@ export default function ReportEditorPage() {
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) {
return (
<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 (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 64px)",
mx: -3,
mt: -3,
height: { xs: "calc(100vh - 56px)", sm: "calc(100vh - 64px)" },
mx: { xs: -1.5, sm: -2, md: -3 },
mt: { xs: -1.5, sm: -2, md: -3 },
overflow: "hidden",
}}
>
{/* Dataset Selector */}
<DatasetSelector
availableDatasets={availableDatasets}
selectedDatasets={selectedDatasets}
onAddDataset={handleAddDataset}
onRemoveDataset={handleRemoveDataset}
onOpenDatasetManager={() => setDatasetManagerDialog(true)}
/>
{/* Dataset Selector - hide on mobile, show in compact mode on tablet */}
{!isMobile && (
<DatasetSelector
availableDatasets={availableDatasets}
selectedDatasets={selectedDatasets}
onAddDataset={handleAddDataset}
onRemoveDataset={handleRemoveDataset}
onOpenDatasetManager={() => setDatasetManagerDialog(true)}
/>
)}
{/* Toolbar */}
<EditorToolbar
@@ -1244,30 +1403,40 @@ export default function ReportEditorPage() {
setSelectedElementId(null);
}
}}
// New props for enhanced toolbar
selectedElement={selectedElement}
onUpdateSelectedElement={handleUpdateSelectedElement}
hasUnsavedChanges={hasUnsavedChanges}
// Auto-save props
autoSaveEnabled={autoSaveEnabled}
onAutoSaveToggle={setAutoSaveEnabled}
/>
{/* Main Editor Area */}
<Box sx={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Page Navigator */}
<PageNavigator
pages={template.pages}
elements={template.elements}
currentPageId={currentPageId}
onSelectPage={handleSelectPage}
onAddPage={handleAddPage}
onDuplicatePage={handleDuplicatePage}
onDeletePage={handleDeletePage}
onRenamePage={handleRenamePage}
onMovePage={handleMovePage}
/>
{/* Desktop: Show all panels */}
{isDesktop && (
<>
{renderPageNavigator()}
{renderDataBindingPanel()}
</>
)}
{/* Data Binding Panel */}
<DataBindingPanel
schemas={schemas}
selectedDatasets={selectedDatasets}
onInsertBinding={handleInsertBinding}
onRemoveDataset={handleRemoveDataset}
/>
{/* Tablet: Show page navigator and data panel in collapsible sidebars */}
{isTablet && (
<Box
sx={{
width: 180,
borderRight: 1,
borderColor: "divider",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{renderPageNavigator()}
</Box>
)}
{/* Canvas - show only elements for current page */}
<EditorCanvas
@@ -1286,7 +1455,13 @@ export default function ReportEditorPage() {
},
}}
selectedElementId={selectedElementId}
onSelectElement={setSelectedElementId}
onSelectElement={(id) => {
setSelectedElementId(id);
// On mobile, auto-open properties when selecting element
if (isMobile && id) {
setMobilePanel("properties");
}
}}
onUpdateElement={handleUpdateElementWithoutHistory}
onUpdateElementComplete={historyActions.commit}
zoom={zoom}
@@ -1296,43 +1471,103 @@ export default function ReportEditorPage() {
onContextMenu={handleContextMenu}
/>
{/* Properties Panel */}
<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,
),
}));
}}
/>
{/* Desktop/Tablet: Properties Panel */}
{!isMobile && renderPropertiesPanel()}
</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 */}
<Dialog
open={saveDialog}
onClose={() => setSaveDialog(false)}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
>
<DialogTitle>Salva Template</DialogTitle>
<DialogContent>
@@ -1379,14 +1614,17 @@ export default function ReportEditorPage() {
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setSaveDialog(false)}>Annulla</Button>
<DialogActions sx={{ p: { xs: 2, sm: 1 } }}>
<Button onClick={() => setSaveDialog(false)} fullWidth={isMobile}>
Annulla
</Button>
<Button
variant="contained"
onClick={() =>
saveMutation.mutate({ template, info: templateInfo })
}
disabled={!templateInfo.nome || saveMutation.isPending}
fullWidth={isMobile}
>
{saveMutation.isPending ? "Salvataggio..." : "Salva"}
</Button>

View File

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

View File

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

Binary file not shown.

Binary file not shown.