Compare commits
2 Commits
ab2e8c92fb
...
ec238c7b50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec238c7b50 | ||
|
|
b01ebccf90 |
127
src/api/index.js
127
src/api/index.js
@@ -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 = {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 ───────────────────────────────── */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 > 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
109
test/capture.mjs
109
test/capture.mjs
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
54
test/cdp.mjs
54
test/cdp.mjs
@@ -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');
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' } },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user