u
This commit is contained in:
628
src/components/settings/ImportExportSettingsPanel.jsx
Normal file
628
src/components/settings/ImportExportSettingsPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
{/* SECTION 1: EXPORT (DOWNLOAD) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
|
||||||
|
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
Daten exportieren (Download)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Große Datenmengen werden als ZIP-Archiv gestreamt.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
|
{downloadError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{downloadError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl component="fieldset" fullWidth sx={{ mb: 2 }}>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 1, fontSize: '0.85rem' }}>
|
||||||
|
Export-Umfang auswählen
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={exportType}
|
||||||
|
onChange={(e) => this.setState({ exportType: e.target.value })}
|
||||||
|
>
|
||||||
|
{EXPORT_OPTIONS.map((opt) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={opt.id}
|
||||||
|
value={opt.id}
|
||||||
|
disabled={downloading}
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ py: 0.5 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{opt.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{opt.desc}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{downloading && (
|
||||||
|
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{downloadProgress != null
|
||||||
|
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
|
||||||
|
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={this.handleCancelDownload}
|
||||||
|
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant={downloadProgress != null ? 'determinate' : 'indeterminate'}
|
||||||
|
value={downloadProgress ?? undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={
|
||||||
|
downloading ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CloudDownloadOutlinedIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={this.handleStartDownload}
|
||||||
|
>
|
||||||
|
{downloading ? 'Herunterladen...' : 'Download starten'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* SECTION 2: IMPORT (UPLOAD) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
|
||||||
|
<CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
Daten importieren (Upload)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Große Archive werden direkt per Stream übertragen und blockieren Ihren Browser nicht.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{uploadError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadSuccess && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
{uploadSuccess}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={this.handleFileChange}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!selectedFile ? (
|
||||||
|
<Box
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
onDragLeave={this.handleDragLeave}
|
||||||
|
onDrop={this.handleDrop}
|
||||||
|
onClick={() => 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',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudUploadOutlinedIcon sx={{ fontSize: 44, color: 'text.secondary', mb: 1 }} />
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
ZIP-Archiv hier ablegen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
oder klicken, um eine ZIP-Datei auszuwählen (auch große Archive > 1 GB)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
p: 1.5,
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<InsertDriveFileOutlinedIcon color="primary" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{selectedFile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Größe: {formatBytes(selectedFile.size)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{!uploading && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ selectedFile: null });
|
||||||
|
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)})
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={this.handleCancelUpload}
|
||||||
|
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={
|
||||||
|
uploading ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CloudUploadOutlinedIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={uploading || !selectedFile}
|
||||||
|
onClick={this.handleStartUpload}
|
||||||
|
>
|
||||||
|
{uploading ? 'Wird hochgeladen...' : 'Upload starten'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,19 +10,23 @@ import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
|||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import BusinessIcon from '@mui/icons-material/Business';
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||||
|
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import CompanySettingsPanel from './CompanySettingsPanel';
|
import CompanySettingsPanel from './CompanySettingsPanel';
|
||||||
import NumberSettingsPanel from './NumberSettingsPanel';
|
import NumberSettingsPanel from './NumberSettingsPanel';
|
||||||
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
||||||
import SettingsKeysPanel from './SettingsKeysPanel';
|
import SettingsKeysPanel from './SettingsKeysPanel';
|
||||||
|
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
||||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
|
import { SURFACE_BASE } from '../../colors';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
company: 'company',
|
company: 'company',
|
||||||
keys: 'keys',
|
keys: 'keys',
|
||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
printers: 'printers',
|
printers: 'printers',
|
||||||
|
importExport: 'importExport',
|
||||||
posterminal: 'posterminal',
|
posterminal: 'posterminal',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ const TABS = [
|
|||||||
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
||||||
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export default class SettingsDialog extends Component {
|
|||||||
<AppDialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidth="md"
|
maxWidth="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{ paper: { sx: paperSx } }}
|
slotProps={{ paper: { sx: paperSx } }}
|
||||||
PaperProps={{ sx: paperSx }}
|
PaperProps={{ sx: paperSx }}
|
||||||
@@ -80,6 +85,8 @@ export default class SettingsDialog extends Component {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
px: 3,
|
px: 3,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
@@ -92,33 +99,87 @@ export default class SettingsDialog extends Component {
|
|||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Tabs
|
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
value={tab}
|
{/* Vertical left sidebar menu */}
|
||||||
onChange={this.handleTabChange}
|
<Box
|
||||||
variant="scrollable"
|
sx={{
|
||||||
scrollButtons="auto"
|
width: 220,
|
||||||
allowScrollButtonsMobile
|
minWidth: 220,
|
||||||
sx={{ px: 3 }}
|
borderRight: '1px solid',
|
||||||
>
|
borderColor: 'divider',
|
||||||
{TABS.map((t) => (
|
bgcolor: SURFACE_BASE,
|
||||||
<Tab key={t.id} icon={t.icon} iconPosition="start" label={t.label} value={t.id} />
|
py: 2,
|
||||||
))}
|
overflowY: 'auto',
|
||||||
</Tabs>
|
}}
|
||||||
<DialogContent dividers sx={{ flex: 1, minHeight: 0 }}>
|
>
|
||||||
<Box sx={{ display: 'grid' }}>
|
<Tabs
|
||||||
{TABS.map((t) => (
|
orientation="vertical"
|
||||||
<Box
|
value={tab}
|
||||||
key={t.id}
|
onChange={this.handleTabChange}
|
||||||
sx={{
|
sx={{
|
||||||
gridArea: '1 / 1',
|
'& .MuiTabs-indicator': {
|
||||||
display: t.id === tab ? 'block' : 'none',
|
display: 'none',
|
||||||
}}
|
},
|
||||||
>
|
'& .MuiTab-root': {
|
||||||
<t.Panel />
|
minHeight: 44,
|
||||||
</Box>
|
justifyContent: 'flex-start',
|
||||||
))}
|
textAlign: 'left',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'text.secondary',
|
||||||
|
borderRadius: 2,
|
||||||
|
mx: 1.5,
|
||||||
|
my: 0.5,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
gap: 1.5,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'text.primary',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: '#ffffff !important',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: '0 2px 6px rgba(15, 118, 110, 0.25)',
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Tab
|
||||||
|
key={t.id}
|
||||||
|
disableRipple
|
||||||
|
icon={t.icon}
|
||||||
|
iconPosition="start"
|
||||||
|
label={t.label}
|
||||||
|
value={t.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
|
||||||
|
{/* Right content panel */}
|
||||||
|
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
|
||||||
|
<Box sx={{ display: 'grid' }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Box
|
||||||
|
key={t.id}
|
||||||
|
sx={{
|
||||||
|
gridArea: '1 / 1',
|
||||||
|
display: t.id === tab ? 'block' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<t.Panel />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Box>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { default as SettingsKeysPanel } from './SettingsKeysPanel';
|
|||||||
export { default as NumberSettingsPanel } from './NumberSettingsPanel';
|
export { default as NumberSettingsPanel } from './NumberSettingsPanel';
|
||||||
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
||||||
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
||||||
|
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ export function createDefaultTheme() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MuiButtonBase: {
|
||||||
|
defaultProps: {
|
||||||
|
disableRipple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
defaultProps: {
|
||||||
|
disableRipple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiListItemButton: {
|
||||||
|
defaultProps: {
|
||||||
|
disableRipple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
MuiPopover: {
|
MuiPopover: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
transitionDuration: 0,
|
transitionDuration: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user