This commit is contained in:
seb
2026-09-27 17:18:11 +02:00
parent 0f875f347e
commit f9383e717f
13 changed files with 1034 additions and 110 deletions

51
eslint.config.js Normal file
View File

@@ -0,0 +1,51 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
ignores: [
'dist/**',
'dist-ssr/**',
'node_modules/**',
'certs/**',
'tmp/**',
'.dashfront-token',
],
},
js.configs.recommended,
{
files: ['**/*.{js,jsx,mjs}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.node,
React: 'readonly',
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^(React|_)',
ignoreRestSiblings: true,
},
],
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-debugger': 'warn',
},
},
];

107
package-lock.json generated
View File

@@ -28,9 +28,12 @@
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-react": "6.1.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"qrcode": "^1.5.4",
"vite": "^8.3.0",
"vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0"
}
},
@@ -1858,6 +1861,27 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
@@ -4206,6 +4230,26 @@
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
@@ -4694,6 +4738,19 @@
"node": ">=10.13.0"
}
},
"node_modules/globals": {
"version": "17.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -4835,6 +4892,23 @@
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -7461,16 +7535,16 @@
}
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"rolldown": "~1.2.9",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -8099,6 +8173,29 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}
}
}

View File

@@ -23,7 +23,6 @@
"@mui/material": "^9.4.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
@@ -35,9 +34,13 @@
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-react": "6.1.1",
"eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"qrcode": "^1.5.4",
"vite": "^8.3.0",
"vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0"
}
}

View File

@@ -0,0 +1,775 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import LinearProgress from '@mui/material/LinearProgress';
import Skeleton from '@mui/material/Skeleton';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
import { enqueueSnackbar } from 'notistack';
import { serper } from '../../api';
import { StorageManagerContext } from '../../api/storageManagerContext';
import artikelAutomat from '../../automats/artikelAutomat';
import { articleWithUploadedPhoto } from '../../utils/articleDetail';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { requireObject } from '../../utils/apiShape';
/**
* Downloads image as a Blob, trying full resolution with CORS first,
* falling back to canvas crossOrigin draw, and finally the Serper SSE dataUrl.
* @param {string} url
* @param {string|null} fallbackDataUrl
* @returns {Promise<Blob>}
*/
async function downloadImageBlob(url, fallbackDataUrl) {
// Strategy 1: Direct fetch with CORS
if (url) {
try {
const resp = await fetch(url, { mode: 'cors' });
if (resp.ok) {
const blob = await resp.blob();
if (blob && blob.type.startsWith('image/')) {
return blob;
}
}
} catch {
// CORS or network failure
}
}
// Strategy 2: Attempt loading with HTMLImageElement crossOrigin
if (url) {
try {
const blob = await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas 2D-Kontext nicht verfügbar'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((b) => {
if (b) resolve(b);
else reject(new Error('Canvas toBlob fehlgeschlagen'));
}, 'image/png');
} catch (err) {
reject(err);
}
};
img.onerror = reject;
img.src = url;
});
if (blob) return blob;
} catch {
// Canvas tainted or image load failed
}
}
// Strategy 3: Streamed WebP/JPEG dataUrl from Serper SSE
if (fallbackDataUrl) {
const resp = await fetch(fallbackDataUrl);
const blob = await resp.blob();
if (blob) return blob;
}
throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).');
}
function computeDefaultQuery(article) {
if (!article) return '';
const brandName = article.brand?.name || (typeof article.brand === 'string' ? article.brand : '');
const articleName = String(article.name ?? '').trim();
if (brandName && articleName) {
if (!articleName.toLowerCase().includes(brandName.toLowerCase())) {
return `${brandName} ${articleName}`.trim();
}
return articleName;
}
return articleName || String(article.sku ?? '').trim();
}
/**
* Dialog that searches Google Images via Serper.dev SSE stream for article photos.
*/
export default class ArticleImageSearchDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
query: computeDefaultQuery(props.article),
status: 'idle', // 'idle' | 'loading' | 'streaming' | 'done' | 'error'
error: null,
images: [],
loadedThumbs: 0,
processingId: null,
processingStep: '',
};
this.abortController = null;
}
componentDidMount() {
if (this.props.open) {
this.initSearch();
}
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const nextQuery = computeDefaultQuery(this.props.article);
this.setState(
{
query: nextQuery,
status: 'idle',
error: null,
images: [],
loadedThumbs: 0,
processingId: null,
processingStep: '',
},
() => {
this.initSearch();
},
);
} else if (!this.props.open && prevProps.open) {
this.abortCurrentSearch();
}
}
componentWillUnmount() {
this.abortCurrentSearch();
}
abortCurrentSearch() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
initSearch = () => {
const { query } = this.state;
if (!query.trim()) return;
this.abortCurrentSearch();
const abortController = new AbortController();
this.abortController = abortController;
this.setState({
status: 'loading',
error: null,
images: [],
loadedThumbs: 0,
});
void (async () => {
try {
const stream = serper.searchImages(
{ q: query.trim(), gl: 'de', hl: 'de', num: 100, preferSource: true },
{ signal: abortController.signal },
);
for await (const chunk of stream) {
if (abortController.signal.aborted) return;
const { event, data } = chunk;
if (event === 'images') {
const rawImages = Array.isArray(data?.images) ? data.images : [];
const initialList = rawImages.map((img) => ({
id: img.id,
url: img.url,
width: img.width,
height: img.height,
dataUrl: null,
failed: false,
}));
this.setState({
images: initialList,
status: initialList.length === 0 ? 'done' : 'streaming',
});
} else if (event === 'thumb') {
const { id, dataUrl } = data || {};
this.setState((prev) => {
const nextImages = prev.images.map((img) => {
if (img.id === id) {
return {
...img,
dataUrl: dataUrl || null,
failed: !dataUrl,
};
}
return img;
});
return {
images: nextImages,
loadedThumbs: prev.loadedThumbs + 1,
};
});
} else if (event === 'done') {
this.setState({ status: 'done' });
} else if (event === 'error') {
this.setState({
status: 'error',
error: data?.error || 'Fehler bei der Bildersuche',
});
}
}
} catch (err) {
if (abortController.signal.aborted) return;
this.setState({
status: 'error',
error: err?.message || String(err),
});
}
})();
};
handleQueryKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.initSearch();
}
};
handleCopyUrl = (url, e) => {
e?.stopPropagation();
if (!url) return;
navigator.clipboard?.writeText(url);
enqueueSnackbar('Bild-URL in Zwischenablage kopiert.', { variant: 'info' });
};
handleOpenUrl = (url, e) => {
e?.stopPropagation();
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
};
handleSelectImage = async (img) => {
const { article: articleData, onClose, onArticleUpdated } = this.props;
if (!articleData?.sku) {
enqueueSnackbar('Kein Artikel ausgewählt.', { variant: 'error' });
return;
}
if (this.state.processingId) return;
this.setState({
processingId: img.id,
processingStep: 'Herunterladen...',
});
try {
// 1. Download image as Blob
const blob = await downloadImageBlob(img.url, img.dataUrl);
// 2. Prepare article photo (scale to max 1000px, encode to AVIF)
this.setState({ processingStep: 'In AVIF konvertieren...' });
const file = await prepareArticlePhoto(blob);
// 3. Upload to backend
this.setState({ processingStep: 'Bild wird gespeichert...' });
const res = await this.context.article.addImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
const updated = articleWithUploadedPhoto(articleData, res.image);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
enqueueSnackbar('Bild erfolgreich hinzugefügt.', { variant: 'success' });
if (onClose) {
onClose();
}
} catch (err) {
console.error('Failed to process and upload article photo:', err);
enqueueSnackbar(`Fehler beim Speichern: ${err?.message || err}`, { variant: 'error' });
this.setState({ processingId: null, processingStep: '' });
}
};
renderSuggestionChips() {
const { article } = this.props;
const { query, status, processingId } = this.state;
if (!article) return null;
const brandName = article.brand?.name || (typeof article.brand === 'string' ? article.brand : '');
const articleName = String(article.name ?? '').trim();
const sku = String(article.sku ?? '').trim();
const firstEan = Array.isArray(article.eans) && article.eans.length > 0 ? String(article.eans[0]).trim() : '';
const suggestions = [];
if (articleName && articleName !== query) {
suggestions.push({ label: articleName, value: articleName });
}
if (brandName && articleName && `${brandName} ${articleName}` !== query) {
suggestions.push({ label: `${brandName} ${articleName}`, value: `${brandName} ${articleName}` });
}
if (sku && sku !== query && sku !== articleName) {
suggestions.push({ label: `SKU: ${sku}`, value: sku });
}
if (firstEan && firstEan !== query) {
suggestions.push({ label: `EAN: ${firstEan}`, value: firstEan });
}
if (suggestions.length === 0) return null;
const disabled = status === 'loading' || status === 'streaming' || Boolean(processingId);
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.5 }}>
{suggestions.map((s) => (
<Chip
key={s.label}
size="small"
label={s.label}
variant="outlined"
clickable={!disabled}
disabled={disabled}
onClick={() => {
this.setState({ query: s.value }, () => this.initSearch());
}}
sx={{ fontSize: '0.75rem' }}
/>
))}
</Box>
);
}
render() {
const { open, onClose, article } = this.props;
const { query, status, error, images, loadedThumbs, processingId, processingStep } = this.state;
const isSearching = status === 'loading' || status === 'streaming';
const isSerperKeyError = error && error.toLowerCase().includes('keys.serper');
return (
<Dialog
open={open}
onClose={processingId ? undefined : onClose}
maxWidth="md"
fullWidth
scroll="paper"
aria-labelledby="article-image-search-dialog-title"
>
<DialogTitle
id="article-image-search-dialog-title"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1.5,
px: 2.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ImageSearchIcon color="primary" sx={{ fontSize: 24 }} />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Bildsuche: {article?.name || article?.sku || 'Artikel'}
</Typography>
</Box>
<IconButton
size="small"
onClick={onClose}
disabled={Boolean(processingId)}
aria-label="Schließen"
sx={{ color: 'text.secondary' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2.5 }}>
{/* Search bar */}
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
this.initSearch();
}}
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
>
<TextField
size="small"
fullWidth
value={query}
disabled={isSearching || Boolean(processingId)}
onChange={(e) => this.setState({ query: e.target.value })}
onKeyDown={this.handleQueryKeyDown}
placeholder="z. B. Produktname, Marke oder EAN"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
},
}}
/>
<Button
type="submit"
variant="contained"
disabled={isSearching || !query.trim() || Boolean(processingId)}
startIcon={
isSearching ? (
<CircularProgress size={16} color="inherit" />
) : (
<SearchIcon />
)
}
sx={{ flexShrink: 0 }}
>
Suchen
</Button>
</Box>
{/* Search suggestions */}
{this.renderSuggestionChips()}
{/* User helper tip */}
{images.length > 0 && !error && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 1.5,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
}}
>
<AddPhotoAlternateIcon color="primary" sx={{ fontSize: 17 }} />
<Typography variant="caption" color="text.secondary">
Klicken Sie auf ein Bild, um es herunterzuladen, als AVIF zu optimieren und zum Artikel hinzuzufügen.
</Typography>
</Box>
)}
{/* Progress / Status banner */}
{status === 'loading' && <LinearProgress sx={{ mb: 2, borderRadius: 1 }} />}
{status === 'streaming' && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography variant="caption" color="text.secondary">
Bilder werden geladen...
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{loadedThumbs} / {images.length}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={
images.length > 0
? Math.round((loadedThumbs / images.length) * 100)
: 0
}
sx={{ borderRadius: 1 }}
/>
</Box>
)}
{/* Error banner */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{error}
</Typography>
{isSerperKeyError && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 0.5 }}>
Hinweis: Sie können Ihren Serper API-Key im Hauptmenü unter{' '}
<strong>Firmeneinstellungen → Einstellungen (Serper API-Key)</strong> hinterlegen.
</Typography>
)}
</Alert>
)}
{/* Empty state when done */}
{status === 'done' && images.length === 0 && !error && (
<Box sx={{ textAlign: 'center', py: 6 }}>
<ImageSearchIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Keine Bilder zu „{query}“ gefunden.
</Typography>
</Box>
)}
{/* Image Grid */}
{images.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(auto-fill, minmax(110px, 1fr))',
sm: 'repeat(auto-fill, minmax(130px, 1fr))',
md: 'repeat(auto-fill, minmax(140px, 1fr))',
},
gap: 1.5,
maxHeight: '55vh',
overflowY: 'auto',
p: 0.5,
}}
>
{images.map((img) => {
const isThisProcessing = processingId === img.id;
const isOtherProcessing = Boolean(processingId) && !isThisProcessing;
return (
<Box
key={img.id}
onClick={() => this.handleSelectImage(img)}
sx={{
position: 'relative',
aspectRatio: '1 / 1',
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: isThisProcessing ? 'primary.main' : 'divider',
bgcolor: '#fff',
backgroundImage:
'linear-gradient(45deg, #f0f0f0 25%, transparent 25%), linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f0f0f0 75%), linear-gradient(-45deg, transparent 75%, #f0f0f0 75%)',
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
cursor: processingId ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isOtherProcessing ? 0.45 : 1,
pointerEvents: isOtherProcessing ? 'none' : 'auto',
transition:
'transform 0.15s, box-shadow 0.15s, border-color 0.15s, opacity 0.15s',
'&:hover': {
transform: processingId ? 'none' : 'scale(1.02)',
boxShadow: processingId ? 0 : 3,
borderColor: 'primary.main',
'& .image-overlay-actions': {
opacity: 1,
},
'& .image-overlay-select': {
opacity: 1,
transform: 'translateY(0)',
},
},
}}
>
{img.dataUrl ? (
<Box
component="img"
src={img.dataUrl}
alt={article?.name || 'Bild'}
sx={{
display: 'block',
maxWidth: '92%',
maxHeight: '92%',
objectFit: 'contain',
}}
/>
) : img.failed ? (
<Tooltip title="Vorschau konnte nicht geladen werden">
<BrokenImageIcon sx={{ color: 'text.disabled', fontSize: 28 }} />
</Tooltip>
) : (
<Skeleton
variant="rectangular"
width="100%"
height="100%"
animation="wave"
sx={{ bgcolor: 'action.hover' }}
/>
)}
{/* Dimensions badge */}
{img.width && img.height ? (
<Chip
size="small"
label={`${img.width}×${img.height}`}
sx={{
position: 'absolute',
bottom: 4,
right: 4,
height: 16,
fontSize: '0.62rem',
fontWeight: 600,
bgcolor: 'rgba(0, 0, 0, 0.65)',
color: '#fff',
pointerEvents: 'none',
px: 0.25,
}}
/>
) : null}
{/* Quick selection hover pill */}
{!processingId && (
<Box
className="image-overlay-select"
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
py: 0.6,
px: 0.8,
bgcolor: 'rgba(25, 118, 210, 0.92)',
backdropFilter: 'blur(3px)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
opacity: 0,
transform: 'translateY(100%)',
transition: 'all 0.18s ease-in-out',
pointerEvents: 'none',
}}
>
<CheckCircleIcon sx={{ fontSize: 13 }} />
<Typography
variant="caption"
sx={{ fontWeight: 600, fontSize: '0.68rem', letterSpacing: 0.2 }}
>
Bild hinzufügen
</Typography>
</Box>
)}
{/* Processing overlay for selected card */}
{isThisProcessing && (
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(3px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
zIndex: 3,
textAlign: 'center',
}}
>
<CircularProgress size={24} color="primary" sx={{ mb: 1 }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: 'primary.main',
fontSize: '0.72rem',
display: 'block',
}}
>
{processingStep || 'Verarbeite Bild...'}
</Typography>
</Box>
)}
{/* Quick actions top-right overlay on hover */}
{!processingId && (
<Box
className="image-overlay-actions"
sx={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 0.5,
opacity: 0,
transition: 'opacity 0.15s ease-in-out',
zIndex: 2,
}}
>
<Tooltip title="Bild-URL kopieren" arrow>
<IconButton
size="small"
onClick={(e) => this.handleCopyUrl(img.url, e)}
sx={{
p: 0.4,
bgcolor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(2px)',
boxShadow: 1,
'&:hover': { bgcolor: '#fff' },
}}
>
<ContentCopyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
<Tooltip title="In neuem Tab öffnen" arrow>
<IconButton
size="small"
onClick={(e) => this.handleOpenUrl(img.url, e)}
sx={{
p: 0.4,
bgcolor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(2px)',
boxShadow: 1,
'&:hover': { bgcolor: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
);
})}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{images.length > 0 && (
<Typography variant="caption" color="text.secondary">
{images.length} Ergebnisse
</Typography>
)}
</Box>
<Button
type="button"
onClick={onClose}
disabled={Boolean(processingId)}
color="inherit"
>
Schließen
</Button>
</DialogActions>
</Dialog>
);
}
}

View File

@@ -1,66 +1,16 @@
import { PureComponent, createRef } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import { PureComponent } from 'react';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { PanelTile } from 'dashgui/panels';
import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
import { primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.state = { busy: false, editOpen: false };
this.handlePasteClick = this.handlePasteClick.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
this.state = { editOpen: false };
this.handleEditOpen = this.handleEditOpen.bind(this);
this.handleEditClose = this.handleEditClose.bind(this);
this.uploadRawImage = this.uploadRawImage.bind(this);
}
async uploadRawImage(rawBlob) {
const { article: articleData, onArticleUpdated } = this.props;
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
const uploadStarted = performance.now();
const res = await this.context.article.addImage(articleData.sku, file);
console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size,
sku: articleData.sku,
});
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
handlePasteClick() {
if (this.state.busy) return;
void pasteImageOrFallback(() => {
this.fileInputRef.current?.click();
}).then((image) => {
if (image) void this.uploadRawImage(image);
});
}
handleFileChange(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void this.uploadRawImage(file);
}
handleEditOpen() {
@@ -80,9 +30,8 @@ class ArticlePhotoTile extends PureComponent {
sx,
color,
} = this.props;
const { busy, editOpen } = this.state;
const { editOpen } = this.state;
const photo = primaryPhoto(articleData);
const hasPhoto = photo?.thumbHash != null || photo?.fullHash != null;
return (
<>
@@ -91,31 +40,15 @@ class ArticlePhotoTile extends PureComponent {
minHeight={minHeight}
sx={sx}
color={color}
action={
hasPhoto
? {
label: 'Bilder bearbeiten',
icon: EditOutlinedIcon,
onClick: this.handleEditOpen,
busy: false,
}
: {
label: 'Bild einfügen',
icon: ContentPasteIcon,
onClick: this.handlePasteClick,
busy,
}
}
action={{
label: 'Bilder bearbeiten',
icon: EditOutlinedIcon,
onClick: this.handleEditOpen,
busy: false,
}}
>
<ArticlePhoto photo={photo} />
</PanelTile>
<input
ref={this.fileInputRef}
type="file"
accept="image/*"
hidden
onChange={this.handleFileChange}
/>
<ArticlePhotosDialog
open={editOpen}
article={articleData}
@@ -128,3 +61,4 @@ class ArticlePhotoTile extends PureComponent {
}
export default ArticlePhotoTile;

View File

@@ -8,10 +8,14 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../api/storageManagerContext';
import artikelAutomat from '../../automats/artikelAutomat';
import { requireArray, requireObject } from '../../utils/apiShape';
import {
articleWithPhotoOrder,
@@ -22,9 +26,10 @@ import {
sortedPhotos,
} from '../../utils/articleDetail';
import { notifyError } from '../../utils/notify';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { readClipboardImage } from '../../utils/pasteImage';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import ArticlePhoto from './ArticlePhoto';
import ArticleImageSearchDialog from './ArticleImageSearchDialog';
const overlayChipSx = {
height: 22,
@@ -46,9 +51,12 @@ class ArticlePhotosDialog extends Component {
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.state = { busy: false };
this.state = { busy: false, searchOpen: false };
this.handleClose = this.handleClose.bind(this);
this.handlePasteClick = this.handlePasteClick.bind(this);
this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleSearchOpen = this.handleSearchOpen.bind(this);
this.handleSearchClose = this.handleSearchClose.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
this.uploadRawImage = this.uploadRawImage.bind(this);
}
@@ -72,7 +80,9 @@ class ArticlePhotosDialog extends Component {
});
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
const updated = articleWithUploadedPhoto(articleData, res.image);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
@@ -80,13 +90,33 @@ class ArticlePhotosDialog extends Component {
}
}
handlePasteClick() {
async handlePasteClick() {
if (this.state.busy) return;
void pasteImageOrFallback(() => {
this.fileInputRef.current?.click();
}).then((image) => {
if (image) void this.uploadRawImage(image);
});
try {
const image = await readClipboardImage();
if (image) {
await this.uploadRawImage(image);
} else {
enqueueSnackbar('Kein Bild in der Zwischenablage gefunden.', { variant: 'info' });
}
} catch (err) {
console.warn('Clipboard read error:', err);
enqueueSnackbar('Zugriff auf Zwischenablage nicht möglich.', { variant: 'warning' });
}
}
handleUploadClick() {
if (this.state.busy) return;
this.fileInputRef.current?.click();
}
handleSearchOpen() {
if (this.state.busy) return;
this.setState({ searchOpen: true });
}
handleSearchClose() {
this.setState({ searchOpen: false });
}
handleFileChange(event) {
@@ -101,7 +131,9 @@ class ArticlePhotosDialog extends Component {
this.setState({ busy: true });
try {
await this.context.article.deleteImage(articleData.sku, fullHash);
onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash));
const updated = articleWithoutPhoto(articleData, fullHash);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
@@ -126,7 +158,9 @@ class ArticlePhotosDialog extends Component {
const res = await this.context.article.orderImages(articleData.sku, { fullHashes });
requireObject(res, 'PUT /api/article/:sku/images/order');
requireArray(res.fullHashes, 'fullHashes');
onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes));
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
@@ -250,16 +284,37 @@ class ArticlePhotosDialog extends Component {
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}>
<Button
type="button"
startIcon={<ContentPasteIcon />}
onClick={this.handlePasteClick}
disabled={busy}
>
Bild einfügen
</Button>
<Button type="button" onClick={this.handleClose} disabled={busy}>
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button
type="button"
variant="outlined"
startIcon={<ContentPasteIcon />}
onClick={this.handlePasteClick}
disabled={busy}
>
Bild einfügen
</Button>
<Button
type="button"
variant="outlined"
startIcon={<UploadFileIcon />}
onClick={this.handleUploadClick}
disabled={busy}
>
Bild hochladen
</Button>
<Button
type="button"
variant="contained"
startIcon={<ImageSearchIcon />}
onClick={this.handleSearchOpen}
disabled={busy}
>
Bild suchen
</Button>
</Box>
<Button type="button" onClick={this.handleClose} disabled={busy} color="inherit">
Schließen
</Button>
</DialogActions>
@@ -271,6 +326,12 @@ class ArticlePhotosDialog extends Component {
hidden
onChange={this.handleFileChange}
/>
<ArticleImageSearchDialog
open={this.state.searchOpen}
article={articleData}
onClose={this.handleSearchClose}
onArticleUpdated={this.props.onArticleUpdated}
/>
</>
);
}

View File

@@ -1,5 +1,6 @@
export { default as ArticleView } from './ArticleView';
export { default as ArticleNameDialog } from './ArticleNameDialog';
export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
export { default as ArticleTileTab } from './ArticleTileTab';
export { default as NewArticleTile } from './NewArticleTile';

View File

@@ -446,6 +446,7 @@ class ManifestForm extends PureComponent {
partyId,
number,
externalNumber,
lines,
saving,
pdfUploading,
pdfDialogOpen,

View File

@@ -17,7 +17,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
import { getFirstVal, parseNum } from './manifestConfig';
import { getFirstVal, parseNum, LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice } from '../../utils/price';
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';

View File

@@ -30,7 +30,7 @@ import { requireArray, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
const PRINTER_TYPES = ['esc/pos', 'zpl'];
const PRINTER_TYPES = ['esc/pos', 'zpl', 'ipp'];
const EMPTY_FORM = {
address: '',
@@ -458,7 +458,7 @@ export default class PrinterSettingsPanel extends Component {
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl.
Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl, ipp.
</Typography>
<PrinterFormDialog

View File

@@ -9,6 +9,7 @@ import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { formatPrice } from '../../utils/price';

View File

@@ -113,7 +113,7 @@ export function autoCropWhiteAndTransparent(img) {
imgData = ctx.getImageData(0, 0, width, height);
} catch (err) {
// Canvas tainted by CORS, throw to let caller know
throw new Error('Canvas durch CORS blockiert: ' + (err?.message || err));
throw new Error('Canvas durch CORS blockiert: ' + (err?.message || err), { cause: err });
}
const data = imgData.data;

View File

@@ -87,7 +87,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
let status = 'unbooked';
let status;
if (totalPositions === 0) {
status = 'empty';
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {