u
This commit is contained in:
@@ -24,6 +24,11 @@ import categoryAutomat from '../../automats/categoryAutomat';
|
||||
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import { orderAutomat } from '../../automats/orderAutomat';
|
||||
import SkeleHashImage from './SkeleHashImage';
|
||||
|
||||
// Keep only the most recent N image events in state to avoid unbounded growth
|
||||
// on large imports (thousands of images).
|
||||
const MAX_STREAMED_IMAGES = 60;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B';
|
||||
@@ -42,14 +47,18 @@ function isZipFile(file) {
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_LABELS = {
|
||||
brands: 'Marken werden importiert…',
|
||||
categories: 'Kategorien werden importiert…',
|
||||
articles: 'Artikel werden abgeglichen…',
|
||||
variants: 'Varianten werden verknüpft…',
|
||||
images: 'Bilder werden optimiert…',
|
||||
orders: 'Aufträge werden importiert…',
|
||||
};
|
||||
function shortHash(hash) {
|
||||
if (typeof hash !== 'string') return '';
|
||||
return hash.length > 10 ? `${hash.slice(0, 8)}…` : hash;
|
||||
}
|
||||
|
||||
// Counter-based phases emitted by the `progress` event.
|
||||
const PROGRESS_PHASES = [
|
||||
{ key: 'categories', label: 'Kategorien', totalKey: 'categoriestotal', doneKey: 'categoriesdone' },
|
||||
{ key: 'articles', label: 'Artikel', totalKey: 'articlestotal', doneKey: 'articlesdone' },
|
||||
{ key: 'images', label: 'Medien', totalKey: 'imagestotal', doneKey: 'imagesdone' },
|
||||
{ key: 'receipts', label: 'Belege', totalKey: 'receiptstotal', doneKey: 'receiptsdone' },
|
||||
];
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
{
|
||||
@@ -94,10 +103,10 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
uploadProgress: 0,
|
||||
uploadLoaded: 0,
|
||||
uploadTotal: 0,
|
||||
importPhase: null, // 'categories' | 'articles' | 'variants' | 'images' | null
|
||||
importPhaseCurrent: 0,
|
||||
importPhaseTotal: 0,
|
||||
importItem: null,
|
||||
importProgress: null, // raw counters from the `progress` event
|
||||
// Most recent SkeleHashes from the stream ({ id, hash })
|
||||
importedImages: [],
|
||||
importedImageCount: 0,
|
||||
statusMessage: null,
|
||||
importStats: null,
|
||||
uploadError: null,
|
||||
@@ -243,16 +252,16 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
|
||||
this.uploadAbortController = new AbortController();
|
||||
|
||||
this.imageSeq = 0;
|
||||
this.setState({
|
||||
uploading: true,
|
||||
uploadStage: 'upload',
|
||||
uploadProgress: 0,
|
||||
uploadLoaded: 0,
|
||||
uploadTotal: selectedFile.size,
|
||||
importPhase: null,
|
||||
importPhaseCurrent: 0,
|
||||
importPhaseTotal: 0,
|
||||
importItem: null,
|
||||
importProgress: null,
|
||||
importedImages: [],
|
||||
importedImageCount: 0,
|
||||
statusMessage: null,
|
||||
importStats: null,
|
||||
uploadError: null,
|
||||
@@ -278,12 +287,31 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
});
|
||||
},
|
||||
onProgress: (progress) => {
|
||||
this.setState({
|
||||
uploadStage: 'server',
|
||||
importPhase: progress?.phase ?? null,
|
||||
importPhaseCurrent: progress?.current ?? 0,
|
||||
importPhaseTotal: progress?.total ?? 0,
|
||||
importItem: progress?.item ?? null,
|
||||
if (!progress || typeof progress !== 'object') return;
|
||||
// Counter-based protocol: { categoriestotal, categoriesdone, ... }
|
||||
this.setState({ uploadStage: 'server', importProgress: progress });
|
||||
},
|
||||
onImage: (images) => {
|
||||
const list = Array.isArray(images) ? images.filter(Boolean) : [];
|
||||
if (!list.length) return;
|
||||
this.setState((prev) => {
|
||||
let seq = this.imageSeq ?? 0;
|
||||
const batch = list.map((item) => {
|
||||
seq += 1;
|
||||
return {
|
||||
id: seq,
|
||||
hash: typeof item === 'string' ? item : (item?.skelehash ?? null),
|
||||
};
|
||||
});
|
||||
this.imageSeq = seq;
|
||||
const next = [...batch.reverse(), ...prev.importedImages];
|
||||
if (next.length > MAX_STREAMED_IMAGES) next.length = MAX_STREAMED_IMAGES;
|
||||
return {
|
||||
uploadStage: 'server',
|
||||
importedImages: next,
|
||||
importedImageCount: prev.importedImageCount + list.length,
|
||||
statusMessage: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
onDone: (stats) => {
|
||||
@@ -293,7 +321,7 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
importStats: stats,
|
||||
uploadSuccess: 'Import erfolgreich abgeschlossen.',
|
||||
statusMessage: null,
|
||||
importItem: null,
|
||||
importProgress: null,
|
||||
selectedFile: null,
|
||||
});
|
||||
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
|
||||
@@ -354,20 +382,30 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
uploadProgress,
|
||||
uploadLoaded,
|
||||
uploadTotal,
|
||||
importPhase,
|
||||
importPhaseCurrent,
|
||||
importPhaseTotal,
|
||||
importItem,
|
||||
importProgress,
|
||||
importedImages,
|
||||
importedImageCount,
|
||||
statusMessage,
|
||||
importStats,
|
||||
uploadError,
|
||||
uploadSuccess,
|
||||
} = this.state;
|
||||
|
||||
const progressPercent =
|
||||
importPhaseTotal > 0
|
||||
? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100))
|
||||
: null;
|
||||
const phaseRows = PROGRESS_PHASES.map((phase) => {
|
||||
const total = importProgress?.[phase.totalKey] ?? 0;
|
||||
const done = importProgress?.[phase.doneKey] ?? 0;
|
||||
return {
|
||||
...phase,
|
||||
total,
|
||||
done,
|
||||
percent: total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0,
|
||||
};
|
||||
}).filter((row) => row.total > 0);
|
||||
|
||||
const totalUnits = phaseRows.reduce((sum, row) => sum + row.total, 0);
|
||||
const doneUnits = phaseRows.reduce((sum, row) => sum + Math.min(row.done, row.total), 0);
|
||||
const overallPercent =
|
||||
totalUnits > 0 ? Math.min(100, Math.round((doneUnits / totalUnits) * 100)) : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
|
||||
@@ -750,7 +788,8 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} color="primary.main">
|
||||
{(importPhase && PHASE_LABELS[importPhase]) || statusMessage || 'Verarbeitung läuft…'}
|
||||
{statusMessage || 'Daten werden verarbeitet…'}
|
||||
{overallPercent != null ? ` · ${overallPercent}%` : ''}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -761,32 +800,26 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Box>
|
||||
{importPhaseTotal > 0 ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
maxWidth: '75%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{importItem || 'Fortschritt'}
|
||||
</Typography>
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
{importPhaseCurrent} / {importPhaseTotal} ({progressPercent}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progressPercent ?? 0}
|
||||
sx={{ height: 8, borderRadius: 1 }}
|
||||
/>
|
||||
</>
|
||||
{phaseRows.length > 0 ? (
|
||||
<Stack spacing={1}>
|
||||
{phaseRows.map((row) => (
|
||||
<Box key={row.key}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
{row.done} / {row.total} ({row.percent}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={row.percent}
|
||||
sx={{ height: 6, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{statusMessage && (
|
||||
@@ -797,6 +830,91 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
<LinearProgress sx={{ height: 8, borderRadius: 1 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live stream of decoded SkeleHash placeholders */}
|
||||
{importedImages.length > 0 && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
SkeleHash-Platzhalter (Vorschau)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{importedImageCount} Bild(er) verarbeitet
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: 1,
|
||||
maxHeight: 220,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{importedImages.map((image) => (
|
||||
<Box
|
||||
key={image.id}
|
||||
title={image.hash || 'kein Hash'}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
aspectRatio: '1 / 1',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
{image.hash ? (
|
||||
<SkeleHashImage hash={image.hash} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.disabled',
|
||||
fontSize: '0.6rem',
|
||||
textAlign: 'center',
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
Kein Hash
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 'auto 0 0 0',
|
||||
bgcolor: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
noWrap
|
||||
display="block"
|
||||
sx={{ fontSize: '0.6rem', lineHeight: 1.2, fontFamily: 'monospace' }}
|
||||
>
|
||||
{shortHash(image.hash)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user