-
This commit is contained in:
113
CLAUDE.md
113
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 `<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`):
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user