diff --git a/CLAUDE.md b/CLAUDE.md
index a1d43e4..e417976 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 `` 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`):
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 0e3b353..9638816 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -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: , 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 = (
-
-
-
- Apollinare
-
+
+
+ {!isTablet && (
+
+ Apollinare
+
+ )}
+ {isMobile && (
+
+
+
+ )}
-
+
{menuItems.map((item) => (
-
+
{
navigate(item.path);
- setMobileOpen(false);
+ if (isMobile) setMobileOpen(false);
+ }}
+ sx={{
+ borderRadius: 1,
+ minHeight: 48,
+ justifyContent: isTablet ? "center" : "flex-start",
+ px: isTablet ? 1 : 2,
}}
>
- {item.icon}
-
+
+ {item.icon}
+
+ {!isTablet && }
))}
-
+ {!isTablet && (
+
+
+ © 2025 Apollinare
+
+
+ )}
+
);
return (
-
+
-
+
-
- Catering & Banqueting Management
+
+ {isMobile ? "Apollinare" : "Catering & Banqueting Management"}
+
+ {/* Mobile Drawer */}
{drawer}
+
+ {/* Desktop/Tablet Drawer */}
+
+ {/* Main Content */}
-
+ {/* Toolbar spacer */}
+
+
+ {/* Content */}
+
+
+
);
diff --git a/frontend/src/components/reportEditor/DataBindingPanel.tsx b/frontend/src/components/reportEditor/DataBindingPanel.tsx
index 385a770..cc9413b 100644
--- a/frontend/src/components/reportEditor/DataBindingPanel.tsx
+++ b/frontend/src/components/reportEditor/DataBindingPanel.tsx
@@ -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(["special"]);
const [search, setSearch] = useState("");
const [copiedField, setCopiedField] = useState(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 (
@@ -231,24 +241,28 @@ export default function DataBindingPanel({
return (
{/* Header con ricerca */}
-
- Campi Disponibili
-
+ {!isMobile && (
+
+ Campi Disponibili
+
+ )}
- 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."}
diff --git a/frontend/src/components/reportEditor/DatasetSelector.tsx b/frontend/src/components/reportEditor/DatasetSelector.tsx
index fc57938..52a591d 100644
--- a/frontend/src/components/reportEditor/DatasetSelector.tsx
+++ b/frontend/src/components/reportEditor/DatasetSelector.tsx
@@ -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);
const [hoveredDataset, setHoveredDataset] = useState(
null,
);
+ const [expanded, setExpanded] = useState(!isMobile);
const handleOpenMenu = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
@@ -152,18 +162,169 @@ export default function DatasetSelector({
return [...baseCategories, ...additionalCategories];
}, [groupedDatasets]);
+ // Compact mobile view
+ if (isMobile) {
+ return (
+
+ {/* Header - always visible */}
+ setExpanded(!expanded)}
+ >
+
+
+
+ Dataset ({selectedDatasets.length})
+
+
+
+ {expanded ? : }
+
+
+
+ {/* Expandable content */}
+
+
+ {selectedDatasets.map((dataset) => (
+ onRemoveDataset(dataset.id)}
+ sx={{ fontWeight: 500 }}
+ />
+ ))}
+
+ {selectedDatasets.length === 0 && (
+
+ Nessun dataset selezionato
+
+ )}
+
+ {unselectedDatasets.length > 0 && (
+
+
+
+ )}
+
+
+
+ {/* Menu - shared */}
+
+
+ );
+ }
+
+ // Tablet/Desktop view
return (
-
-
- setAddMenuAnchor(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
- transformOrigin={{ vertical: "top", horizontal: "left" }}
- TransitionComponent={Fade}
- slotProps={{
- paper: {
- sx: { mt: 0.5, borderRadius: 2, minWidth: 200 },
- },
- }}
- >
-
- {ELEMENT_TYPES.map(({ type, icon: Icon, label, shortcut, color }) => (
- handleAddElement(type)}
- sx={{
- py: 1,
- "&:hover": {
- bgcolor: `${color}10`,
- },
- }}
+ {/* Text formatting */}
+
+ handleTextFormat("bold")}
>
-
-
-
-
-
-
- ))}
-
-
+
+
+ handleTextFormat("italic")}
+ >
+
+
+ handleTextFormat("underline")}
+ >
+
+
+
-
+
- {/* Quick Add Buttons */}
-
- {ELEMENT_TYPES.map(({ type, icon: Icon, label, color }) => (
-
- onAddElement(type)}
+ {/* Text alignment */}
+ value && handleTextAlign(value)}
+ sx={{ "& .MuiToggleButton-root": { border: 0, borderRadius: 1 } }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Colors */}
+
+ onUpdateSelectedElement({ style: { ...currentStyle, color } })
+ }
+ label="Colore testo"
+ icon={TextColorIcon}
+ />
+
+ onUpdateSelectedElement({
+ style: { ...currentStyle, backgroundColor: color },
+ })
+ }
+ label="Sfondo"
+ icon={FillColorIcon}
+ />
+
+
+
+ {/* Data binding indicator */}
+ {selectedElement.content?.type === "binding" && (
+ }
+ label="Data Bound"
size="small"
+ color="info"
+ variant="outlined"
+ sx={{ height: 24 }}
+ />
+ )}
+
+ );
+ }
+
+ // Shape/Line toolbar
+ if (selectedElement.type === "shape" || selectedElement.type === "line") {
+ return (
+
+
+ onUpdateSelectedElement({
+ style: { ...currentStyle, backgroundColor: color },
+ })
+ }
+ label="Riempimento"
+ icon={FillColorIcon}
+ />
+
+ onUpdateSelectedElement({
+ style: { ...currentStyle, borderColor: color },
+ })
+ }
+ label="Bordo"
+ icon={StrokeColorIcon}
+ />
+
+
+
+ {/* Border width */}
+
+
+
+ Bordo:
+
+
+ onUpdateSelectedElement({
+ style: {
+ ...currentStyle,
+ borderWidth: Number(e.target.value),
+ },
+ })
+ }
+ inputProps={{ min: 0, max: 20, step: 0.5 }}
+ sx={{ width: 60, "& input": { py: 0.5, fontSize: "0.75rem" } }}
+ />
+
+
+
+ );
+ }
+
+ // Image toolbar
+ if (selectedElement.type === "image") {
+ return (
+
+ }
+ label={selectedElement.name || "Immagine"}
+ size="small"
+ variant="outlined"
+ sx={{ height: 24, maxWidth: 150 }}
+ />
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+ };
+
+ // Save status indicator
+ const renderSaveStatus = () => {
+ return (
+
+ {/* Auto-save toggle */}
+ {onAutoSaveToggle && (
+
+ onAutoSaveToggle(!autoSaveEnabled)}
sx={{
borderRadius: 1.5,
+ bgcolor: autoSaveEnabled
+ ? alpha(theme.palette.success.main, 0.1)
+ : alpha(theme.palette.grey[500], 0.1),
+ color: autoSaveEnabled ? "success.main" : "text.disabled",
"&:hover": {
- bgcolor: `${color}15`,
- color: color,
+ bgcolor: autoSaveEnabled
+ ? alpha(theme.palette.success.main, 0.2)
+ : alpha(theme.palette.grey[500], 0.2),
},
}}
>
-
+
- ))}
+ )}
+
+ {/* Save status */}
+ {isSaving ? (
+
+
+
+
+ Salvo...
+
+
+
+ ) : hasUnsavedChanges ? (
+
+
+
+
+ Non salvato
+
+
+
+ ) : (
+
+
+
+
+ {formatTimeAgo(lastSavedAt) || "Salvato"}
+
+
+
+ )}
+ );
+ };
-
+ // Mobile: Compact toolbar with essential actions
+ if (isMobile) {
+ return (
+
+ {/* Primary Row */}
+
+ {/* Add Element */}
+ setAddMenuAnchor(e.currentTarget)}
+ sx={{
+ minWidth: 36,
+ width: 36,
+ height: 36,
+ p: 0,
+ borderRadius: 2,
+ }}
+ >
+
+
- {/* Selection Actions */}
-
-
-
-
+
+ {/* Undo/Redo */}
+
+
+
+
+
+
+
+
+
+ {/* Delete */}
+
+
+
+
+
+
+ {/* Page Navigation - Compact */}
+ setPageMenuAnchor(e.currentTarget)}
+ endIcon={}
+ sx={{
+ bgcolor: "grey.100",
+ borderRadius: 1.5,
+ textTransform: "none",
+ fontFamily: "monospace",
+ fontWeight: 600,
+ px: 1,
+ }}
+ >
+ {currentPageIndex + 1}/{totalPages}
+
+
+
+
+ {/* Save/Preview */}
+
+
+
+ {!autoSaveEnabled && (
+
-
-
-
-
-
-
-
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Expand/Collapse */}
+ setShowSecondRow(!showSecondRow)}
+ >
+ {showSecondRow ? : }
+
+
+
+ {/* Secondary Row - Collapsible */}
+
+
+ {/* Zoom */}
+ onZoomChange(Math.max(0.25, zoom - 0.25))}
+ >
+
+
+ setZoomMenuAnchor(e.currentTarget)}
sx={{
- borderRadius: 1.5,
- "&:hover": { bgcolor: "error.light", color: "white" },
+ minWidth: 55,
+ fontFamily: "monospace",
+ fontWeight: 600,
+ bgcolor: "grey.100",
+ borderRadius: 1,
}}
>
-
-
-
-
-
-
-
+ onZoomChange(Math.min(3, zoom + 0.25))}
+ >
+
+
+
+
+
+ {/* Grid & Snap */}
+
+ {showGrid ? (
+
+ ) : (
+
+ )}
+
+ setSnapMenuAnchor(e.currentTarget)}
+ active={activeSnapCount > 0}
+ badge={activeSnapCount || undefined}
+ >
+
+
+
+
+
+ {/* Copy/Lock */}
+
+
+
+
{isLocked ? (
) : (
)}
-
-
-
-
+
+
+
-
-
- {/* Undo/Redo */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* View Controls */}
-
- setAddMenuAnchor(null)}
+ PaperProps={{ sx: { borderRadius: 2, minWidth: 200 } }}
>
- (
+
+ ),
+ )}
+
+
+ {/* Page Menu */}
+
+
+ {/* Snap Menu (shared) */}
+ setSnapMenuAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
+ PaperProps={{ sx: { borderRadius: 2 } }}
+ >
+
+
+
+ Opzioni Snap
+
+
+
+
+ {SNAP_OPTIONS_CONFIG.map(({ key, icon: Icon, label }) => (
+ handleToggleSnapOption(key)}
+ dense
+ sx={{
+ borderRadius: 1,
+ mb: 0.5,
+ bgcolor: snapOptions[key]
+ ? alpha(theme.palette.primary.main, 0.08)
+ : "transparent",
+ }}
+ >
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Zoom Popover */}
+ setZoomMenuAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
+ PaperProps={{ sx: { borderRadius: 2 } }}
+ >
+
+ onZoomChange(value as number)}
+ size="small"
+ valueLabelDisplay="auto"
+ valueLabelFormat={(v) => `${Math.round(v * 100)}%`}
+ />
+
+
+ {ZOOM_PRESETS.map(({ value, label }) => (
+ {
+ onZoomChange(value);
+ setZoomMenuAnchor(null);
+ }}
+ sx={{ fontSize: "0.7rem", cursor: "pointer" }}
+ />
+ ))}
+
+
+
+
+ );
+ }
+
+ // Tablet: Medium toolbar
+ if (isTablet) {
+ return (
+
+
+ {/* Add Element */}
+ }
+ onClick={(e) => setAddMenuAnchor(e.currentTarget)}
+ sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
+ >
+ Aggiungi
+
+
+
+
+ {/* Quick Add Buttons */}
+ {ELEMENT_TYPES.slice(0, 3).map(
+ ({ type, icon: Icon, label, color }) => (
+ onAddElement(type)}
+ color={color}
+ >
+
+
+ ),
+ )}
+
+
+
+ {/* Selection Actions */}
+
+
+
+
+
+
+
+ {isLocked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Undo/Redo */}
+
+
+
+
+
+
+
+
+
+ {/* View Controls */}
+
{showGrid ? (
) : (
)}
-
-
+
+ setSnapMenuAnchor(e.currentTarget)}
+ active={activeSnapCount > 0}
+ badge={activeSnapCount || undefined}
+ >
+
+
-
+
+
+ {/* Zoom */}
+ onZoomChange(Math.max(0.25, zoom - 0.25))}
+ >
+
+
setSnapMenuAnchor(e.currentTarget)}
- startIcon={}
+ onClick={(e) => setZoomMenuAnchor(e.currentTarget)}
sx={{
+ minWidth: 55,
+ fontFamily: "monospace",
+ fontWeight: 600,
+ bgcolor: "grey.100",
borderRadius: 1.5,
- textTransform: "none",
- fontWeight: 500,
- bgcolor: activeSnapCount > 0 ? "primary.light" : "grey.100",
- color:
- activeSnapCount > 0 ? "primary.contrastText" : "text.primary",
- "&:hover": {
- bgcolor: activeSnapCount > 0 ? "primary.main" : "grey.200",
- },
- minWidth: 80,
}}
>
- Snap {activeSnapCount > 0 && `(${activeSnapCount})`}
+ {Math.round(zoom * 100)}%
-
+ onZoomChange(Math.min(3, zoom + 0.25))}
+ >
+
+
+
+
+
+ {/* Page Navigation */}
+
+
+
+
+ setPageMenuAnchor(e.currentTarget)}
+ sx={{
+ fontFamily: "monospace",
+ fontWeight: 600,
+ bgcolor: "grey.100",
+ borderRadius: 1.5,
+ px: 1.5,
+ }}
+ >
+
+ {currentPageIndex + 1}/{totalPages}
+
+
+ = totalPages - 1}
+ >
+
+
+
+
+
+ {/* Actions */}
+ }
+ onClick={onPreview}
+ sx={{ borderRadius: 2, textTransform: "none" }}
+ >
+ Anteprima
+
+ {!autoSaveEnabled && (
+
+ ) : (
+
+ )
+ }
+ onClick={onSave}
+ disabled={isSaving}
+ sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
+ >
+ {isSaving ? "Salvo..." : "Salva"}
+
+ )}
+
+
+ {/* Contextual toolbar for text/shape */}
+ {selectedElement && onUpdateSelectedElement && (
+
+ {renderContextualToolbar()}
+
+ )}
+
+ {/* Shared Popovers */}
+
setSnapMenuAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
- transformOrigin={{ vertical: "top", horizontal: "left" }}
- slotProps={{
- paper: {
- sx: { mt: 0.5, borderRadius: 2, minWidth: 280, p: 1 },
- },
- }}
+ PaperProps={{ sx: { borderRadius: 2 } }}
>
-
+
}
- label={
-
- Tutti
-
- }
+ label={Tutti}
labelPlacement="start"
sx={{ mr: 0 }}
/>
-
- {SNAP_OPTIONS_CONFIG.map(
- ({ key, icon: Icon, label, description }) => (
- handleToggleSnapOption(key)}
- sx={{
- py: 0.75,
- borderRadius: 1,
- mb: 0.5,
- bgcolor: snapOptions[key] ? "primary.50" : "transparent",
- "&:hover": {
- bgcolor: snapOptions[key]
- ? "primary.100"
- : "action.hover",
- },
+ {SNAP_OPTIONS_CONFIG.map(
+ ({ key, icon: Icon, label, description }) => (
+ handleToggleSnapOption(key)}
+ sx={{
+ py: 0.5,
+ borderRadius: 1,
+ bgcolor: snapOptions[key]
+ ? alpha(theme.palette.primary.main, 0.08)
+ : "transparent",
+ }}
+ >
+
+
+
+
-
-
-
-
- e.stopPropagation()}
- onChange={() => handleToggleSnapOption(key)}
- />
-
- ),
- )}
-
+ secondaryTypographyProps={{ variant: "caption" }}
+ />
+
+
+ ),
+ )}
-
-
-
-
- {/* Zoom Controls */}
-
-
- onZoomChange(Math.max(0.25, zoom - 0.25))}
- size="small"
- sx={{ borderRadius: 1.5 }}
- >
-
-
-
-
-
- setZoomMenuAnchor(e.currentTarget)}
- sx={{
- minWidth: 60,
- borderRadius: 1.5,
- textTransform: "none",
- fontWeight: 600,
- fontFamily: "monospace",
- bgcolor: "grey.100",
- color: "text.primary",
- "&:hover": { bgcolor: "grey.200" },
- }}
- >
- {Math.round(zoom * 100)}%
-
-
setZoomMenuAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- transformOrigin={{ vertical: "top", horizontal: "center" }}
- slotProps={{
- paper: {
- sx: { mt: 0.5, borderRadius: 2, p: 1 },
- },
- }}
+ PaperProps={{ sx: { borderRadius: 2 } }}
>
-
-
- Livello zoom
-
+
onZoomChange(value as number)}
size="small"
- sx={{ mt: 1, mx: 1, width: "calc(100% - 16px)" }}
+ valueLabelDisplay="auto"
+ valueLabelFormat={(v) => `${Math.round(v * 100)}%`}
/>
-
+
{ZOOM_PRESETS.map(({ value, label }) => (
))}
-
-
-
- {
- onZoomChange(0.75);
- setZoomMenuAnchor(null);
- }}
- >
-
-
-
-
-
-
-
-
-
-
- onZoomChange(Math.min(2, zoom + 0.25))}
- size="small"
- sx={{ borderRadius: 1.5 }}
- >
-
-
-
-
-
-
-
- {/* Page Navigation */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {currentPageIndex + 1} / {totalPages}
+
+
+
+
+
+
+ );
+ }
-
-
- = totalPages - 1}
- sx={{ borderRadius: 1.5 }}
- >
-
-
-
-
-
-
-
-
- {/* Keyboard Shortcuts Help */}
-
- setShortcutsAnchor(e.currentTarget)}
- sx={{ borderRadius: 1.5, mr: 1 }}
- >
-
-
-
-
- setShortcutsAnchor(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
- transformOrigin={{ vertical: "top", horizontal: "right" }}
- slotProps={{
- paper: {
- sx: { mt: 0.5, borderRadius: 2, p: 2, minWidth: 280 },
- },
+ // Desktop: Full professional toolbar
+ return (
+
+ {/* Main Toolbar Row */}
+
-
- Scorciatoie Tastiera
-
-
-
- {[
- ["Ctrl + Z", "Annulla"],
- ["Ctrl + Y", "Ripeti"],
- ["Ctrl + S", "Salva"],
- ["Ctrl + D", "Duplica"],
- ["Canc / Backspace", "Elimina"],
- ["Frecce", "Sposta (1px)"],
- ["Shift + Frecce", "Sposta (10px)"],
- ["G", "Mostra/nascondi griglia"],
- ["+ / -", "Zoom"],
- ].map(([key, action]) => (
-
- |
-
- |
-
-
- {action}
-
- |
-
- ))}
-
-
-
+ {/* Add Element - Primary Action */}
+ }
+ onClick={(e) => setAddMenuAnchor(e.currentTarget)}
+ sx={{
+ borderRadius: 2,
+ textTransform: "none",
+ fontWeight: 600,
+ px: 2,
+ boxShadow: 2,
+ background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
+ "&:hover": {
+ boxShadow: 4,
+ background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.primary.main} 100%)`,
+ },
+ }}
+ >
+ Aggiungi
+
- {/* Actions */}
-
+ {/* Add Menu with rich descriptions */}
+ setAddMenuAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
+ PaperProps={{
+ sx: { mt: 1, borderRadius: 3, minWidth: 280, overflow: "hidden" },
+ }}
+ >
+
+
+ Inserisci elemento
+
+
+ {ELEMENT_TYPES.map(
+ ({ type, icon: Icon, label, shortcut, color, description }) => (
+ handleAddElement(type)}
+ sx={{
+ py: 1.5,
+ px: 1.5,
+ borderRadius: 2,
+ mb: 0.5,
+ transition: "all 0.15s ease",
+ "&:hover": {
+ bgcolor: alpha(color, 0.08),
+ transform: "translateX(4px)",
+ },
+ }}
+ >
+
+
+
+
+
+
+ ),
+ )}
+
+
+
+
+
+
+ {/* Quick Add Toolbar */}
+
+ {ELEMENT_TYPES.map(({ type, icon: Icon, label, color }) => (
+ e.type === type)?.shortcut})`}
+ onClick={() => onAddElement(type)}
+ color={color}
+ >
+
+
+ ))}
+
+
+
+
+ {/* Selection Actions */}
+
+
+
+
+
+
+
+
+ {isLocked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* History */}
+
+
+
+
+
+
+
+ {onOpenHistory && (
+
+
+
+ )}
+
+
+
+
+ {/* View Controls */}
+
+
+ {showGrid ? (
+
+ ) : (
+
+ )}
+
+
+ setSnapMenuAnchor(e.currentTarget)}
+ startIcon={}
+ endIcon={
+ activeSnapCount > 0 ? (
+
+ ) : undefined
+ }
+ sx={{
+ borderRadius: 1.5,
+ textTransform: "none",
+ fontWeight: 500,
+ bgcolor:
+ activeSnapCount > 0
+ ? alpha(theme.palette.primary.main, 0.1)
+ : "grey.100",
+ color: activeSnapCount > 0 ? "primary.main" : "text.primary",
+ px: 1.5,
+ "&:hover": {
+ bgcolor:
+ activeSnapCount > 0
+ ? alpha(theme.palette.primary.main, 0.2)
+ : "grey.200",
+ },
+ }}
+ >
+ Snap
+
+
+
+ {/* Snap Popover */}
+ setSnapMenuAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
+ PaperProps={{ sx: { mt: 1, borderRadius: 2, minWidth: 300 } }}
+ >
+
+
+
+ Allineamento automatico
+
+
+ }
+ label={
+
+ Tutti
+
+ }
+ labelPlacement="start"
+ sx={{ mr: 0 }}
+ />
+
+
+
+ {SNAP_OPTIONS_CONFIG.map(
+ ({ key, icon: Icon, label, description }) => (
+ handleToggleSnapOption(key)}
+ sx={{
+ py: 1,
+ px: 1.5,
+ borderRadius: 2,
+ bgcolor: snapOptions[key]
+ ? alpha(theme.palette.primary.main, 0.08)
+ : "transparent",
+ transition: "all 0.15s ease",
+ "&:hover": {
+ bgcolor: snapOptions[key]
+ ? alpha(theme.palette.primary.main, 0.15)
+ : "action.hover",
+ },
+ }}
+ >
+
+
+
+
+ e.stopPropagation()}
+ />
+
+ ),
+ )}
+
+
+
+
+
+
+ {/* Zoom Controls */}
+
+ onZoomChange(Math.max(0.25, zoom - 0.25))}
+ >
+
+
+
+ setZoomMenuAnchor(e.currentTarget)}
+ sx={{
+ minWidth: 70,
+ borderRadius: 1.5,
+ textTransform: "none",
+ fontWeight: 600,
+ fontFamily: "monospace",
+ bgcolor: "grey.100",
+ color: "text.primary",
+ "&:hover": { bgcolor: "grey.200" },
+ }}
+ >
+ {Math.round(zoom * 100)}%
+
+
+ onZoomChange(Math.min(3, zoom + 0.25))}
+ >
+
+
+
+ onZoomChange(0.75)}
+ >
+
+
+
+
+ {/* Zoom Popover */}
+ setZoomMenuAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
+ PaperProps={{ sx: { mt: 1, borderRadius: 2, p: 2, width: 220 } }}
+ >
+
+ Livello zoom: {Math.round(zoom * 100)}%
+
+ onZoomChange(value as number)}
+ valueLabelDisplay="auto"
+ valueLabelFormat={(v) => `${Math.round(v * 100)}%`}
+ sx={{ mt: 1, mb: 2 }}
+ />
+
+ Preset
+
+
+ {ZOOM_PRESETS.map(({ value, label }) => (
+ {
+ onZoomChange(value);
+ setZoomMenuAnchor(null);
+ }}
+ sx={{ cursor: "pointer" }}
+ />
+ ))}
+
+
+
+
+
+ {/* Page Navigation */}
+
+
+
+
+
+ setPageMenuAnchor(e.currentTarget)}
+ endIcon={}
+ sx={{
+ px: 1.5,
+ borderRadius: 1.5,
+ textTransform: "none",
+ bgcolor: "grey.100",
+ "&:hover": { bgcolor: "grey.200" },
+ }}
+ >
+
+
+ {currentPageIndex + 1} / {totalPages}
+
+
+
+ = totalPages - 1}
+ >
+
+
+
+
+ {/* Page Menu */}
+
+
+
+
+ {/* Save Status */}
+
+ {renderSaveStatus()}
+
+
+ {/* Command Palette / Search */}
+ {onOpenCommandPalette && (
+
+ }
+ sx={{
+ borderRadius: 2,
+ textTransform: "none",
+ color: "text.secondary",
+ bgcolor: "grey.100",
+ px: 2,
+ "&:hover": { bgcolor: "grey.200" },
+ }}
+ >
+ Cerca...
+
+
+ )}
+
+ {/* Keyboard Shortcuts */}
+ setShortcutsAnchor(e.currentTarget)}
+ >
+
+
+
+ {/* Shortcuts Popover */}
+ setShortcutsAnchor(null)}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ transformOrigin={{ vertical: "top", horizontal: "right" }}
+ PaperProps={{ sx: { mt: 1, borderRadius: 2, p: 2, minWidth: 300 } }}
+ >
+
+ Scorciatoie Tastiera
+
+
+
+
+ {[
+ ["Ctrl + Z", "Annulla"],
+ ["Ctrl + Y", "Ripeti"],
+ ["Ctrl + S", "Salva"],
+ ["Ctrl + D", "Duplica"],
+ ["Ctrl + K", "Cerca comando"],
+ ["Canc / Backspace", "Elimina"],
+ ["Frecce", "Sposta (1px)"],
+ ["Shift + Frecce", "Sposta (10px)"],
+ ["G", "Mostra/nascondi griglia"],
+ ["+ / -", "Zoom in/out"],
+ ["PgUp / PgDn", "Cambia pagina"],
+ ].map(([key, action]) => (
+
+ |
+
+ |
+
+
+ {action}
+
+ |
+
+ ))}
+
+
+
+
+
+
+ {/* Primary Actions */}
}
onClick={onPreview}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: 500,
+ px: 2,
}}
>
Anteprima
- }
- onClick={onSave}
- disabled={isSaving}
+
+ {!autoSaveEnabled && (
+
+ ) : hasUnsavedChanges ? (
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={onSave}
+ disabled={isSaving}
+ sx={{
+ borderRadius: 2,
+ textTransform: "none",
+ fontWeight: 600,
+ px: 3,
+ boxShadow: 2,
+ "&:hover": { boxShadow: 4 },
+ }}
+ >
+ {isSaving ? "Salvataggio..." : "Salva"}
+
+ )}
+
+
+ {/* Contextual Toolbar Row - appears when element is selected */}
+ {selectedElement && onUpdateSelectedElement && (
+
- {isSaving ? "Salvo..." : "Salva"}
-
-
+ {/* Element type indicator */}
+ e.type === selectedElement.type)?.icon
+ ? (() => {
+ const Icon = ELEMENT_TYPES.find(
+ (e) => e.type === selectedElement.type,
+ )!.icon;
+ return ;
+ })()
+ : undefined
+ }
+ label={
+ ELEMENT_TYPES.find((e) => e.type === selectedElement.type)
+ ?.label || selectedElement.type
+ }
+ size="small"
+ variant="outlined"
+ sx={{
+ borderColor: ELEMENT_TYPES.find(
+ (e) => e.type === selectedElement.type,
+ )?.color,
+ color: ELEMENT_TYPES.find((e) => e.type === selectedElement.type)
+ ?.color,
+ }}
+ />
+
+
+
+ {/* Contextual formatting options */}
+ {renderContextualToolbar()}
+
+
+
+ {/* Element name */}
+ {selectedElement.name && (
+
+ {selectedElement.name}
+
+ )}
+
+ {/* Position info */}
+
+ {Math.round(selectedElement.position.x)}×
+ {Math.round(selectedElement.position.y)} |{" "}
+ {Math.round(selectedElement.position.width)}×
+ {Math.round(selectedElement.position.height)}mm
+
+
+ )}
);
}
diff --git a/frontend/src/components/reportEditor/ImageUploadDialog.tsx b/frontend/src/components/reportEditor/ImageUploadDialog.tsx
index 0fbcace..18326a6 100644
--- a/frontend/src/components/reportEditor/ImageUploadDialog.tsx
+++ b/frontend/src/components/reportEditor/ImageUploadDialog.tsx
@@ -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 (
-
);
diff --git a/frontend/src/components/reportEditor/PageNavigator.tsx b/frontend/src/components/reportEditor/PageNavigator.tsx
index 8fea4c9..d92ec14 100644
--- a/frontend/src/components/reportEditor/PageNavigator.tsx
+++ b/frontend/src/components/reportEditor/PageNavigator.tsx
@@ -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 (
{/* 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({
>
{index + 1}
@@ -286,7 +297,7 @@ export default function PageNavigator({
}
onClick={onAddPage}
sx={{ textTransform: "none" }}
@@ -358,6 +369,7 @@ export default function PageNavigator({
}
maxWidth="xs"
fullWidth
+ fullScreen={isMobile}
>
Rinomina Pagina
@@ -380,11 +392,12 @@ export default function PageNavigator({
sx={{ mt: 1 }}
/>
-
+
setRenameDialog({ open: false, pageId: "", currentName: "" })
}
+ fullWidth={isMobile}
>
Annulla
@@ -392,6 +405,7 @@ export default function PageNavigator({
variant="contained"
onClick={handleRenameConfirm}
disabled={!renameDialog.currentName.trim()}
+ fullWidth={isMobile}
>
Rinomina
@@ -405,6 +419,7 @@ export default function PageNavigator({
setDeleteConfirm({ open: false, pageId: "", pageName: "" })
}
maxWidth="xs"
+ fullScreen={isMobile}
>
Elimina Pagina
@@ -415,11 +430,12 @@ export default function PageNavigator({
Tutti gli elementi sulla pagina verranno eliminati.
-
+
setDeleteConfirm({ open: false, pageId: "", pageName: "" })
}
+ fullWidth={isMobile}
>
Annulla
@@ -427,6 +443,7 @@ export default function PageNavigator({
variant="contained"
color="error"
onClick={handleDeleteConfirm}
+ fullWidth={isMobile}
>
Elimina
diff --git a/frontend/src/components/reportEditor/PreviewDialog.tsx b/frontend/src/components/reportEditor/PreviewDialog.tsx
index 5cb0163..3cbb737 100644
--- a/frontend/src/components/reportEditor/PreviewDialog.tsx
+++ b/frontend/src/components/reportEditor/PreviewDialog.tsx
@@ -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>(
{},
);
const [searchTerms, setSearchTerms] = useState>({});
const [activeDataset, setActiveDataset] = useState(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 = () => (
+
+ {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 (
+ handleSelectDataset(dataset.id)}
+ sx={{
+ borderBottom: 1,
+ borderColor: "divider",
+ bgcolor: isSelected
+ ? (theme) => alpha(theme.palette.success.main, 0.08)
+ : "inherit",
+ py: isMobile ? 1.5 : 1,
+ }}
+ >
+
+ {isSelected ? (
+
+ ) : (
+ getDatasetIcon(dataset.icon)
+ )}
+
+
+ {isSelected && (
+
+ {
+ e.stopPropagation();
+ handleClearSelection(dataset.id);
+ }}
+ >
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+
+ // Render entity list for active dataset
+ const renderEntityList = () => {
+ if (!activeDatasetObj) return null;
+
+ return (
+
+ {/* Header dataset attivo */}
+
+ {isMobile && (
+
+ setMobileShowList(true)}>
+
+
+ Seleziona
+
+ )}
+
+ {getDatasetIcon(activeDatasetObj.icon)}
+
+ {activeDatasetObj.name}
+
+ {!isMobile && (
+
+ )}
+
+ {!isMobile && (
+
+ {activeDatasetObj.description}
+
+ )}
+
+ {/* Ricerca */}
+ handleSearchChange(activeDataset!, e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: searchTerms[activeDataset || ""] && (
+
+ handleSearchChange(activeDataset!, "")}
+ >
+
+
+
+ ),
+ }}
+ />
+
+
+ {/* Lista entità */}
+
+ {activeQuery?.isLoading ? (
+
+
+
+ ) : filteredEntities.length === 0 ? (
+
+
+ {searchTerms[activeDataset || ""]
+ ? "Nessun risultato trovato"
+ : "Nessuna entità disponibile"}
+
+
+ ) : (
+
+ {filteredEntities.map((entity) => {
+ const isEntitySelected =
+ selections[activeDataset!] === entity.id;
+
+ return (
+
+ 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,
+ }}
+ >
+
+ {isEntitySelected ? (
+
+ ) : (
+
+ )}
+
+
+ {entity.description}
+ {entity.secondaryInfo && !isMobile && (
+
+ {entity.secondaryInfo}
+
+ )}
+
+ }
+ primaryTypographyProps={{
+ variant: "body2",
+ fontWeight: isEntitySelected ? 600 : 400,
+ }}
+ secondaryTypographyProps={{
+ variant: "caption",
+ }}
+ />
+ {entity.status && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Footer con conteggio */}
+
+
+ {filteredEntities.length} risultati
+
+
+
+ );
+ };
+
return (
-
-
- Anteprima Report
-
-
-
- Seleziona un'entità per ogni dataset da utilizzare nell'anteprima
-
-
+ {/* Mobile AppBar */}
+ {isMobile ? (
+
+
+
+
+
+
+ Anteprima Report
+
+
+
+
+ ) : (
+
+
+ Anteprima Report
+
+
+
+ Seleziona un'entità per ogni dataset da utilizzare nell'anteprima
+
+
+ )}
-
+
{hasError && (
Errore nel caricamento dei dati disponibili
@@ -243,7 +556,38 @@ export default function PreviewDialog({
almeno un dataset per poter generare l'anteprima.
+ ) : isMobile ? (
+ // Mobile: Switch between dataset list and entity list
+
+ {mobileShowList ? (
+
+
+
+ Seleziona un'entità per ogni dataset
+
+
+ {renderDatasetList()}
+
+ ) : (
+ renderEntityList()
+ )}
+
) : (
+ // Desktop: Side by side
<>
{/* Lista dataset (sidebar) */}
-
- {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 (
- setActiveDataset(dataset.id)}
- sx={{
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: isSelected
- ? (theme) => alpha(theme.palette.success.main, 0.08)
- : "inherit",
- }}
- >
-
- {isSelected ? (
-
- ) : (
- getDatasetIcon(dataset.icon)
- )}
-
-
- {isSelected && (
-
- {
- e.stopPropagation();
- handleClearSelection(dataset.id);
- }}
- >
-
-
-
- )}
-
- );
- })}
-
+ {renderDatasetList()}
{/* Area principale con lista entità */}
-
- {activeDatasetObj && (
- <>
- {/* Header dataset attivo */}
-
-
- {getDatasetIcon(activeDatasetObj.icon)}
-
- {activeDatasetObj.name}
-
-
-
-
- {activeDatasetObj.description}
-
-
- {/* Ricerca */}
-
- handleSearchChange(activeDataset!, e.target.value)
- }
- InputProps={{
- startAdornment: (
-
-
-
- ),
- endAdornment: searchTerms[activeDataset || ""] && (
-
-
- handleSearchChange(activeDataset!, "")
- }
- >
-
-
-
- ),
- }}
- />
-
-
- {/* Lista entità */}
-
- {activeQuery?.isLoading ? (
-
-
-
- ) : filteredEntities.length === 0 ? (
-
-
- {searchTerms[activeDataset || ""]
- ? "Nessun risultato trovato per la ricerca"
- : "Nessuna entità disponibile"}
-
-
- ) : (
-
- {filteredEntities.map((entity) => {
- const isEntitySelected =
- selections[activeDataset!] === entity.id;
-
- return (
-
- handleSelectionChange(activeDataset!, entity.id)
- }
- selected={isEntitySelected}
- sx={{
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: isEntitySelected
- ? (theme) =>
- alpha(theme.palette.primary.main, 0.12)
- : "inherit",
- }}
- >
-
- {isEntitySelected ? (
-
- ) : (
-
- )}
-
-
- {entity.description}
- {entity.secondaryInfo && (
-
- {entity.secondaryInfo}
-
- )}
-
- }
- primaryTypographyProps={{
- variant: "body2",
- fontWeight: isEntitySelected ? 600 : 400,
- }}
- secondaryTypographyProps={{
- variant: "caption",
- }}
- />
- {entity.status && (
-
- )}
-
- );
- })}
-
- )}
-
-
- {/* Footer con conteggio */}
-
-
- {filteredEntities.length} risultati
- {searchTerms[activeDataset || ""] &&
- ` per "${searchTerms[activeDataset || ""]}"`}
-
-
- >
- )}
-
+ {renderEntityList()}
>
)}
-
- Annulla
+
+
+ Annulla
+
: null}
+ fullWidth={isMobile}
>
- {isGenerating ? "Generazione..." : "Genera Anteprima PDF"}
+ {isGenerating
+ ? "Generazione..."
+ : isMobile
+ ? "Genera PDF"
+ : "Genera Anteprima PDF"}
diff --git a/frontend/src/components/reportEditor/PropertiesPanel.tsx b/frontend/src/components/reportEditor/PropertiesPanel.tsx
index 40302e5..a9df476 100644
--- a/frontend/src/components/reportEditor/PropertiesPanel.tsx
+++ b/frontend/src/components/reportEditor/PropertiesPanel.tsx
@@ -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([
"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 (
-
- {currentPage ? `Pagina: ${currentPage.name}` : "Impostazioni Pagina"}
-
+ {!isMobile && (
+
+ {currentPage
+ ? `Pagina: ${currentPage.name}`
+ : "Impostazioni Pagina"}
+
+ )}
-
+
{/* Page name (only if currentPage is available) */}
{currentPage && onUpdateCurrentPage && (
<>
@@ -354,10 +373,12 @@ export default function PropertiesPanel({
return (
@@ -373,11 +394,13 @@ export default function PropertiesPanel({
}>
Posizione
-
+
}>
Stile
-
+
{element.type === "text" && (
<>
@@ -474,7 +499,12 @@ export default function PropertiesPanel({
-
+
updateStyle("fontSize", Number(e.target.value))
}
- sx={{ width: 100 }}
+ sx={{ width: isMobile ? "100%" : 100 }}
/>
-
+
-
+
Colore Bordo
}>
Immagine
-
+
{/* Image Preview or Upload Button */}
{element.imageSettings?.src ? (
@@ -642,7 +674,7 @@ export default function PropertiesPanel({
- Mantieni proporzioni
+ Proporzioni
- {/* Alignment */}
-
-
- Allineamento Orizzontale
-
-
- value && updateImageSettings("horizontalAlign", value)
- }
- size="small"
- fullWidth
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Allineamento Verticale
-
-
- value && updateImageSettings("verticalAlign", value)
- }
- size="small"
- fullWidth
- >
- Alto
- Centro
- Basso
-
-
-
{/* Opacity */}
@@ -878,11 +867,13 @@ export default function PropertiesPanel({
}>
Contenuto
-
+
Tipo
@@ -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({
}>
Sezione
-
+
Sezione
-
+
+ {/* Actions */}
+
-
+
@@ -293,7 +397,7 @@ export default function ReportTemplatesPage() {
size="small"
onClick={() => cloneMutation.mutate(template.id)}
>
-
+
@@ -301,7 +405,9 @@ export default function ReportTemplatesPage() {
size="small"
onClick={() => handleExport(template)}
>
-
+
@@ -311,7 +417,7 @@ export default function ReportTemplatesPage() {
color="error"
onClick={() => setDeleteDialog({ open: true, template })}
>
-
+
@@ -321,10 +427,32 @@ export default function ReportTemplatesPage() {
)}
+ {/* Mobile FAB */}
+ {isMobile && (
+
+ navigate("/report-editor")}
+ sx={{
+ position: "fixed",
+ bottom: 16,
+ right: 16,
+ zIndex: 1000,
+ }}
+ >
+
+
+
+ )}
+
{/* Delete Dialog */}
setDeleteDialog({ open: false, template: null })}
+ fullWidth
+ maxWidth="xs"
+ fullScreen={isMobile}
>
Conferma Eliminazione
@@ -336,9 +464,10 @@ export default function ReportTemplatesPage() {
Questa azione non può essere annullata.
-
+
setDeleteDialog({ open: false, template: null })}
+ fullWidth={isMobile}
>
Annulla
@@ -350,6 +479,7 @@ export default function ReportTemplatesPage() {
deleteMutation.mutate(deleteDialog.template.id)
}
disabled={deleteMutation.isPending}
+ fullWidth={isMobile}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
@@ -363,6 +493,9 @@ export default function ReportTemplatesPage() {
setImportDialog(false);
setImportFile(null);
}}
+ fullWidth
+ maxWidth="xs"
+ fullScreen={isMobile}
>
Importa Template
@@ -379,12 +512,13 @@ export default function ReportTemplatesPage() {
/>
-
+
{
setImportDialog(false);
setImportFile(null);
}}
+ fullWidth={isMobile}
>
Annulla
@@ -392,6 +526,7 @@ export default function ReportTemplatesPage() {
variant="contained"
onClick={handleImport}
disabled={!importFile || importMutation.isPending}
+ fullWidth={isMobile}
>
{importMutation.isPending ? "Importazione..." : "Importa"}
diff --git a/frontend/src/types/report.ts b/frontend/src/types/report.ts
index c95972a..4e9440e 100644
--- a/frontend/src/types/report.ts
+++ b/frontend/src/types/report.ts
@@ -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";
diff --git a/src/Apollinare.API/apollinare.db-shm b/src/Apollinare.API/apollinare.db-shm
deleted file mode 100644
index b16d5fc..0000000
Binary files a/src/Apollinare.API/apollinare.db-shm and /dev/null differ
diff --git a/src/Apollinare.API/apollinare.db-wal b/src/Apollinare.API/apollinare.db-wal
deleted file mode 100644
index b6c8af8..0000000
Binary files a/src/Apollinare.API/apollinare.db-wal and /dev/null differ