diff --git a/src/components/settings/ImportExportSettingsPanel.jsx b/src/components/settings/ImportExportSettingsPanel.jsx new file mode 100644 index 0000000..0356848 --- /dev/null +++ b/src/components/settings/ImportExportSettingsPanel.jsx @@ -0,0 +1,628 @@ +import { Component, createRef } from 'react'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormLabel from '@mui/material/FormLabel'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined'; +import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import { enqueueSnackbar } from 'notistack'; + +function formatBytes(bytes) { + if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +function isZipFile(file) { + if (!file) return false; + const name = (file.name || '').toLowerCase(); + return ( + name.endsWith('.zip') || + file.type === 'application/zip' || + file.type === 'application/x-zip-compressed' + ); +} + +const EXPORT_OPTIONS = [ + { + id: 'articles', + label: 'Artikel & Medien (ZIP)', + desc: 'artikel.csv inklusive aller referenzierten AVIF-Bilder und Preise.', + endpoint: '/api/article/export.zip', + defaultFilename: 'artikel-export.zip', + }, + { + id: 'categories', + label: 'Kategorien & Bilder (ZIP)', + desc: 'kategorien.csv inklusive Kategoriebäumen und AVIF-Vorschaubildern.', + endpoint: '/api/category/export.zip', + defaultFilename: 'kategorien-export.zip', + }, + { + id: 'company', + label: 'Gesamtsicherung / Mandant (ZIP)', + desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).', + endpoint: '/api/company/export.zip', + defaultFilename: 'mandant-gesamtsicherung.zip', + }, +]; + +export default class ImportExportSettingsPanel extends Component { + constructor(props) { + super(props); + this.state = { + // Export state + exportType: 'articles', + downloading: false, + downloadProgress: null, // null = unknown, number 0-100 + downloadLoaded: 0, + downloadTotal: 0, + downloadError: null, + + // Import state + selectedFile: null, + uploading: false, + uploadProgress: 0, // 0-100 + uploadLoaded: 0, + uploadTotal: 0, + uploadError: null, + uploadSuccess: null, + isDraggingOver: false, + }; + + this.fileInputRef = createRef(); + this.xhrRef = null; + this.abortController = null; + + this.handleStartDownload = this.handleStartDownload.bind(this); + this.handleCancelDownload = this.handleCancelDownload.bind(this); + this.handleFileChange = this.handleFileChange.bind(this); + this.handleStartUpload = this.handleStartUpload.bind(this); + this.handleCancelUpload = this.handleCancelUpload.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDragLeave = this.handleDragLeave.bind(this); + this.handleDrop = this.handleDrop.bind(this); + } + + componentWillUnmount() { + if (this.xhrRef) { + this.xhrRef.abort(); + } + if (this.abortController) { + this.abortController.abort(); + } + } + + async handleStartDownload() { + const { exportType } = this.state; + const option = EXPORT_OPTIONS.find((opt) => opt.id === exportType) ?? EXPORT_OPTIONS[0]; + + this.setState({ + downloading: true, + downloadProgress: null, + downloadLoaded: 0, + downloadTotal: 0, + downloadError: null, + }); + + this.abortController = new AbortController(); + + try { + const response = await fetch(option.endpoint, { + method: 'GET', + credentials: 'include', + signal: this.abortController.signal, + }); + + if (!response.ok) { + let errMessage = `Fehler beim Herunterladen (${response.status})`; + try { + const json = await response.json(); + errMessage = json.error || json.message || errMessage; + } catch { + // Ignore json parse error + } + throw new Error(errMessage); + } + + const contentLengthHeader = response.headers.get('content-length'); + const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0; + const reader = response.body.getReader(); + const chunks = []; + let loadedBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + loadedBytes += value.length; + + if (totalBytes > 0) { + const percent = Math.min(100, Math.round((loadedBytes / totalBytes) * 100)); + this.setState({ + downloadProgress: percent, + downloadLoaded: loadedBytes, + downloadTotal: totalBytes, + }); + } else { + this.setState({ + downloadProgress: null, + downloadLoaded: loadedBytes, + }); + } + } + + // Stream completed — trigger browser file save + const blob = new Blob(chunks, { type: 'application/zip' }); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = option.defaultFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + + enqueueSnackbar(`${option.label} erfolgreich heruntergeladen.`, { variant: 'success' }); + this.setState({ downloading: false, downloadProgress: 100 }); + } catch (err) { + if (err.name === 'AbortError') { + enqueueSnackbar('Download abgebrochen.', { variant: 'info' }); + } else { + this.setState({ downloadError: err.message || String(err) }); + } + } finally { + this.setState({ downloading: false }); + this.abortController = null; + } + } + + handleCancelDownload() { + if (this.abortController) { + this.abortController.abort(); + } + } + + handleFileChange(event) { + const file = event.target.files?.[0]; + if (file) { + if (!isZipFile(file)) { + this.setState({ + selectedFile: null, + uploadError: 'Nur ZIP-Dateien (.zip) sind für den Import zulässig.', + uploadSuccess: null, + uploadProgress: 0, + }); + if (this.fileInputRef.current) this.fileInputRef.current.value = ''; + return; + } + this.setState({ + selectedFile: file, + uploadError: null, + uploadSuccess: null, + uploadProgress: 0, + }); + } + } + + handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + if (!this.state.isDraggingOver) { + this.setState({ isDraggingOver: true }); + } + } + + handleDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ isDraggingOver: false }); + } + + handleDrop(e) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ isDraggingOver: false }); + const file = e.dataTransfer?.files?.[0]; + if (file) { + if (!isZipFile(file)) { + this.setState({ + selectedFile: null, + uploadError: 'Nur ZIP-Dateien (.zip) sind für den Import zulässig.', + uploadSuccess: null, + uploadProgress: 0, + }); + if (this.fileInputRef.current) this.fileInputRef.current.value = ''; + return; + } + this.setState({ + selectedFile: file, + uploadError: null, + uploadSuccess: null, + uploadProgress: 0, + }); + } + } + + handleStartUpload() { + const { selectedFile } = this.state; + if (!selectedFile) return; + + this.setState({ + uploading: true, + uploadProgress: 0, + uploadLoaded: 0, + uploadTotal: selectedFile.size, + uploadError: null, + uploadSuccess: null, + }); + + const formData = new FormData(); + formData.append('file', selectedFile); + + const xhr = new XMLHttpRequest(); + this.xhrRef = xhr; + xhr.open('POST', '/api/company/import'); + xhr.withCredentials = true; + + // Track large upload progress in real-time + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = Math.min(100, Math.round((event.loaded / event.total) * 100)); + this.setState({ + uploadProgress: percent, + uploadLoaded: event.loaded, + uploadTotal: event.total, + }); + } + }; + + xhr.onload = () => { + this.xhrRef = null; + if (xhr.status >= 200 && xhr.status < 300) { + let msg = 'Import erfolgreich abgeschlossen.'; + try { + const json = JSON.parse(xhr.responseText); + msg = json.message || msg; + } catch { + // ignore + } + enqueueSnackbar(msg, { variant: 'success' }); + this.setState({ + uploading: false, + uploadProgress: 100, + uploadSuccess: msg, + selectedFile: null, + }); + if (this.fileInputRef.current) { + this.fileInputRef.current.value = ''; + } + } else { + let errMessage = `Upload fehlgeschlagen (${xhr.status})`; + try { + const json = JSON.parse(xhr.responseText); + errMessage = json.error || json.message || errMessage; + } catch { + // ignore + } + this.setState({ + uploading: false, + uploadError: errMessage, + }); + } + }; + + xhr.onerror = () => { + this.xhrRef = null; + this.setState({ + uploading: false, + uploadError: 'Netzwerkfehler beim Upload der Datei.', + }); + }; + + xhr.onabort = () => { + this.xhrRef = null; + this.setState({ uploading: false }); + enqueueSnackbar('Upload abgebrochen.', { variant: 'info' }); + }; + + xhr.send(formData); + } + + handleCancelUpload() { + if (this.xhrRef) { + this.xhrRef.abort(); + } + } + + render() { + const { + exportType, + downloading, + downloadProgress, + downloadLoaded, + downloadTotal, + downloadError, + selectedFile, + uploading, + uploadProgress, + uploadLoaded, + uploadTotal, + uploadError, + uploadSuccess, + isDraggingOver, + } = this.state; + + return ( + + + {/* SECTION 1: EXPORT (DOWNLOAD) */} + + + + + + Daten exportieren (Download) + + + Große Datenmengen werden als ZIP-Archiv gestreamt. + + + + + + + {downloadError && ( + + {downloadError} + + )} + + + + Export-Umfang auswählen + + this.setState({ exportType: e.target.value })} + > + {EXPORT_OPTIONS.map((opt) => ( + } + label={ + + + {opt.label} + + + {opt.desc} + + + } + /> + ))} + + + + {downloading && ( + + + + {downloadProgress != null + ? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})` + : `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`} + + + + + + )} + + + + + + + {/* SECTION 2: IMPORT (UPLOAD) */} + + + + + + Daten importieren (Upload) + + + Große Archive werden direkt per Stream übertragen und blockieren Ihren Browser nicht. + + + + + + + {uploadError && ( + + {uploadError} + + )} + + {uploadSuccess && ( + + {uploadSuccess} + + )} + + + + {!selectedFile ? ( + this.fileInputRef.current?.click()} + sx={{ + border: '2px dashed', + borderColor: isDraggingOver ? 'primary.main' : 'divider', + bgcolor: isDraggingOver ? 'action.hover' : 'background.default', + borderRadius: 2, + p: 4, + textAlign: 'center', + cursor: 'pointer', + transition: 'all 0.2s', + mb: 2, + '&:hover': { + borderColor: 'primary.main', + bgcolor: 'action.hover', + }, + }} + > + + + ZIP-Archiv hier ablegen + + + oder klicken, um eine ZIP-Datei auszuwählen (auch große Archive > 1 GB) + + + ) : ( + + + + + + {selectedFile.name} + + + Größe: {formatBytes(selectedFile.size)} + + + + {!uploading && ( + { + this.setState({ selectedFile: null }); + if (this.fileInputRef.current) this.fileInputRef.current.value = ''; + }} + > + + + )} + + )} + + {uploading && ( + + + + {uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)}) + + + + + + )} + + + + + + + + ); + } +} diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx index 6f62bcf..ef83a0c 100644 --- a/src/components/settings/SettingsDialog.jsx +++ b/src/components/settings/SettingsDialog.jsx @@ -10,19 +10,23 @@ import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined'; import TuneIcon from '@mui/icons-material/Tune'; import BusinessIcon from '@mui/icons-material/Business'; import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; +import ImportExportIcon from '@mui/icons-material/ImportExport'; import CloseIcon from '@mui/icons-material/Close'; import IconButton from '@mui/material/IconButton'; import CompanySettingsPanel from './CompanySettingsPanel'; import NumberSettingsPanel from './NumberSettingsPanel'; import PrinterSettingsPanel from './PrinterSettingsPanel'; import SettingsKeysPanel from './SettingsKeysPanel'; +import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; +import { SURFACE_BASE } from '../../colors'; const TAB_KEYS = { company: 'company', keys: 'keys', numbers: 'numbers', printers: 'printers', + importExport: 'importExport', posterminal: 'posterminal', }; @@ -31,6 +35,7 @@ const TABS = [ { id: TAB_KEYS.keys, label: 'Einstellungen', icon: , Panel: SettingsKeysPanel }, { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: , Panel: NumberSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: , Panel: PrinterSettingsPanel }, + { id: TAB_KEYS.importExport, label: 'Import/Export', icon: , Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: , Panel: PosTerminalSettingsPanel }, ]; @@ -68,7 +73,7 @@ export default class SettingsDialog extends Component { Einstellungen @@ -92,33 +99,87 @@ export default class SettingsDialog extends Component { - - {TABS.map((t) => ( - - ))} - - - - {TABS.map((t) => ( - - - - ))} + + {/* Vertical left sidebar menu */} + + + {TABS.map((t) => ( + + ))} + - + + {/* Right content panel */} + + + {TABS.map((t) => ( + + + + ))} + + + ); } diff --git a/src/components/settings/index.js b/src/components/settings/index.js index 618231e..7b8e948 100644 --- a/src/components/settings/index.js +++ b/src/components/settings/index.js @@ -4,3 +4,4 @@ export { default as SettingsKeysPanel } from './SettingsKeysPanel'; export { default as NumberSettingsPanel } from './NumberSettingsPanel'; export { default as PrinterSettingsPanel } from './PrinterSettingsPanel'; export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel'; +export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel'; diff --git a/src/theme/themes/default.js b/src/theme/themes/default.js index 76affeb..b3e8007 100644 --- a/src/theme/themes/default.js +++ b/src/theme/themes/default.js @@ -61,6 +61,21 @@ export function createDefaultTheme() { }, }, }, + MuiButtonBase: { + defaultProps: { + disableRipple: true, + }, + }, + MuiTab: { + defaultProps: { + disableRipple: true, + }, + }, + MuiListItemButton: { + defaultProps: { + disableRipple: true, + }, + }, MuiPopover: { defaultProps: { transitionDuration: 0,