diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1e165f6 --- /dev/null +++ b/eslint.config.js @@ -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', + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 64463f8..cea77cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index f437f7d..2d6d24b 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/components/article/ArticleImageSearchDialog.jsx b/src/components/article/ArticleImageSearchDialog.jsx new file mode 100644 index 0000000..d041a84 --- /dev/null +++ b/src/components/article/ArticleImageSearchDialog.jsx @@ -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} + */ +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 ( + + {suggestions.map((s) => ( + { + this.setState({ query: s.value }, () => this.initSearch()); + }} + sx={{ fontSize: '0.75rem' }} + /> + ))} + + ); + } + + 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 ( + + + + + + Bildsuche: {article?.name || article?.sku || 'Artikel'} + + + + + + + + + {/* Search bar */} + { + e.preventDefault(); + this.initSearch(); + }} + sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }} + > + this.setState({ query: e.target.value })} + onKeyDown={this.handleQueryKeyDown} + placeholder="z. B. Produktname, Marke oder EAN" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + {/* Search suggestions */} + {this.renderSuggestionChips()} + + {/* User helper tip */} + {images.length > 0 && !error && ( + + + + Klicken Sie auf ein Bild, um es herunterzuladen, als AVIF zu optimieren und zum Artikel hinzuzufügen. + + + )} + + {/* Progress / Status banner */} + {status === 'loading' && } + + {status === 'streaming' && ( + + + + Bilder werden geladen... + + + {loadedThumbs} / {images.length} + + + 0 + ? Math.round((loadedThumbs / images.length) * 100) + : 0 + } + sx={{ borderRadius: 1 }} + /> + + )} + + {/* Error banner */} + {error && ( + + + {error} + + {isSerperKeyError && ( + + Hinweis: Sie können Ihren Serper API-Key im Hauptmenü unter{' '} + Firmeneinstellungen → Einstellungen (Serper API-Key) hinterlegen. + + )} + + )} + + {/* Empty state when done */} + {status === 'done' && images.length === 0 && !error && ( + + + + Keine Bilder zu „{query}“ gefunden. + + + )} + + {/* Image Grid */} + {images.length > 0 && ( + + {images.map((img) => { + const isThisProcessing = processingId === img.id; + const isOtherProcessing = Boolean(processingId) && !isThisProcessing; + + return ( + 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 ? ( + + ) : img.failed ? ( + + + + ) : ( + + )} + + {/* Dimensions badge */} + {img.width && img.height ? ( + + ) : null} + + {/* Quick selection hover pill */} + {!processingId && ( + + + + Bild hinzufügen + + + )} + + {/* Processing overlay for selected card */} + {isThisProcessing && ( + + + + {processingStep || 'Verarbeite Bild...'} + + + )} + + {/* Quick actions top-right overlay on hover */} + {!processingId && ( + + + this.handleCopyUrl(img.url, e)} + sx={{ + p: 0.4, + bgcolor: 'rgba(255, 255, 255, 0.85)', + backdropFilter: 'blur(2px)', + boxShadow: 1, + '&:hover': { bgcolor: '#fff' }, + }} + > + + + + + this.handleOpenUrl(img.url, e)} + sx={{ + p: 0.4, + bgcolor: 'rgba(255, 255, 255, 0.85)', + backdropFilter: 'blur(2px)', + boxShadow: 1, + '&:hover': { bgcolor: '#fff' }, + }} + > + + + + + )} + + ); + })} + + )} + + + + + {images.length > 0 && ( + + {images.length} Ergebnisse + + )} + + + + + ); + } +} diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index 016a501..c0ae9be 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -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, + }} > - { - 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 { )} - - - + + + + @@ -271,6 +326,12 @@ class ArticlePhotosDialog extends Component { hidden onChange={this.handleFileChange} /> + ); } diff --git a/src/components/article/index.js b/src/components/article/index.js index da06dec..7169ba8 100644 --- a/src/components/article/index.js +++ b/src/components/article/index.js @@ -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'; diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 72c0ba9..0a77280 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -446,6 +446,7 @@ class ManifestForm extends PureComponent { partyId, number, externalNumber, + lines, saving, pdfUploading, pdfDialogOpen, diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index 4ae5494..516c93c 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -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'; diff --git a/src/components/settings/PrinterSettingsPanel.jsx b/src/components/settings/PrinterSettingsPanel.jsx index 452b77b..019a1fc 100644 --- a/src/components/settings/PrinterSettingsPanel.jsx +++ b/src/components/settings/PrinterSettingsPanel.jsx @@ -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 { - Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl. + Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl, ipp. 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) {