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...)`}
+
+
+
+
+
+ )}
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={downloading}
+ onClick={this.handleStartDownload}
+ >
+ {downloading ? 'Herunterladen...' : 'Download starten'}
+
+
+
+
+ {/* 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)})
+
+
+
+
+
+ )}
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={uploading || !selectedFile}
+ onClick={this.handleStartUpload}
+ >
+ {uploading ? 'Wird hochgeladen...' : 'Upload starten'}
+
+
+
+
+
+ );
+ }
+}
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,