Compare commits

..

2 Commits

Author SHA1 Message Date
seb
ec238c7b50 u 2026-10-08 04:45:35 +02:00
seb
b01ebccf90 u 2026-10-08 04:41:27 +02:00
11 changed files with 996 additions and 405 deletions

View File

@@ -1686,6 +1686,133 @@ export const company = {
body: { companyId }, body: { companyId },
}); });
}, },
/**
* POST /api/company/import
* Uploads and streams catalog import (.zip) archive via Server-Sent Events.
* Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time.
*
* @param {File|Blob|null} file
* @param {object} [callbacks]
* @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress]
* @param {(message: string) => void} [callbacks.onStatus]
* @param {(progress: { phase: string, current: number, total: number, item?: string }) => void} [callbacks.onProgress]
* @param {(stats: object) => void} [callbacks.onDone]
* @param {(error: string) => void} [callbacks.onError]
* @param {object} [options]
* @param {AbortSignal} [options.signal]
*/
importCatalog(file, callbacks = {}, { signal } = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/company/import');
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'text/event-stream');
if (signal) {
if (signal.aborted) {
return reject(new DOMException('Aborted', 'AbortError'));
}
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Aborted', 'AbortError'));
});
}
// Track request body upload progress
if (file && xhr.upload) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
callbacks.onUploadProgress?.({
loaded: event.loaded,
total: event.total,
percent,
});
}
};
}
// Stream and parse incoming SSE chunks as the server processes the archive
let processedIndex = 0;
let buffer = '';
const processChunks = () => {
const text = xhr.responseText || '';
if (text.length <= processedIndex) return;
const newChunk = text.slice(processedIndex);
processedIndex = text.length;
buffer += newChunk;
const messages = buffer.split('\n\n');
buffer = messages.pop() || '';
for (const msg of messages) {
if (!msg.trim()) continue;
const eventMatch = msg.match(/^event:\s*(\w+)/m);
const dataMatch = msg.match(/^data:\s*(.+)$/m);
const eventType = eventMatch ? eventMatch[1] : 'message';
let eventData = null;
if (dataMatch) {
try {
eventData = JSON.parse(dataMatch[1]);
} catch {
eventData = dataMatch[1];
}
}
switch (eventType) {
case 'status':
callbacks.onStatus?.(eventData?.message);
break;
case 'progress':
callbacks.onProgress?.(eventData);
break;
case 'done':
callbacks.onDone?.(eventData?.stats ?? eventData);
break;
case 'error':
callbacks.onError?.(eventData?.message || 'Fehler beim Importieren');
break;
}
}
};
xhr.onprogress = processChunks;
xhr.onload = () => {
processChunks();
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`;
try {
const parsed = JSON.parse(xhr.responseText);
errMsg = parsed.error || parsed.message || errMsg;
} catch {
// ignore
}
reject(new Error(errMsg));
}
};
xhr.onerror = () => {
reject(new Error('Netzwerkfehler beim Upload der Datei'));
};
xhr.onabort = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
const formData = new FormData();
if (file) {
formData.append('file', file);
}
xhr.send(formData);
});
},
}; };
export const posterminal = { export const posterminal = {

View File

@@ -406,7 +406,7 @@ class CompanySetupDialog extends Component {
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an: Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
</Typography> </Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 1.5 }}> <Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
{PRESETS.map((p) => { {PRESETS.map((p) => {
const isSelected = selectedPreset === p.id; const isSelected = selectedPreset === p.id;
return ( return (

View File

@@ -39,6 +39,8 @@ import articleIndexAutomat from '../../../automats/articleIndexAutomat';
import { import {
MAGENTA_PANEL, MAGENTA_PANEL,
SHADOW_CARD, SHADOW_CARD,
WHITE,
alpha,
} from '../../../colors'; } from '../../../colors';
export default class ArticleViewLists extends PureComponent { export default class ArticleViewLists extends PureComponent {
@@ -1095,9 +1097,21 @@ export default class ArticleViewLists extends PureComponent {
assignedMatch?.attributeValue || assignedMatch?.attributeValue ||
null; null;
const siblings = (parentArticle?.children || []).filter( const childrenList = parentArticle?.children || [];
(c) => c.sku !== articleData.sku && c.id !== articleData.id const hasSelf = childrenList.some(
(c) => c.sku === articleData.sku || (articleData.id != null && c.id === articleData.id)
); );
const siblings = hasSelf
? childrenList
: [
...childrenList,
{
id: articleData.id,
sku: articleData.sku,
name: articleData.name,
attributeValue: variationValue,
},
];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
@@ -1206,10 +1220,10 @@ export default class ArticleViewLists extends PureComponent {
</Box> </Box>
</Box> </Box>
{/* Link(s) Geschwister */} {/* Varianten */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Geschwister: Varianten:
</Typography> </Typography>
{siblings.length === 0 ? ( {siblings.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 0.5, fontSize: '0.8rem' }}> <Typography variant="body2" color="text.secondary" sx={{ py: 0.5, fontSize: '0.8rem' }}>
@@ -1217,82 +1231,131 @@ export default class ArticleViewLists extends PureComponent {
</Typography> </Typography>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{siblings.map((sibling) => ( {siblings.map((sibling) => {
<Box const isSelf =
key={sibling.id || sibling.sku} sibling.sku === articleData.sku ||
sx={{ (articleData.id != null && sibling.id === articleData.id);
display: 'flex',
alignItems: 'center', return (
justifyContent: 'space-between', <Box
px: 1, key={sibling.id || sibling.sku}
py: 0.6,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.15s ease-in-out',
'&:hover': {
borderColor: 'primary.main',
boxShadow: SHADOW_CARD,
},
}}
>
<Link
component={RouterLink}
to={artikelDetailPath(sibling.sku)}
sx={{ sx={{
minWidth: 0,
flex: 1,
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
textDecoration: 'none', justifyContent: 'space-between',
color: 'inherit', px: 1,
py: 0.25, py: 0.6,
cursor: 'pointer', borderRadius: 1,
'&:hover .child-name': { bgcolor: isSelf ? alpha(WHITE, 0.6) : 'background.paper',
color: 'primary.main', border: '1px solid',
}, borderColor: 'divider',
'&:hover .child-sku': { transition: 'all 0.15s ease-in-out',
color: 'primary.dark', ...(!isSelf && {
textDecoration: 'underline', '&:hover': {
}, borderColor: 'primary.main',
boxShadow: SHADOW_CARD,
},
}),
}} }}
> >
<Typography {isSelf ? (
className="child-name" <Box
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
}}
>
{sibling.attributeValue || sibling.name || sibling.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="child-sku"
variant="caption"
sx={{ sx={{
fontSize: '0.78rem', minWidth: 0,
fontWeight: 600, flex: 1,
color: 'primary.main', display: 'flex',
fontFamily: 'monospace', flexDirection: 'column',
letterSpacing: '0.02em', py: 0.25,
}} }}
> >
{sibling.sku} <Typography
</Typography> variant="body2"
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} /> sx={{
</Box> fontWeight: 600,
</Link> fontSize: '0.86rem',
</Box> lineHeight: 1.3,
))} overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
}}
>
{sibling.attributeValue || variationValue || sibling.name || sibling.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
variant="caption"
sx={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'text.secondary',
fontFamily: 'monospace',
letterSpacing: '0.02em',
}}
>
{sibling.sku}
</Typography>
</Box>
</Box>
) : (
<Link
component={RouterLink}
to={artikelDetailPath(sibling.sku)}
sx={{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
py: 0.25,
cursor: 'pointer',
'&:hover .child-name': {
color: 'primary.main',
},
'&:hover .child-sku': {
color: 'primary.dark',
textDecoration: 'underline',
},
}}
>
<Typography
className="child-name"
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
}}
>
{sibling.attributeValue || sibling.name || sibling.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="child-sku"
variant="caption"
sx={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'primary.main',
fontFamily: 'monospace',
letterSpacing: '0.02em',
}}
>
{sibling.sku}
</Typography>
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
</Box>
</Link>
)}
</Box>
);
})}
</Box> </Box>
)} )}
</Box> </Box>
@@ -1510,6 +1573,176 @@ export default class ArticleViewLists extends PureComponent {
) : null} ) : null}
</Box> </Box>
{/* Vaterartikel */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Vaterartikel:
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.6,
borderRadius: 1,
bgcolor: alpha(WHITE, 0.6),
border: '1px solid',
borderColor: 'divider',
}}
>
<Box
sx={{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
py: 0.25,
}}
>
<Typography
className="link-title"
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
}}
>
{articleData.name || articleData.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="link-sku"
variant="caption"
sx={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'text.secondary',
fontFamily: 'monospace',
letterSpacing: '0.02em',
}}
>
{articleData.sku}
</Typography>
</Box>
</Box>
</Box>
</Box>
{/* Varianten */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Varianten:
</Typography>
{children.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => (
<Box
key={child.id || child.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.6,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.15s ease-in-out',
'&:hover': {
borderColor: 'primary.main',
boxShadow: SHADOW_CARD,
},
}}
>
<Link
component={RouterLink}
to={artikelDetailPath(child.sku)}
sx={{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
py: 0.25,
mr: 1,
cursor: 'pointer',
'&:hover .child-name': {
color: 'primary.main',
},
'&:hover .child-sku': {
color: 'primary.dark',
textDecoration: 'underline',
},
}}
>
<Typography
className="child-name"
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
}}
>
{child.attributeValue || child.name || child.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="child-sku"
variant="caption"
sx={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'primary.main',
fontFamily: 'monospace',
letterSpacing: '0.02em',
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
}}
>
{child.sku}
</Typography>
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
</Box>
</Link>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{
p: 0.35,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
))}
</Box>
)}
</Box>
{/* Add Child Variant Row */} {/* Add Child Variant Row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
{isCustomChildValue || availableParentValues.length === 0 ? ( {isCustomChildValue || availableParentValues.length === 0 ? (
@@ -1533,7 +1766,17 @@ export default class ArticleViewLists extends PureComponent {
label="Variante" label="Variante"
value={selectedChildValueId} value={selectedChildValueId}
disabled={busy} disabled={busy}
onChange={(e) => this.setState({ selectedChildValueId: e.target.value })} onChange={(e) => {
if (e.target.value === '__custom__') {
this.setState({
isCustomChildValue: true,
selectedChildValueId: '',
newChildText: '',
});
} else {
this.setState({ selectedChildValueId: e.target.value });
}
}}
sx={{ flex: 1, minWidth: 0 }} sx={{ flex: 1, minWidth: 0 }}
> >
<MenuItem value="" disabled> <MenuItem value="" disabled>
@@ -1544,6 +1787,9 @@ export default class ArticleViewLists extends PureComponent {
{v.value} {v.value}
</MenuItem> </MenuItem>
))} ))}
<MenuItem value="__custom__">
<em>+ Neuer Wert...</em>
</MenuItem>
</TextInput> </TextInput>
)} )}
@@ -1597,117 +1843,6 @@ export default class ArticleViewLists extends PureComponent {
</Link> </Link>
</Box> </Box>
) : null} ) : null}
{/* Children List */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
mt: 0.5,
}}
>
{children.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
children.map((child) => (
<Box
key={child.id || child.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.6,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.15s ease-in-out',
'&:hover': {
borderColor: 'primary.main',
boxShadow: SHADOW_CARD,
},
}}
>
<Link
component={RouterLink}
to={artikelDetailPath(child.sku)}
sx={{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
py: 0.25,
mr: 1,
cursor: 'pointer',
'&:hover .child-name': {
color: 'primary.main',
},
'&:hover .child-sku': {
color: 'primary.dark',
textDecoration: 'underline',
},
}}
>
<Typography
className="child-name"
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
}}
>
{child.attributeValue || child.name || child.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="child-sku"
variant="caption"
sx={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'primary.main',
fontFamily: 'monospace',
letterSpacing: '0.02em',
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
}}
>
{child.sku}
</Typography>
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
</Box>
</Link>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{
p: 0.35,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
))
)}
</Box>
</Box> </Box>
) : isChild && parentSku ? ( ) : isChild && parentSku ? (
/* ── Mode 2: Child Variant Article ───────────────────────────────── */ /* ── Mode 2: Child Variant Article ───────────────────────────────── */

View File

@@ -131,7 +131,7 @@ export default class ArticleSettingsTab extends Component {
return ( return (
<Box> <Box>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap"> <Stack direction="row" spacing={1.5} alignItems="center" useFlexGap sx={{ flexWrap: 'wrap' }}>
<Button <Button
type="button" type="button"
color="error" color="error"

View File

@@ -16,8 +16,13 @@ import Typography from '@mui/material/Typography';
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined'; import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { company } from '../../api';
import categoryAutomat from '../../automats/categoryAutomat';
import articleIndexAutomat from '../../automats/articleIndexAutomat';
import brandAutomat from '../../automats/brandAutomat';
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B'; if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B';
@@ -36,6 +41,14 @@ 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…',
};
const EXPORT_OPTIONS = [ const EXPORT_OPTIONS = [
{ {
id: 'articles', id: 'articles',
@@ -72,19 +85,25 @@ export default class ImportExportSettingsPanel extends Component {
downloadTotal: 0, downloadTotal: 0,
downloadError: null, downloadError: null,
// Import state // Import state (SSE streaming & upload tracking)
selectedFile: null, selectedFile: null,
uploading: false, uploading: false,
uploadProgress: 0, // 0-100 uploadStage: 'idle', // 'idle' | 'upload' | 'server'
uploadProgress: 0,
uploadLoaded: 0, uploadLoaded: 0,
uploadTotal: 0, uploadTotal: 0,
importPhase: null, // 'categories' | 'articles' | 'variants' | 'images' | null
importPhaseCurrent: 0,
importPhaseTotal: 0,
importItem: null,
statusMessage: null,
importStats: null,
uploadError: null, uploadError: null,
uploadSuccess: null, uploadSuccess: null,
isDraggingOver: false,
}; };
this.fileInputRef = createRef(); this.fileInputRef = createRef();
this.xhrRef = null; this.uploadAbortController = null;
this.abortController = null; this.abortController = null;
this.handleStartDownload = this.handleStartDownload.bind(this); this.handleStartDownload = this.handleStartDownload.bind(this);
@@ -92,14 +111,11 @@ export default class ImportExportSettingsPanel extends Component {
this.handleFileChange = this.handleFileChange.bind(this); this.handleFileChange = this.handleFileChange.bind(this);
this.handleStartUpload = this.handleStartUpload.bind(this); this.handleStartUpload = this.handleStartUpload.bind(this);
this.handleCancelUpload = this.handleCancelUpload.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() { componentWillUnmount() {
if (this.xhrRef) { if (this.uploadAbortController) {
this.xhrRef.abort(); this.uploadAbortController.abort();
} }
if (this.abortController) { if (this.abortController) {
this.abortController.abort(); this.abortController.abort();
@@ -219,133 +235,103 @@ export default class ImportExportSettingsPanel extends Component {
} }
} }
handleDragOver(e) { async handleStartUpload() {
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; const { selectedFile } = this.state;
if (!selectedFile) return; if (!selectedFile) return;
this.uploadAbortController = new AbortController();
this.setState({ this.setState({
uploading: true, uploading: true,
uploadStage: 'upload',
uploadProgress: 0, uploadProgress: 0,
uploadLoaded: 0, uploadLoaded: 0,
uploadTotal: selectedFile.size, uploadTotal: selectedFile.size,
importPhase: null,
importPhaseCurrent: 0,
importPhaseTotal: 0,
importItem: null,
statusMessage: null,
importStats: null,
uploadError: null, uploadError: null,
uploadSuccess: null, uploadSuccess: null,
}); });
const formData = new FormData(); try {
formData.append('file', selectedFile); await company.importCatalog(
selectedFile,
const xhr = new XMLHttpRequest(); {
this.xhrRef = xhr; onUploadProgress: (p) => {
xhr.open('POST', '/api/company/import'); this.setState({
xhr.withCredentials = true; uploadStage: 'upload',
uploadProgress: p.percent,
// Track large upload progress in real-time uploadLoaded: p.loaded,
xhr.upload.onprogress = (event) => { uploadTotal: p.total,
if (event.lengthComputable) { });
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100)); },
this.setState({ onStatus: (message) => {
uploadProgress: percent, this.setState({
uploadLoaded: event.loaded, uploadStage: 'server',
uploadTotal: event.total, statusMessage: message,
}); });
},
onProgress: (progress) => {
this.setState({
uploadStage: 'server',
importPhase: progress?.phase ?? null,
importPhaseCurrent: progress?.current ?? 0,
importPhaseTotal: progress?.total ?? 0,
importItem: progress?.item ?? null,
});
},
onDone: (stats) => {
this.setState({
uploading: false,
uploadStage: 'idle',
importStats: stats,
uploadSuccess: 'Import erfolgreich abgeschlossen.',
statusMessage: null,
importItem: null,
selectedFile: null,
});
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
enqueueSnackbar('Import erfolgreich abgeschlossen.', { variant: 'success' });
void categoryAutomat.actions.loadTree();
void articleIndexAutomat.actions.reload?.();
void brandAutomat.actions.load?.();
},
onError: (message) => {
this.setState({
uploading: false,
uploadStage: 'idle',
uploadError: message,
});
enqueueSnackbar(message || 'Fehler beim Importieren', { variant: 'error' });
},
},
{ signal: this.uploadAbortController.signal },
);
} catch (err) {
if (err.name === 'AbortError') {
this.setState({ uploading: false, uploadStage: 'idle', statusMessage: null });
enqueueSnackbar('Import abgebrochen.', { variant: 'info' });
return;
} }
};
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({ this.setState({
uploading: false, uploading: false,
uploadError: 'Netzwerkfehler beim Upload der Datei.', uploadStage: 'idle',
uploadError: err?.message || 'Upload fehlgeschlagen.',
}); });
}; enqueueSnackbar(err?.message || 'Upload fehlgeschlagen.', { variant: 'error' });
} finally {
xhr.onabort = () => { this.uploadAbortController = null;
this.xhrRef = null; }
this.setState({ uploading: false });
enqueueSnackbar('Upload abgebrochen.', { variant: 'info' });
};
xhr.send(formData);
} }
handleCancelUpload() { handleCancelUpload() {
if (this.xhrRef) { if (this.uploadAbortController) {
this.xhrRef.abort(); this.uploadAbortController.abort();
this.uploadAbortController = null;
} }
} }
@@ -359,14 +345,25 @@ export default class ImportExportSettingsPanel extends Component {
downloadError, downloadError,
selectedFile, selectedFile,
uploading, uploading,
uploadStage,
uploadProgress, uploadProgress,
uploadLoaded, uploadLoaded,
uploadTotal, uploadTotal,
importPhase,
importPhaseCurrent,
importPhaseTotal,
importItem,
statusMessage,
importStats,
uploadError, uploadError,
uploadSuccess, uploadSuccess,
isDraggingOver,
} = this.state; } = this.state;
const progressPercent =
importPhaseTotal > 0
? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100))
: null;
return ( return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}> <Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
<Stack spacing={4}> <Stack spacing={4}>
@@ -483,14 +480,9 @@ export default class ImportExportSettingsPanel extends Component {
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} /> <CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Box> <Typography variant="subtitle1" fontWeight={600}>
<Typography variant="subtitle1" fontWeight={600}> Import
Daten importieren (Upload) </Typography>
</Typography>
<Typography variant="caption" color="text.secondary">
Große Archive werden direkt per Stream übertragen und blockieren Ihren Browser nicht.
</Typography>
</Box>
</Box> </Box>
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
@@ -507,6 +499,139 @@ export default class ImportExportSettingsPanel extends Component {
</Alert> </Alert>
)} )}
{/* Import statistics breakdown on done */}
{importStats && (
<Box
sx={{
p: 2,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<CheckCircleOutlineIcon color="success" />
<Typography variant="subtitle2" fontWeight={600}>
Import-Statistik
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 1.5,
}}
>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Kategorien
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.categories ?? 0}
</Typography>
{importStats.categoriesCreated != null && (
<Typography variant="caption" color="success.main" display="block">
+{importStats.categoriesCreated} neu
</Typography>
)}
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Artikel
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.articlesTotal ??
((importStats.articlesImported ?? 0) +
(importStats.articlesUpdated ?? 0))}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{importStats.articlesImported ?? 0} neu / {importStats.articlesUpdated ?? 0} akt.
</Typography>
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Varianten
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.variantsLinked ?? 0}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
verknüpft
</Typography>
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Bilder
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.articlePictures ?? 0}
</Typography>
{importStats.categoryPictures != null && (
<Typography variant="caption" color="text.secondary" display="block">
+{importStats.categoryPictures} Kat-Bilder
</Typography>
)}
</Box>
{importStats.brands != null && (
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Marken
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.brands ?? 0}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{importStats.brandsCreated != null ? `+${importStats.brandsCreated} neu` : ''}
{importStats.brandPictures ? ` (${importStats.brandPictures} Logos)` : ''}
</Typography>
</Box>
)}
</Box>
</Box>
)}
<input <input
ref={this.fileInputRef} ref={this.fileInputRef}
type="file" type="file"
@@ -517,34 +642,15 @@ export default class ImportExportSettingsPanel extends Component {
/> />
{!selectedFile ? ( {!selectedFile ? (
<Box <Box sx={{ mb: 2 }}>
onDragOver={this.handleDragOver} <Button
onDragLeave={this.handleDragLeave} variant="outlined"
onDrop={this.handleDrop} startIcon={<InsertDriveFileOutlinedIcon />}
onClick={() => this.fileInputRef.current?.click()} onClick={() => this.fileInputRef.current?.click()}
sx={{ disabled={uploading}
border: '2px dashed', >
borderColor: isDraggingOver ? 'primary.main' : 'divider', ZIP-Datei auswählen
bgcolor: isDraggingOver ? 'action.hover' : 'background.default', </Button>
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 &gt; 1 GB)
</Typography>
</Box> </Box>
) : ( ) : (
<Box <Box
@@ -560,10 +666,10 @@ export default class ImportExportSettingsPanel extends Component {
bgcolor: 'background.default', bgcolor: 'background.default',
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<InsertDriveFileOutlinedIcon color="primary" /> <InsertDriveFileOutlinedIcon color="primary" />
<Box> <Box sx={{ minWidth: 0 }}>
<Typography variant="body2" fontWeight={600}> <Typography variant="body2" fontWeight={600} noWrap>
{selectedFile.name} {selectedFile.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -585,22 +691,88 @@ export default class ImportExportSettingsPanel extends Component {
</Box> </Box>
)} )}
{/* Real-time Streaming Progress Ticker */}
{uploading && ( {uploading && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}> <Box sx={{ mb: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> {uploadStage === 'upload' ? (
<Typography variant="caption" color="text.secondary"> <>
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)}) <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
</Typography> <Typography variant="body2" fontWeight={600} color="primary.main">
<Button Datei wird übertragen…
size="small" </Typography>
color="error" <Button
onClick={this.handleCancelUpload} size="small"
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }} color="error"
> onClick={this.handleCancelUpload}
Abbrechen sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
</Button> >
</Box> Abbrechen
<LinearProgress variant="determinate" value={uploadProgress} /> </Button>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)})
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={uploadProgress}
sx={{ height: 8, borderRadius: 1 }}
/>
</>
) : (
<>
<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…'}
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelUpload}
sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
>
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 }}
/>
</>
) : (
<>
{statusMessage && (
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
{statusMessage}
</Typography>
)}
<LinearProgress sx={{ height: 8, borderRadius: 1 }} />
</>
)}
</>
)}
</Box> </Box>
)} )}
@@ -617,7 +789,7 @@ export default class ImportExportSettingsPanel extends Component {
disabled={uploading || !selectedFile} disabled={uploading || !selectedFile}
onClick={this.handleStartUpload} onClick={this.handleStartUpload}
> >
{uploading ? 'Wird hochgeladen...' : 'Upload starten'} {uploading ? 'Import läuft…' : 'Upload & Import starten'}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@@ -71,7 +71,7 @@ export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onC
{isExpanded ? ( {isExpanded ? (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}> <Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}> <Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}>
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null} {automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null} {automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
{automat.error ? ( {automat.error ? (
@@ -89,7 +89,7 @@ export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onC
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack> </Stack>
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}> <Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}>
{actionNames.length === 0 ? ( {actionNames.length === 0 ? (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
keine keine

View File

@@ -16,7 +16,7 @@
* *
* The dummy backend is always-authed, so no login step is needed. * The dummy backend is always-authed, so no login step is needed.
*/ */
import { spawn } from 'node:child_process'; import { execSync, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -40,11 +40,24 @@ const DEBUG_PORT = Number(args['debug-port'] ?? 9333);
const BASE = `http://localhost:${VITE_PORT}`; const BASE = `http://localhost:${VITE_PORT}`;
const SETTLE_MS = Number(args.settle ?? 2500); const SETTLE_MS = Number(args.settle ?? 2500);
const CHROME = args.chrome ?? '/usr/bin/google-chrome'; const CHROME = args.chrome ?? '/usr/bin/google-chrome';
const FILTER = args.filter ? String(args.filter) : null;
const ACTIVE_ROUTES = FILTER
? ROUTES.filter((r) => r.name.includes(FILTER) || r.path.includes(FILTER))
: ROUTES;
function wait(ms) { function wait(ms) {
return new Promise((r) => setTimeout(r, ms)); return new Promise((r) => setTimeout(r, ms));
} }
function freePorts(ports) {
try {
const portList = ports.map((p) => `${p}/tcp`).join(' ');
execSync(`fuser -k ${portList} 2>/dev/null || true`, { stdio: 'ignore' });
} catch {
// ignore
}
}
async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) { async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) {
const net = await import('node:net'); const net = await import('node:net');
const start = Date.now(); const start = Date.now();
@@ -66,33 +79,39 @@ async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) {
} }
} }
const activeProcs = new Set();
let activeProfile = null;
function startProc(cmd, procArgs, env, tag) { function startProc(cmd, procArgs, env, tag) {
const child = spawn(cmd, procArgs, { const child = spawn(cmd, procArgs, {
cwd: FRONT_ROOT, cwd: FRONT_ROOT,
env: { ...process.env, ...env }, env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
// Own process group per child so stopProc can kill the whole tree
// (e.g. `npx vite` wrapper + the real vite process).
detached: process.platform !== 'win32', detached: process.platform !== 'win32',
}); });
// Piped stdio keeps the parent alive even after kill(); detach the streams activeProcs.add(child);
// so teardown below can actually exit. child.stdout.on('data', (d) => {
child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`)); const text = String(d);
if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return;
process.stdout.write(`[${tag}] ${d}`);
});
child.stderr.on('data', (d) => { child.stderr.on('data', (d) => {
const text = String(d); const text = String(d);
// preview proxy logs are noise during capture; keep errors.
if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return;
process.stderr.write(`[${tag}] ${d}`); process.stderr.write(`[${tag}] ${d}`);
}); });
child.on('exit', () => activeProcs.delete(child));
child.stdout.unref?.(); child.stdout.unref?.();
child.stderr.unref?.(); child.stderr.unref?.();
child.unref?.(); child.unref?.();
return child; return child;
} }
async function stopProc(child, tag, timeoutMs = 8000) { async function stopProc(child, tag, timeoutMs = 4000) {
if (!child || child.exitCode != null || child.signalCode != null) return; if (!child || child.exitCode != null || child.signalCode != null) {
// Kill the whole process group (child + grandchildren like npx/vite). activeProcs.delete(child);
return;
}
const killTree = (sig) => { const killTree = (sig) => {
try { try {
if (child.pid && process.platform !== 'win32') { if (child.pid && process.platform !== 'win32') {
@@ -100,7 +119,7 @@ async function stopProc(child, tag, timeoutMs = 8000) {
return; return;
} }
} catch { } catch {
// fall through to single kill // fall through
} }
try { try {
child.kill(sig); child.kill(sig);
@@ -120,19 +139,39 @@ async function stopProc(child, tag, timeoutMs = 8000) {
}); });
killTree('SIGTERM'); killTree('SIGTERM');
}); });
// Drain any buffered pipe listeners so they can't hold the loop open. activeProcs.delete(child);
try { try {
child.stdout?.destroy(); child.stdout?.destroy();
} catch { } catch {}
// ignore
}
try { try {
child.stderr?.destroy(); child.stderr?.destroy();
} catch { } catch {}
// ignore
}
} }
async function cleanupEverything() {
for (const child of Array.from(activeProcs)) {
await stopProc(child, 'proc');
}
if (activeProfile) {
try {
fs.rmSync(activeProfile, { recursive: true, force: true });
} catch {}
activeProfile = null;
}
freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]);
}
process.on('SIGINT', async () => {
console.log('\n[capture] Interrupted, cleaning up...');
await cleanupEverything();
process.exit(130);
});
process.on('SIGTERM', async () => {
await cleanupEverything();
process.exit(143);
});
async function main() { async function main() {
if (!fs.existsSync(CHROME)) { if (!fs.existsSync(CHROME)) {
throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`); throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`);
@@ -142,6 +181,9 @@ async function main() {
} }
fs.mkdirSync(OUT_DIR, { recursive: true }); fs.mkdirSync(OUT_DIR, { recursive: true });
// Ensure ports are free before starting
freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]);
// Production bundle first — screenshots must reflect the shipped app. // Production bundle first — screenshots must reflect the shipped app.
console.log('[capture] vite build…'); console.log('[capture] vite build…');
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -169,12 +211,12 @@ async function main() {
'preview', 'preview',
); );
await waitForPort(VITE_PORT); await waitForPort(VITE_PORT);
await wait(2000); await wait(1500);
// Fresh profile per run: the production SW registers and caches dist/ — // Fresh profile per run: the production SW registers and caches dist/ —
// never let it leak between reference/compare runs. // never let it leak between reference/compare runs.
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-')); activeProfile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-'));
fs.mkdirSync(profile, { recursive: true }); fs.mkdirSync(activeProfile, { recursive: true });
const chrome = startProc( const chrome = startProc(
CHROME, CHROME,
[ [
@@ -183,7 +225,7 @@ async function main() {
'--disable-gpu', '--disable-gpu',
'--hide-scrollbars', '--hide-scrollbars',
`--remote-debugging-port=${DEBUG_PORT}`, `--remote-debugging-port=${DEBUG_PORT}`,
`--user-data-dir=${profile}`, `--user-data-dir=${activeProfile}`,
'about:blank', 'about:blank',
], ],
{}, {},
@@ -194,44 +236,37 @@ async function main() {
let failed = 0; let failed = 0;
try { try {
for (const viewport of VIEWPORTS) { for (const viewport of VIEWPORTS) {
for (const route of ROUTES) { for (const route of ACTIVE_ROUTES) {
const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`); const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`);
try { try {
await capturePage(DEBUG_PORT, `${BASE}${route.path}`, file, { await capturePage(DEBUG_PORT, `${BASE}${route.path}`, file, {
width: viewport.width, width: viewport.width,
height: viewport.height, height: viewport.height,
settleMs: SETTLE_MS, settleMs: SETTLE_MS,
dialog: route.dialog,
}); });
const kb = (fs.statSync(file).size / 1024).toFixed(0); const kb = (fs.statSync(file).size / 1024).toFixed(0);
console.log(`[capture] ${route.path} @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`); console.log(`[capture] ${route.name} (${route.path}) @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`);
} catch (err) { } catch (err) {
console.error(`[capture] FAIL ${route.path} @ ${viewport.name}: ${err.message}`); console.error(`[capture] FAIL ${route.name} (${route.path}) @ ${viewport.name}: ${err.message}`);
failed++; failed++;
} }
} }
} }
} finally { } finally {
await stopProc(chrome, 'chrome'); await cleanupEverything();
await stopProc(server, 'preview');
await stopProc(dummy, 'dummy');
// Remove the throwaway Chrome profile (SW + caches).
try {
fs.rmSync(profile, { recursive: true, force: true });
} catch {
// ignore
}
} }
if (failed > 0) { if (failed > 0) {
console.error(`[capture] ${failed} shots failed`); console.error(`[capture] ${failed} shots failed`);
process.exit(1); process.exit(1);
} }
console.log(`[capture] done → ${OUT_DIR}`); console.log(`[capture] done → ${OUT_DIR}`);
// Explicit exit: spawned grandchildren (preview server, chrome helpers) can
// otherwise keep the event loop alive after teardown.
process.exit(0); process.exit(0);
} }
main().catch((err) => { main().catch(async (err) => {
console.error(`[capture] FAILED: ${err.message}`); console.error(`[capture] FAILED: ${err.message}`);
await cleanupEverything();
process.exit(1); process.exit(1);
}); });

View File

@@ -161,7 +161,7 @@ async function httpJson(url, options = {}) {
* Opens a new page target on the browser, navigates, waits for the app root * Opens a new page target on the browser, navigates, waits for the app root
* to contain content + network to quiet down, captures a PNG. * to contain content + network to quiet down, captures a PNG.
*/ */
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000 }) { export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog }) {
// Chrome ≥136 requires PUT for /json/new. // Chrome ≥136 requires PUT for /json/new.
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, { const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
method: 'PUT', method: 'PUT',
@@ -205,6 +205,58 @@ export async function capturePage(debugPort, url, file, { width, height, settleM
// Extra quiet period for images / thumbhash decode / layout. // Extra quiet period for images / thumbhash decode / layout.
await wait(settleMs); await wait(settleMs);
// If a dialog needs to be opened before capturing:
if (dialog) {
if (dialog.name === 'settings') {
// Open user avatar menu
await session.send('Runtime.evaluate', {
expression: `(() => {
const btn = document.querySelector('nav .MuiAvatar-root')?.closest('button');
if (btn) btn.click();
})()`,
});
await wait(300);
// Click "Einstellungen" menu item
await session.send('Runtime.evaluate', {
expression: `(() => {
const item = Array.from(document.querySelectorAll('.MuiMenuItem-root'))
.find(el => el.textContent.includes('Einstellungen'));
if (item) item.click();
})()`,
});
await wait(400);
// Click specific tab if requested
if (dialog.tab) {
await session.send('Runtime.evaluate', {
expression: `(() => {
const target = ${JSON.stringify(dialog.tab.toLowerCase())};
const tab = Array.from(document.querySelectorAll('[role="dialog"] [role="tab"]'))
.find(el => el.textContent.trim().toLowerCase() === target || el.textContent.toLowerCase().includes(target));
if (tab) tab.click();
})()`,
});
await wait(300);
}
// Wait for any dialog-internal loading spinners to settle
const dialogDeadline = Date.now() + 15000;
while (Date.now() < dialogDeadline) {
const res = await session.send('Runtime.evaluate', {
expression: `(() => {
const spinners = document.querySelectorAll('[role="dialog"] [role="progressbar"]').length;
return { spinners };
})()`,
returnByValue: true,
});
if ((res.result?.value?.spinners ?? 0) === 0) break;
await wait(300);
}
await wait(800);
}
}
// Determinism: hide carets and scrollbars. // Determinism: hide carets and scrollbars.
await session.send('Runtime.evaluate', { await session.send('Runtime.evaluate', {
expression: `(() => { const s = document.createElement('style'); expression: `(() => { const s = document.createElement('style');

View File

@@ -77,6 +77,27 @@ async function main() {
fs.mkdirSync(FIXTURE_DIR, { recursive: true }); fs.mkdirSync(FIXTURE_DIR, { recursive: true });
fs.mkdirSync(IMAGE_DIR, { recursive: true }); fs.mkdirSync(IMAGE_DIR, { recursive: true });
const snapshotFile = path.join(FIXTURE_DIR, 'snapshot.json');
try {
await fetch(`${BACKEND}/api/auth/me`, { signal: AbortSignal.timeout(1500) });
} catch {
if (fs.existsSync(snapshotFile)) {
console.log(`[rig:collect] Live backend (${BACKEND}) not running; using existing ${snapshotFile}`);
return;
}
throw new Error(`Live backend (${BACKEND}) not running and ${snapshotFile} does not exist.`);
}
// Load existing snapshot if available for fallbacks
let existingSnapshot = null;
try {
if (fs.existsSync(snapshotFile)) {
existingSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
}
} catch {
// Ignore
}
// GOOGLE_CLIENT_ID comes from the testing backend env (../dashback/.env). // GOOGLE_CLIENT_ID comes from the testing backend env (../dashback/.env).
let dotenvClientId = null; let dotenvClientId = null;
try { try {
@@ -127,13 +148,25 @@ async function main() {
const data = await getOptional(token, `/api/category/${id}/articles`); const data = await getOptional(token, `/api/category/${id}/articles`);
snapshot.catalog.articlesByCategory[String(id)] = data?.articles ?? []; snapshot.catalog.articlesByCategory[String(id)] = data?.articles ?? [];
} }
snapshot.catalog.articleDetail = (
await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}`) const allArticles = Object.values(snapshot.catalog.articlesByCategory).flat();
)?.article ?? null; const targetSku =
allArticles.find((a) => a.sku === KNOWN_SKU)?.sku ||
allArticles[0]?.sku ||
KNOWN_SKU;
snapshot.catalog.articleDetail =
(await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}`))?.article ??
existingSnapshot?.catalog?.articleDetail ??
null;
snapshot.catalog.articleHistory = snapshot.catalog.articleHistory =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/history?limit=50`))?.history ?? []; (await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}/history?limit=50`))?.history ??
existingSnapshot?.catalog?.articleHistory ??
[];
snapshot.catalog.articleSuppliers = snapshot.catalog.articleSuppliers =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/supplier`))?.suppliers ?? []; (await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}/supplier`))?.suppliers ??
existingSnapshot?.catalog?.articleSuppliers ??
[];
snapshot.catalog.brands = (await getOptional(token, '/api/brand'))?.brands ?? []; snapshot.catalog.brands = (await getOptional(token, '/api/brand'))?.brands ?? [];
snapshot.catalog.units = (await getOptional(token, '/api/unit'))?.units ?? []; snapshot.catalog.units = (await getOptional(token, '/api/unit'))?.units ?? [];
snapshot.catalog.warehouses = snapshot.catalog.warehouses =

View File

@@ -161,6 +161,22 @@ const server = http.createServer(async (req, res) => {
article: { ...catalog.articleDetail, sku, name: catalog.articleDetail.name }, article: { ...catalog.articleDetail, sku, name: catalog.articleDetail.name },
}); });
} }
const catArticle = Object.values(catalog.articlesByCategory ?? {}).flat().find((a) => a.sku === sku);
if (catArticle) {
return json(res, 200, {
article: {
id: catArticle.id,
sku: catArticle.sku,
name: catArticle.name,
unitamount: 1,
unit: { si: 'stk', name: 'Stück' },
prices: [{ minmultiplier: 1, net: 10, taxRate: 19, customergroupId: 1 }],
categoryIds: [],
photos: catArticle.fullHash ? [{ fullHash: catArticle.fullHash, thumbHash: catArticle.thumbHash }] : [],
children: [],
},
});
}
return json(res, 404, { error: 'Article not found' }); return json(res, 404, { error: 'Article not found' });
} }
if (p === '/api/article/history') return json(res, 200, { history: misc.articleHistoryGrid ?? [] }); if (p === '/api/article/history') return json(res, 200, { history: misc.articleHistoryGrid ?? [] });
@@ -419,12 +435,27 @@ const server = http.createServer(async (req, res) => {
if (p.match(/^\/api\/company\/setting\/.+$/) && method === 'PUT') { if (p.match(/^\/api\/company\/setting\/.+$/) && method === 'PUT') {
return json(res, 200, { setting: { key: decodeURIComponent(p.split('/').pop()), ...(body ?? {}) } }); return json(res, 200, { setting: { key: decodeURIComponent(p.split('/').pop()), ...(body ?? {}) } });
} }
if (p === '/api/company/import' && method === 'POST') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('event: status\ndata: {"message": "Import gestartet. Daten werden verarbeitet..."}\n\n');
res.write('event: progress\ndata: {"phase": "categories", "current": 1, "total": 1, "item": "Katalog"}\n\n');
res.write('event: done\ndata: {"ok": true, "message": "Import erfolgreich abgeschlossen.", "stats": {"categories": 1, "categoriesCreated": 1, "categoryPictures": 0, "articlesTotal": 1, "articlesImported": 1, "articlesUpdated": 0, "variantsLinked": 0, "articlePictures": 1}}\n\n');
res.end();
return;
}
if (p.match(/^\/api\/number\/\d+$/) && method === 'PATCH') { if (p.match(/^\/api\/number\/\d+$/) && method === 'PATCH') {
return json(res, 200, { number: { id: Number(p.split('/').pop()), ...(body ?? {}) } }); return json(res, 200, { number: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
} }
if (p === '/api/posterminal' && method === 'PUT') { if (p === '/api/posterminal' && method === 'PUT') {
return json(res, 200, { posterminal: { id: 999001, ...(body ?? {}) } }); return json(res, 200, { posterminal: { id: 999001, ...(body ?? {}) } });
} }
if (p.match(/^\/api\/posterminal\/\d+$/) && method === 'PATCH') {
return json(res, 200, { posterminal: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/posterminal\/\d+\/(all|category\/\d+)$/) && ['PUT', 'DELETE'].includes(method)) { if (p.match(/^\/api\/posterminal\/\d+\/(all|category\/\d+)$/) && ['PUT', 'DELETE'].includes(method)) {
return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] }); return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] });
} }

View File

@@ -33,4 +33,10 @@ export const ROUTES = [
{ name: 'infos', path: '/infos' }, { name: 'infos', path: '/infos' },
{ name: 'infos-statistics', path: '/infos/statistics' }, { name: 'infos-statistics', path: '/infos/statistics' },
{ name: 'infos-globalhistory', path: '/infos/globalhistory' }, { name: 'infos-globalhistory', path: '/infos/globalhistory' },
{ name: 'dialog-settings-unternehmen', path: '/artikel', dialog: { name: 'settings', tab: 'Unternehmen' } },
{ name: 'dialog-settings-einstellungen', path: '/artikel', dialog: { name: 'settings', tab: 'Einstellungen' } },
{ name: 'dialog-settings-nummernkreise', path: '/artikel', dialog: { name: 'settings', tab: 'Nummernkreise' } },
{ name: 'dialog-settings-drucker', path: '/artikel', dialog: { name: 'settings', tab: 'Drucker' } },
{ name: 'dialog-settings-importexport', path: '/artikel', dialog: { name: 'settings', tab: 'Import/Export' } },
{ name: 'dialog-settings-posterminal', path: '/artikel', dialog: { name: 'settings', tab: 'POS Terminal' } },
]; ];