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

View File

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