u
This commit is contained in:
127
src/api/index.js
127
src/api/index.js
@@ -1686,6 +1686,133 @@ export const company = {
|
||||
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 = {
|
||||
|
||||
@@ -406,7 +406,7 @@ class CompanySetupDialog extends Component {
|
||||
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
|
||||
</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) => {
|
||||
const isSelected = selectedPreset === p.id;
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,8 @@ import articleIndexAutomat from '../../../automats/articleIndexAutomat';
|
||||
import {
|
||||
MAGENTA_PANEL,
|
||||
SHADOW_CARD,
|
||||
WHITE,
|
||||
alpha,
|
||||
} from '../../../colors';
|
||||
|
||||
export default class ArticleViewLists extends PureComponent {
|
||||
@@ -1095,9 +1097,21 @@ export default class ArticleViewLists extends PureComponent {
|
||||
assignedMatch?.attributeValue ||
|
||||
null;
|
||||
|
||||
const siblings = (parentArticle?.children || []).filter(
|
||||
(c) => c.sku !== articleData.sku && c.id !== articleData.id
|
||||
const childrenList = parentArticle?.children || [];
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
||||
@@ -1206,10 +1220,10 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Link(s) Geschwister */}
|
||||
{/* Varianten */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
Geschwister:
|
||||
Varianten:
|
||||
</Typography>
|
||||
{siblings.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 0.5, fontSize: '0.8rem' }}>
|
||||
@@ -1217,82 +1231,131 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{siblings.map((sibling) => (
|
||||
<Box
|
||||
key={sibling.id || sibling.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(sibling.sku)}
|
||||
{siblings.map((sibling) => {
|
||||
const isSelf =
|
||||
sibling.sku === articleData.sku ||
|
||||
(articleData.id != null && sibling.id === articleData.id);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={sibling.id || 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',
|
||||
},
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.6,
|
||||
borderRadius: 1,
|
||||
bgcolor: isSelf ? alpha(WHITE, 0.6) : 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
...(!isSelf && {
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: SHADOW_CARD,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
{isSelf ? (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.02em',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: 0.25,
|
||||
}}
|
||||
>
|
||||
{sibling.sku}
|
||||
</Typography>
|
||||
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.86rem',
|
||||
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>
|
||||
@@ -1510,6 +1573,176 @@ export default class ArticleViewLists extends PureComponent {
|
||||
) : null}
|
||||
</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 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
{isCustomChildValue || availableParentValues.length === 0 ? (
|
||||
@@ -1533,7 +1766,17 @@ export default class ArticleViewLists extends PureComponent {
|
||||
label="Variante"
|
||||
value={selectedChildValueId}
|
||||
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 }}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
@@ -1544,6 +1787,9 @@ export default class ArticleViewLists extends PureComponent {
|
||||
{v.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="__custom__">
|
||||
<em>+ Neuer Wert...</em>
|
||||
</MenuItem>
|
||||
</TextInput>
|
||||
)}
|
||||
|
||||
@@ -1597,117 +1843,6 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</Link>
|
||||
</Box>
|
||||
) : 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>
|
||||
) : isChild && parentSku ? (
|
||||
/* ── Mode 2: Child Variant Article ───────────────────────────────── */
|
||||
|
||||
@@ -131,7 +131,7 @@ export default class ArticleSettingsTab extends Component {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
type="button"
|
||||
color="error"
|
||||
|
||||
@@ -16,8 +16,13 @@ import Typography from '@mui/material/Typography';
|
||||
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
|
||||
import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined';
|
||||
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
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) {
|
||||
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 = [
|
||||
{
|
||||
id: 'articles',
|
||||
@@ -72,19 +85,25 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
downloadTotal: 0,
|
||||
downloadError: null,
|
||||
|
||||
// Import state
|
||||
// Import state (SSE streaming & upload tracking)
|
||||
selectedFile: null,
|
||||
uploading: false,
|
||||
uploadProgress: 0, // 0-100
|
||||
uploadStage: 'idle', // 'idle' | 'upload' | 'server'
|
||||
uploadProgress: 0,
|
||||
uploadLoaded: 0,
|
||||
uploadTotal: 0,
|
||||
importPhase: null, // 'categories' | 'articles' | 'variants' | 'images' | null
|
||||
importPhaseCurrent: 0,
|
||||
importPhaseTotal: 0,
|
||||
importItem: null,
|
||||
statusMessage: null,
|
||||
importStats: null,
|
||||
uploadError: null,
|
||||
uploadSuccess: null,
|
||||
isDraggingOver: false,
|
||||
};
|
||||
|
||||
this.fileInputRef = createRef();
|
||||
this.xhrRef = null;
|
||||
this.uploadAbortController = null;
|
||||
this.abortController = null;
|
||||
|
||||
this.handleStartDownload = this.handleStartDownload.bind(this);
|
||||
@@ -92,14 +111,11 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.handleStartUpload = this.handleStartUpload.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragLeave = this.handleDragLeave.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.xhrRef) {
|
||||
this.xhrRef.abort();
|
||||
if (this.uploadAbortController) {
|
||||
this.uploadAbortController.abort();
|
||||
}
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
@@ -219,133 +235,103 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.state.isDraggingOver) {
|
||||
this.setState({ isDraggingOver: true });
|
||||
}
|
||||
}
|
||||
|
||||
handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ isDraggingOver: false });
|
||||
}
|
||||
|
||||
handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ isDraggingOver: false });
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
if (!isZipFile(file)) {
|
||||
this.setState({
|
||||
selectedFile: null,
|
||||
uploadError: 'Nur ZIP-Dateien (.zip) sind für den Import zulässig.',
|
||||
uploadSuccess: null,
|
||||
uploadProgress: 0,
|
||||
});
|
||||
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
selectedFile: file,
|
||||
uploadError: null,
|
||||
uploadSuccess: null,
|
||||
uploadProgress: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleStartUpload() {
|
||||
async handleStartUpload() {
|
||||
const { selectedFile } = this.state;
|
||||
if (!selectedFile) return;
|
||||
|
||||
this.uploadAbortController = new AbortController();
|
||||
|
||||
this.setState({
|
||||
uploading: true,
|
||||
uploadStage: 'upload',
|
||||
uploadProgress: 0,
|
||||
uploadLoaded: 0,
|
||||
uploadTotal: selectedFile.size,
|
||||
importPhase: null,
|
||||
importPhaseCurrent: 0,
|
||||
importPhaseTotal: 0,
|
||||
importItem: null,
|
||||
statusMessage: null,
|
||||
importStats: null,
|
||||
uploadError: null,
|
||||
uploadSuccess: null,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
this.xhrRef = xhr;
|
||||
xhr.open('POST', '/api/company/import');
|
||||
xhr.withCredentials = true;
|
||||
|
||||
// Track large upload progress in real-time
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
|
||||
this.setState({
|
||||
uploadProgress: percent,
|
||||
uploadLoaded: event.loaded,
|
||||
uploadTotal: event.total,
|
||||
});
|
||||
try {
|
||||
await company.importCatalog(
|
||||
selectedFile,
|
||||
{
|
||||
onUploadProgress: (p) => {
|
||||
this.setState({
|
||||
uploadStage: 'upload',
|
||||
uploadProgress: p.percent,
|
||||
uploadLoaded: p.loaded,
|
||||
uploadTotal: p.total,
|
||||
});
|
||||
},
|
||||
onStatus: (message) => {
|
||||
this.setState({
|
||||
uploadStage: 'server',
|
||||
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({
|
||||
uploading: false,
|
||||
uploadError: 'Netzwerkfehler beim Upload der Datei.',
|
||||
uploadStage: 'idle',
|
||||
uploadError: err?.message || 'Upload fehlgeschlagen.',
|
||||
});
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
this.xhrRef = null;
|
||||
this.setState({ uploading: false });
|
||||
enqueueSnackbar('Upload abgebrochen.', { variant: 'info' });
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
enqueueSnackbar(err?.message || 'Upload fehlgeschlagen.', { variant: 'error' });
|
||||
} finally {
|
||||
this.uploadAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleCancelUpload() {
|
||||
if (this.xhrRef) {
|
||||
this.xhrRef.abort();
|
||||
if (this.uploadAbortController) {
|
||||
this.uploadAbortController.abort();
|
||||
this.uploadAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,14 +345,25 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
downloadError,
|
||||
selectedFile,
|
||||
uploading,
|
||||
uploadStage,
|
||||
uploadProgress,
|
||||
uploadLoaded,
|
||||
uploadTotal,
|
||||
importPhase,
|
||||
importPhaseCurrent,
|
||||
importPhaseTotal,
|
||||
importItem,
|
||||
statusMessage,
|
||||
importStats,
|
||||
uploadError,
|
||||
uploadSuccess,
|
||||
isDraggingOver,
|
||||
} = this.state;
|
||||
|
||||
const progressPercent =
|
||||
importPhaseTotal > 0
|
||||
? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
|
||||
<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 }}>
|
||||
<CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Daten importieren (Upload)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Große Archive werden direkt per Stream übertragen und blockieren Ihren Browser nicht.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Import
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
@@ -507,6 +499,139 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
</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
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
@@ -517,34 +642,15 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
/>
|
||||
|
||||
{!selectedFile ? (
|
||||
<Box
|
||||
onDragOver={this.handleDragOver}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDrop={this.handleDrop}
|
||||
onClick={() => this.fileInputRef.current?.click()}
|
||||
sx={{
|
||||
border: '2px dashed',
|
||||
borderColor: isDraggingOver ? 'primary.main' : 'divider',
|
||||
bgcolor: isDraggingOver ? 'action.hover' : 'background.default',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 2,
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloudUploadOutlinedIcon sx={{ fontSize: 44, color: 'text.secondary', mb: 1 }} />
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
ZIP-Archiv hier ablegen
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
oder klicken, um eine ZIP-Datei auszuwählen (auch große Archive > 1 GB)
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<InsertDriveFileOutlinedIcon />}
|
||||
onClick={() => this.fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
ZIP-Datei auswählen
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
@@ -560,10 +666,10 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
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" />
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
{selectedFile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -585,22 +691,88 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Real-time Streaming Progress Ticker */}
|
||||
{uploading && (
|
||||
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)})
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={this.handleCancelUpload}
|
||||
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
{uploadStage === 'upload' ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} color="primary.main">
|
||||
Datei wird übertragen…
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={this.handleCancelUpload}
|
||||
sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
|
||||
>
|
||||
Abbrechen
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -617,7 +789,7 @@ export default class ImportExportSettingsPanel extends Component {
|
||||
disabled={uploading || !selectedFile}
|
||||
onClick={this.handleStartUpload}
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Upload starten'}
|
||||
{uploading ? 'Import läuft…' : 'Upload & Import starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onC
|
||||
|
||||
{isExpanded ? (
|
||||
<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.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
|
||||
{automat.error ? (
|
||||
@@ -89,7 +89,7 @@ export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onC
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</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 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
keine
|
||||
|
||||
Reference in New Issue
Block a user