Compare commits
2 Commits
3ab9d904e6
...
0f875f347e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f875f347e | ||
|
|
559307675c |
7
package-lock.json
generated
7
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@shopify/draggable": "^1.2.1",
|
"@shopify/draggable": "^1.2.1",
|
||||||
"dashgui": "file:../dashgui",
|
"dashgui": "file:../dashgui",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.11.0",
|
||||||
|
"jsbarcode": "^3.12.3",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -5415,6 +5416,12 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsbarcode": {
|
||||||
|
"version": "3.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsbarcode/-/jsbarcode-3.12.3.tgz",
|
||||||
|
"integrity": "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@shopify/draggable": "^1.2.1",
|
"@shopify/draggable": "^1.2.1",
|
||||||
"dashgui": "file:../dashgui",
|
"dashgui": "file:../dashgui",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.11.0",
|
||||||
|
"jsbarcode": "^3.12.3",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
|
|||||||
@@ -442,6 +442,19 @@ export class ApiClient {
|
|||||||
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.printer = {
|
||||||
|
/** GET /api/printer → { printers: Printer[] } */
|
||||||
|
list: () => this.#json('GET', '/api/printer'),
|
||||||
|
/** POST /api/printer → 201 { printer } */
|
||||||
|
create: (body) => this.#json('POST', '/api/printer', { body }),
|
||||||
|
/** PATCH /api/printer/{id} → { printer } */
|
||||||
|
update: (id, body) => this.#json('PATCH', `/api/printer/${enc(id)}`, { body }),
|
||||||
|
/** DELETE /api/printer/{id} → 204 */
|
||||||
|
delete: (id) => this.#empty('DELETE', `/api/printer/${enc(id)}`),
|
||||||
|
/** PUT /api/printer/order body { printerIds } → { printerIds } */
|
||||||
|
order: (body) => this.#json('PUT', '/api/printer/order', { body }),
|
||||||
|
};
|
||||||
|
|
||||||
this.setting = {
|
this.setting = {
|
||||||
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
||||||
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
||||||
|
|||||||
134
src/api/index.js
134
src/api/index.js
@@ -581,6 +581,35 @@ export const brand = {
|
|||||||
upsert(body) {
|
upsert(body) {
|
||||||
return http('/api/brand', { method: 'PUT', body });
|
return http('/api/brand', { method: 'PUT', body });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/brand/:id
|
||||||
|
* → 204 No Content · 404 Brand not found
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/brand/:id/image
|
||||||
|
* Accepts full-size AVIF body (Content-Type: image/avif).
|
||||||
|
* → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }
|
||||||
|
*/
|
||||||
|
addImage(brandId, file) {
|
||||||
|
return httpBinary(`/api/brand/${brandId}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'image/avif',
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/brand/:id/image
|
||||||
|
* → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } }
|
||||||
|
*/
|
||||||
|
deleteImage(brandId) {
|
||||||
|
return http(`/api/brand/${brandId}/image`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -834,6 +863,111 @@ export const delivery = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serper Google Images search and WebP thumbnail streaming.
|
||||||
|
*/
|
||||||
|
export const serper = {
|
||||||
|
/**
|
||||||
|
* POST /api/serper/images
|
||||||
|
* Body: { q, gl, hl, num, preferSource }
|
||||||
|
* Streams SSE events: images, thumb, done, error
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.q - Search query
|
||||||
|
* @param {string} [params.gl='de'] - Country code
|
||||||
|
* @param {string} [params.hl='de'] - Language code
|
||||||
|
* @param {number} [params.num=100] - Result count (1-100)
|
||||||
|
* @param {boolean} [params.preferSource=false] - Prefer high-res source image
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {AbortSignal} [options.signal] - Abort controller signal
|
||||||
|
* @returns {AsyncGenerator<{event: string, data: any}>}
|
||||||
|
*/
|
||||||
|
async *searchImages(
|
||||||
|
{ q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {},
|
||||||
|
{ signal } = {},
|
||||||
|
) {
|
||||||
|
const res = await fetch('/api/serper/images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ q, gl, hl, num, preferSource }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
message = data.error || data.message || message;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const err = new Error(message || `Request failed (${res.status})`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
let currentEvent = 'message';
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
try {
|
||||||
|
reader.cancel();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
currentEvent = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
const raw = line.slice(5).trim();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
data = raw;
|
||||||
|
}
|
||||||
|
yield { event: currentEvent, data };
|
||||||
|
currentEvent = 'message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal?.aborted || err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (signal) {
|
||||||
|
signal.removeEventListener('abort', onAbort);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append-only stock and payment ledger.
|
* Append-only stock and payment ledger.
|
||||||
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
|
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
|
|||||||
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
|
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
|
||||||
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
|
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
|
||||||
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
||||||
|
const ArtikelMarkenPage = lazy(() => import('./pages/artikel/marken'));
|
||||||
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
||||||
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
|
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
|
||||||
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
|
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
|
||||||
@@ -53,6 +54,7 @@ const routes = [
|
|||||||
{ index: true, element: <ArtikelUebersichtPage /> },
|
{ index: true, element: <ArtikelUebersichtPage /> },
|
||||||
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
|
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
|
||||||
{ path: 'lager', element: <ArtikelLagerPage /> },
|
{ path: 'lager', element: <ArtikelLagerPage /> },
|
||||||
|
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
||||||
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,37 @@ const actions = {
|
|||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a brand by id.
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
async remove(id) {
|
||||||
|
await brandApi.delete(id);
|
||||||
|
const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
|
||||||
|
brandAutomat.setState({ brands });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add / update brand image.
|
||||||
|
* @param {number} id
|
||||||
|
* @param {Blob|File} file
|
||||||
|
*/
|
||||||
|
async addImage(id, file) {
|
||||||
|
const res = await brandApi.addImage(id, file);
|
||||||
|
await brandAutomat.actions.load();
|
||||||
|
return res.brand;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete brand image.
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
async deleteImage(id) {
|
||||||
|
const res = await brandApi.deleteImage(id);
|
||||||
|
await brandAutomat.actions.load();
|
||||||
|
return res.brand;
|
||||||
|
},
|
||||||
|
|
||||||
/** Reset to idle state (called on logout / company switch). */
|
/** Reset to idle state (called on logout / company switch). */
|
||||||
reset() {
|
reset() {
|
||||||
brandAutomat.setState(initialState);
|
brandAutomat.setState(initialState);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from 'react';
|
|||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
@@ -13,14 +14,26 @@ import InputAdornment from '@mui/material/InputAdornment';
|
|||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import BusinessIcon from '@mui/icons-material/Business';
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
|
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||||
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import { company } from '../api';
|
import { company, number as numberApi } from '../api';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||||
import authAutomat from '../automats/authAutomat';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +43,67 @@ import {
|
|||||||
} from '../utils/companyForm';
|
} from '../utils/companyForm';
|
||||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
|
|
||||||
|
const NUMBER_SCHEMES = [
|
||||||
|
{ type: 'sku', label: 'Artikel-SKU', icon: Inventory2Icon },
|
||||||
|
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
||||||
|
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
||||||
|
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{
|
||||||
|
id: 'standard',
|
||||||
|
label: 'Standard',
|
||||||
|
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-)',
|
||||||
|
values: {
|
||||||
|
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
||||||
|
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
||||||
|
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
||||||
|
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'numeric',
|
||||||
|
label: 'Rein numerisch',
|
||||||
|
hint: 'Ohne Präfix, mit Nummernblöcken (10001, 20001…)',
|
||||||
|
values: {
|
||||||
|
sku: { prefix: '', padding: 5, next: 10001 },
|
||||||
|
entity: { prefix: '', padding: 5, next: 20001 },
|
||||||
|
delivery: { prefix: '', padding: 5, next: 30001 },
|
||||||
|
adjustment: { prefix: '', padding: 5, next: 90001 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compact',
|
||||||
|
label: 'Kurzkürzel',
|
||||||
|
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-)',
|
||||||
|
values: {
|
||||||
|
sku: { prefix: 'A-', padding: 5, next: 1 },
|
||||||
|
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
||||||
|
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
||||||
|
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'international',
|
||||||
|
label: 'International',
|
||||||
|
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-)',
|
||||||
|
values: {
|
||||||
|
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
||||||
|
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
||||||
|
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
||||||
|
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatNumberPreview(prefix, padding, next, offset = 0) {
|
||||||
|
const pad = Math.max(0, Number(padding) || 0);
|
||||||
|
const num = Math.max(0, (Number(next) || 1) + offset);
|
||||||
|
const numStr = pad > 0 ? String(num).padStart(pad, '0') : String(num);
|
||||||
|
return `${prefix ?? ''}${numStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
class CompanySetupDialog extends Component {
|
class CompanySetupDialog extends Component {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
@@ -44,11 +118,15 @@ class CompanySetupDialog extends Component {
|
|||||||
iso639: defaultIso639(),
|
iso639: defaultIso639(),
|
||||||
taxrate: '19',
|
taxrate: '19',
|
||||||
reducedtaxrate: '7',
|
reducedtaxrate: '7',
|
||||||
|
selectedPreset: 'standard',
|
||||||
|
numbers: { ...PRESETS[0].values },
|
||||||
};
|
};
|
||||||
this.handleSelect = this.handleSelect.bind(this);
|
this.handleSelect = this.handleSelect.bind(this);
|
||||||
this.handleCreate = this.handleCreate.bind(this);
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
this.handleLogout = this.handleLogout.bind(this);
|
||||||
this.handleClose = this.handleClose.bind(this);
|
this.handleClose = this.handleClose.bind(this);
|
||||||
|
this.applyPreset = this.applyPreset.bind(this);
|
||||||
|
this.handleNumberChange = this.handleNumberChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -79,9 +157,31 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPreset(presetId) {
|
||||||
|
const preset = PRESETS.find((p) => p.id === presetId);
|
||||||
|
if (!preset) return;
|
||||||
|
this.setState({
|
||||||
|
selectedPreset: presetId,
|
||||||
|
numbers: { ...preset.values },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNumberChange(type, field, val) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
selectedPreset: 'custom',
|
||||||
|
numbers: {
|
||||||
|
...prev.numbers,
|
||||||
|
[type]: {
|
||||||
|
...prev.numbers[type],
|
||||||
|
[field]: val,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async handleCreate(event) {
|
async handleCreate(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const { name, iso639, taxrate, reducedtaxrate } = this.state;
|
const { name, iso639, taxrate, reducedtaxrate, numbers } = this.state;
|
||||||
const parsedTaxrate = parseTaxRate(taxrate);
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
const canCreate =
|
const canCreate =
|
||||||
@@ -103,8 +203,29 @@ class CompanySetupDialog extends Component {
|
|||||||
taxrate: parsedTaxrate,
|
taxrate: parsedTaxrate,
|
||||||
reducedtaxrate: parsedReducedtaxrate,
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply initial number sequences if set
|
||||||
|
if (numbers) {
|
||||||
|
try {
|
||||||
|
const listRes = await numberApi.list();
|
||||||
|
const serverNumbers = listRes?.numbers ?? [];
|
||||||
|
for (const serverRow of serverNumbers) {
|
||||||
|
const userConfig = numbers[serverRow.type];
|
||||||
|
if (userConfig) {
|
||||||
|
const prefix = String(userConfig.prefix ?? '');
|
||||||
|
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||||
|
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||||
|
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (numErr) {
|
||||||
|
console.warn('Initial number sequences could not be saved:', numErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
await this.context.updateCompany();
|
await this.context.updateCompany();
|
||||||
|
await this.context.updateNumber?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -126,8 +247,18 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
|
const {
|
||||||
this.state;
|
companies,
|
||||||
|
loading,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
name,
|
||||||
|
iso639,
|
||||||
|
taxrate,
|
||||||
|
reducedtaxrate,
|
||||||
|
selectedPreset,
|
||||||
|
numbers,
|
||||||
|
} = this.state;
|
||||||
const hasCompanies = companies.length > 0;
|
const hasCompanies = companies.length > 0;
|
||||||
const parsedTaxrate = parseTaxRate(taxrate);
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
@@ -138,20 +269,25 @@ class CompanySetupDialog extends Component {
|
|||||||
parsedReducedtaxrate != null;
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth>
|
<Dialog
|
||||||
<DialogTitle>
|
open
|
||||||
|
onClose={this.handleClose}
|
||||||
|
maxWidth={hasCompanies ? 'sm' : 'md'}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
{loading
|
{loading
|
||||||
? 'Unternehmen'
|
? 'Unternehmen'
|
||||||
: hasCompanies
|
: hasCompanies
|
||||||
? 'Unternehmen auswählen'
|
? 'Unternehmen auswählen'
|
||||||
: 'Unternehmen anlegen'}
|
: 'Willkommen bei Dash'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!loading ? (
|
{!loading ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
|
||||||
{hasCompanies
|
{hasCompanies
|
||||||
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
|
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
|
||||||
: 'Legen Sie Ihr Unternehmen an, um fortzufahren.'}
|
: 'Legen Sie Ihr erstes Unternehmen an und wählen Sie das Format für Ihre Nummernkreise.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -191,7 +327,7 @@ class CompanySetupDialog extends Component {
|
|||||||
{hasCompanies ? <Divider>oder</Divider> : null}
|
{hasCompanies ? <Divider>oder</Divider> : null}
|
||||||
|
|
||||||
<Box component="form" onSubmit={this.handleCreate} noValidate>
|
<Box component="form" onSubmit={this.handleCreate} noValidate>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2.5}>
|
||||||
{hasCompanies ? (
|
{hasCompanies ? (
|
||||||
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
|
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -200,7 +336,7 @@ class CompanySetupDialog extends Component {
|
|||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
size="small"
|
size="small"
|
||||||
label="Name"
|
label="Unternehmensname"
|
||||||
value={name}
|
value={name}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => this.setState({ name: e.target.value })}
|
onChange={(e) => this.setState({ name: e.target.value })}
|
||||||
@@ -225,7 +361,7 @@ class CompanySetupDialog extends Component {
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={2}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
@@ -262,8 +398,143 @@ class CompanySetupDialog extends Component {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
|
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
||||||
Anlegen
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||||
|
Nummernkreise & Belegnummern
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
|
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 }}>
|
||||||
|
{PRESETS.map((p) => {
|
||||||
|
const isSelected = selectedPreset === p.id;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={p.id}
|
||||||
|
size="small"
|
||||||
|
variant={isSelected ? 'contained' : 'outlined'}
|
||||||
|
color={isSelected ? 'primary' : 'inherit'}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => this.applyPreset(p.id)}
|
||||||
|
startIcon={isSelected ? <AutoAwesomeIcon sx={{ fontSize: 16 }} /> : null}
|
||||||
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedPreset === 'custom' ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Benutzerdefiniert"
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 30, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1.5 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Bereich</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, width: 130 }}>Präfix</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, width: 90 }}>Stellen</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, width: 110 }}>Startnr.</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Vorschau</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{NUMBER_SCHEMES.map(({ type, label, icon: Icon }) => {
|
||||||
|
const cfg = numbers[type] || { prefix: '', padding: 5, next: 1 };
|
||||||
|
return (
|
||||||
|
<TableRow key={type} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Icon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={cfg.prefix}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleNumberChange(type, 'prefix', e.target.value)}
|
||||||
|
placeholder="z.B. ART-"
|
||||||
|
sx={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={cfg.padding}
|
||||||
|
disabled={busy}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 20, step: 1 } }}
|
||||||
|
onChange={(e) => this.handleNumberChange(type, 'padding', e.target.value)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={cfg.next}
|
||||||
|
disabled={busy}
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
|
onChange={(e) => this.handleNumberChange(type, 'next', e.target.value)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 0)}
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
bgcolor: 'primary.50',
|
||||||
|
color: 'primary.dark',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'primary.200',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
→
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 1)}
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={busy || !canCreate}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Unternehmen anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -287,3 +558,4 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default CompanySetupDialog;
|
export default CompanySetupDialog;
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,34 @@ import { useEffect, useRef } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { thumbHashToRGBA } from 'thumbhash';
|
import { thumbHashToRGBA } from 'thumbhash';
|
||||||
|
|
||||||
function decodeThumbHash(thumbHash) {
|
export function decodeThumbHash(thumbHash) {
|
||||||
|
if (!thumbHash || typeof thumbHash !== 'string') return null;
|
||||||
|
try {
|
||||||
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
|
||||||
return thumbHashToRGBA(bytes);
|
return thumbHashToRGBA(bytes);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ThumbHashImage] decode failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintThumbHash(canvas, thumbHash) {
|
export function getThumbHashDimensions(thumbHash) {
|
||||||
|
const decoded = decodeThumbHash(thumbHash);
|
||||||
|
if (!decoded) return null;
|
||||||
|
return { w: decoded.w, h: decoded.h };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintThumbHash(canvas, thumbHash, explicitWidth, explicitHeight) {
|
||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
if (!parent) return;
|
const rect = parent ? parent.getBoundingClientRect() : null;
|
||||||
|
const width = explicitWidth || rect?.width;
|
||||||
|
const height = explicitHeight || rect?.height;
|
||||||
|
if (!width || !height) return;
|
||||||
|
|
||||||
const { width, height } = parent.getBoundingClientRect();
|
const decoded = decodeThumbHash(thumbHash);
|
||||||
if (width === 0 || height === 0) return;
|
if (!decoded) return;
|
||||||
|
const { w, h, rgba } = decoded;
|
||||||
|
|
||||||
const { w, h, rgba } = decodeThumbHash(thumbHash);
|
|
||||||
const src = document.createElement('canvas');
|
const src = document.createElement('canvas');
|
||||||
src.width = w;
|
src.width = w;
|
||||||
src.height = h;
|
src.height = h;
|
||||||
@@ -34,24 +49,24 @@ function paintThumbHash(canvas, thumbHash) {
|
|||||||
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThumbHashImage({ thumbHash, sx }) {
|
export default function ThumbHashImage({ thumbHash, width, height, sx }) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !thumbHash) return;
|
if (!canvas || !thumbHash) return;
|
||||||
|
|
||||||
paintThumbHash(canvas, thumbHash);
|
paintThumbHash(canvas, thumbHash, width, height);
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
if (!parent || typeof ResizeObserver === 'undefined') return undefined;
|
if (!parent || typeof ResizeObserver === 'undefined') return undefined;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
paintThumbHash(canvas, thumbHash);
|
paintThumbHash(canvas, thumbHash, width, height);
|
||||||
});
|
});
|
||||||
observer.observe(parent);
|
observer.observe(parent);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [thumbHash]);
|
}, [thumbHash, width, height]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { EditablePanelTile } from 'dashgui/panels';
|
|||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import { BrandLabel, BrandThumb } from '../../brand';
|
||||||
|
|
||||||
class PanelValue extends Component {
|
class PanelValue extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { label, children } = this.props;
|
const { label, children } = this.props;
|
||||||
@@ -27,7 +30,12 @@ class PanelValue extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function brandDraftValue(article) {
|
function brandDraftValue(article) {
|
||||||
return { name: article?.brand?.name ?? '' };
|
return {
|
||||||
|
name: article?.brand?.name ?? '',
|
||||||
|
thumbHash: article?.brand?.thumbHash ?? null,
|
||||||
|
thumbW: article?.brand?.thumbW ?? null,
|
||||||
|
thumbH: article?.brand?.thumbH ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function brandDraftDirty(draft, saved) {
|
function brandDraftDirty(draft, saved) {
|
||||||
@@ -43,6 +51,11 @@ const SECOND_ROW_MIN_HEIGHT = 220;
|
|||||||
class BrandEdit extends Component {
|
class BrandEdit extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { draft, onChange, inputRef, brands } = this.props;
|
const { draft, onChange, inputRef, brands } = this.props;
|
||||||
|
const trimmed = String(draft.name ?? '').trim().toLowerCase();
|
||||||
|
const matchingBrand = brands.find(
|
||||||
|
(b) => String(b.name ?? '').toLowerCase() === trimmed,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -57,6 +70,21 @@ class BrandEdit extends Component {
|
|||||||
onChange={(_event, option) => {
|
onChange={(_event, option) => {
|
||||||
onChange({ name: option ? String(option.name ?? '') : '' });
|
onChange({ name: option ? String(option.name ?? '') : '' });
|
||||||
}}
|
}}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
const name = typeof option === 'string' ? option : String(option.name ?? '');
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={key}
|
||||||
|
{...optionProps}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<BrandThumb brand={option} size={20} />
|
||||||
|
<span>{name}</span>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
@@ -65,6 +93,16 @@ class BrandEdit extends Component {
|
|||||||
size="small"
|
size="small"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...params.slotProps,
|
...params.slotProps,
|
||||||
|
input: {
|
||||||
|
...params.slotProps?.input,
|
||||||
|
startAdornment: matchingBrand ? (
|
||||||
|
<InputAdornment position="start" sx={{ mr: 0.5 }}>
|
||||||
|
<BrandThumb brand={matchingBrand} size={20} />
|
||||||
|
</InputAdornment>
|
||||||
|
) : (
|
||||||
|
params.slotProps?.input?.startAdornment
|
||||||
|
),
|
||||||
|
},
|
||||||
inputLabel: {
|
inputLabel: {
|
||||||
...params.slotProps?.inputLabel,
|
...params.slotProps?.inputLabel,
|
||||||
shrink: true,
|
shrink: true,
|
||||||
@@ -152,8 +190,19 @@ export default class ArticleViewBrand extends PureComponent {
|
|||||||
onSave={this.handleSave}
|
onSave={this.handleSave}
|
||||||
onError={notifyError}
|
onError={notifyError}
|
||||||
isDirty={brandDraftDirty}
|
isDirty={brandDraftDirty}
|
||||||
|
display={
|
||||||
|
<PanelValue>
|
||||||
|
{value.name ? (
|
||||||
|
<BrandLabel
|
||||||
|
brand={articleData?.brand || value}
|
||||||
|
name={value.name}
|
||||||
|
size={22}
|
||||||
|
typographyProps={{ variant: 'body2', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</PanelValue>
|
||||||
|
}
|
||||||
canSave={brandDraftValid}
|
canSave={brandDraftValid}
|
||||||
display={<PanelValue>{value.name || null}</PanelValue>}
|
|
||||||
edit={({ draft, onChange, inputRef }) => (
|
edit={({ draft, onChange, inputRef }) => (
|
||||||
<BrandEdit
|
<BrandEdit
|
||||||
brands={brands}
|
brands={brands}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { StorageManagerContext } from '../../../api/storageManagerContext';
|
|||||||
import { requireObject } from '../../../utils/apiShape';
|
import { requireObject } from '../../../utils/apiShape';
|
||||||
import { articleEans } from '../../../utils/articleDetail';
|
import { articleEans } from '../../../utils/articleDetail';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
|
import JsBarcode from 'jsbarcode';
|
||||||
class PanelValue extends Component {
|
class PanelValue extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { label, children } = this.props;
|
const { label, children } = this.props;
|
||||||
@@ -41,20 +42,63 @@ function eansDraftValid(draft) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SkuBarcode extends PureComponent {
|
||||||
|
componentDidMount() {
|
||||||
|
this.renderBarcode();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
this.renderBarcode();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBarcode() {
|
||||||
|
if (!this._svg || !this.props.value) return;
|
||||||
|
try {
|
||||||
|
JsBarcode(this._svg, String(this.props.value).trim(), {
|
||||||
|
format: 'CODE128',
|
||||||
|
width: 1.5,
|
||||||
|
height: 38,
|
||||||
|
displayValue: false,
|
||||||
|
margin: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
lineColor: '#1e293b',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Barcode render error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { value } = this.props;
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ my: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<svg
|
||||||
|
ref={(el) => {
|
||||||
|
this._svg = el;
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SECOND_ROW_MIN_HEIGHT = 220;
|
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
class EansDisplay extends Component {
|
class EansDisplay extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { sku, eans } = this.props;
|
const { sku, eans } = this.props;
|
||||||
return (
|
return (
|
||||||
<>
|
<Box sx={{ width: '100%' }}>
|
||||||
<PanelValue label="SKU">{sku}</PanelValue>
|
<PanelValue label="SKU">{sku}</PanelValue>
|
||||||
|
<SkuBarcode value={sku} />
|
||||||
{(eans ?? []).map((ean) => (
|
{(eans ?? []).map((ean) => (
|
||||||
<PanelValue key={ean} label="EAN">
|
<PanelValue key={ean} label="EAN">
|
||||||
{ean}
|
{ean}
|
||||||
</PanelValue>
|
</PanelValue>
|
||||||
))}
|
))}
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +109,8 @@ class EansEdit extends Component {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<PanelValue label="SKU">{sku}</PanelValue>
|
<PanelValue label="SKU">{sku}</PanelValue>
|
||||||
<Box sx={{ mt: 3 }}>
|
<SkuBarcode value={sku} />
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
fullWidth
|
fullWidth
|
||||||
multiple
|
multiple
|
||||||
|
|||||||
623
src/components/brand/BrandImageSearchDialog.jsx
Normal file
623
src/components/brand/BrandImageSearchDialog.jsx
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
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 AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { serper } from '../../api';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog that searches Google Images via Serper.dev SSE stream for a brand logo.
|
||||||
|
*/
|
||||||
|
export default class BrandImageSearchDialog extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const brandName = props.brand?.name ? String(props.brand.name).trim() : '';
|
||||||
|
this.state = {
|
||||||
|
query: brandName ? `${brandName} logo` : 'logo',
|
||||||
|
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 brandName = this.props.brand?.name ? String(this.props.brand.name).trim() : '';
|
||||||
|
const nextQuery = brandName ? `${brandName} logo` : 'logo';
|
||||||
|
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 },
|
||||||
|
{ 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 { brand, onClose } = this.props;
|
||||||
|
if (!brand?.id) {
|
||||||
|
enqueueSnackbar('Keine Marke ausgewählt.', { variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.state.processingId) return;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
processingId: img.id,
|
||||||
|
processingStep: 'Herunterladen & Freistellen...',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Download, auto white & transparency crop (removing all margins), encode to AVIF
|
||||||
|
const avifFile = await downloadAndPrepareLogoAvif(img.url, img.dataUrl);
|
||||||
|
|
||||||
|
this.setState({ processingStep: 'Logo wird gespeichert...' });
|
||||||
|
|
||||||
|
// 2. Upload as brandpic and update brandAutomat
|
||||||
|
await brandAutomat.actions.addImage(brand.id, avifFile);
|
||||||
|
|
||||||
|
enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to process and upload brand logo:', err);
|
||||||
|
enqueueSnackbar(`Fehler beim Freistellen oder Speichern: ${err?.message || err}`, {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
|
this.setState({ processingId: null, processingStep: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, brand } = 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="brand-image-search-dialog-title"
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
id="brand-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 }}>
|
||||||
|
Logo-Suche: {brand?.name || 'Marke'}
|
||||||
|
</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.5 }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={query}
|
||||||
|
disabled={isSearching || Boolean(processingId)}
|
||||||
|
onChange={(e) => this.setState({ query: e.target.value })}
|
||||||
|
onKeyDown={this.handleQueryKeyDown}
|
||||||
|
placeholder="z. B. Nike logo png"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoFixHighIcon color="primary" sx={{ fontSize: 17 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Klicken Sie auf ein Logo: Es wird heruntergeladen, automatisch freigestellt (Weiß- & Transparenz-Ränder entfernt), in AVIF konvertiert und als Markenbild hinterlegt.
|
||||||
|
</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">
|
||||||
|
Thumbnails werden gestreamt...
|
||||||
|
</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={brand?.name || 'Logo'}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
Als Logo wählen
|
||||||
|
</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,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
color: 'primary.main',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processingStep || 'Wird freigestellt...'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover action overlay (open / copy url) */}
|
||||||
|
{img.url && !processingId && (
|
||||||
|
<Box
|
||||||
|
className="image-overlay-actions"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="URL kopieren">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => this.handleCopyUrl(img.url, e)}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
boxShadow: 1,
|
||||||
|
'&:hover': { bgcolor: '#fff' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 14, color: 'text.primary' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="In neuem Tab öffnen">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => this.handleOpenUrl(img.url, e)}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
boxShadow: 1,
|
||||||
|
'&:hover': { bgcolor: '#fff' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OpenInNewIcon sx={{ fontSize: 14, color: 'text.primary' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 2.5, py: 1.5, justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{images.length > 0 ? `${images.length} Ergebnisse gefunden` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Button onClick={onClose} color="inherit">
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/components/brand/BrandLabel.jsx
Normal file
53
src/components/brand/BrandLabel.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import BrandThumb from './BrandThumb';
|
||||||
|
|
||||||
|
export default function BrandLabel({
|
||||||
|
brand,
|
||||||
|
name: nameProp,
|
||||||
|
thumbHash,
|
||||||
|
size = 18,
|
||||||
|
spacing = 0.5,
|
||||||
|
typographyProps,
|
||||||
|
fallback = null,
|
||||||
|
sx,
|
||||||
|
}) {
|
||||||
|
const brandName = nameProp ?? (typeof brand === 'string' ? brand : brand?.name);
|
||||||
|
if (!brandName && !thumbHash && !brand) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
maxWidth: '100%',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandThumb
|
||||||
|
brand={brand}
|
||||||
|
thumbHash={thumbHash}
|
||||||
|
size={size}
|
||||||
|
fallback={fallback}
|
||||||
|
/>
|
||||||
|
{brandName ? (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
{...typographyProps}
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...typographyProps?.sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brandName}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/brand/BrandThumb.jsx
Normal file
112
src/components/brand/BrandThumb.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ThumbHashImage, { getThumbHashDimensions } from '../article/ThumbHashImage';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts thumbHash from various brand representations:
|
||||||
|
* - Direct thumbHash / thumbhash property
|
||||||
|
* - Brand with pics array (e.g. brand.pics[0].thumbHash)
|
||||||
|
* - Brand by name or id looked up in brandAutomat.state.brands
|
||||||
|
*/
|
||||||
|
export function extractBrandThumbInfo(brand, thumbHashOverride) {
|
||||||
|
if (thumbHashOverride) {
|
||||||
|
const dims = getThumbHashDimensions(thumbHashOverride);
|
||||||
|
return {
|
||||||
|
thumbHash: thumbHashOverride,
|
||||||
|
thumbW: dims?.w ?? null,
|
||||||
|
thumbH: dims?.h ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!brand) return null;
|
||||||
|
|
||||||
|
// Direct thumbHash on brand object
|
||||||
|
let hash = brand.thumbHash ?? brand.thumbhash;
|
||||||
|
let thumbW = brand.thumbW ?? brand.thumbw;
|
||||||
|
let thumbH = brand.thumbH ?? brand.thumbh;
|
||||||
|
|
||||||
|
// Check pics array
|
||||||
|
if (!hash && Array.isArray(brand.pics) && brand.pics.length > 0) {
|
||||||
|
const firstPic = brand.pics[0];
|
||||||
|
hash = firstPic?.thumbHash ?? firstPic?.thumbhash;
|
||||||
|
thumbW = firstPic?.thumbW ?? firstPic?.thumbw;
|
||||||
|
thumbH = firstPic?.thumbH ?? firstPic?.thumbh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look up in brandAutomat
|
||||||
|
if (!hash) {
|
||||||
|
const brands = brandAutomat.state?.brands;
|
||||||
|
if (Array.isArray(brands)) {
|
||||||
|
const id = typeof brand === 'object' ? brand.id : (typeof brand === 'number' ? brand : null);
|
||||||
|
const name = typeof brand === 'object' ? brand.name : (typeof brand === 'string' ? brand : null);
|
||||||
|
const found = brands.find(
|
||||||
|
(b) =>
|
||||||
|
(id != null && b.id === id) ||
|
||||||
|
(name && String(b.name || '').trim().toLowerCase() === String(name).trim().toLowerCase())
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
hash = found.thumbHash ?? found.thumbhash ?? found.pics?.[0]?.thumbHash ?? found.pics?.[0]?.thumbhash;
|
||||||
|
thumbW = found.thumbW ?? found.thumbw ?? found.pics?.[0]?.thumbW ?? found.pics?.[0]?.thumbw;
|
||||||
|
thumbH = found.thumbH ?? found.thumbh ?? found.pics?.[0]?.thumbH ?? found.pics?.[0]?.thumbh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash) return null;
|
||||||
|
|
||||||
|
if (thumbW == null || thumbH == null) {
|
||||||
|
const dims = getThumbHashDimensions(hash);
|
||||||
|
if (dims) {
|
||||||
|
thumbW = dims.w;
|
||||||
|
thumbH = dims.h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { thumbHash: hash, thumbW, thumbH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrandThumb({
|
||||||
|
brand,
|
||||||
|
thumbHash: thumbHashProp,
|
||||||
|
size = 20,
|
||||||
|
fallback = null,
|
||||||
|
sx,
|
||||||
|
}) {
|
||||||
|
const info = extractBrandThumbInfo(brand, thumbHashProp);
|
||||||
|
|
||||||
|
if (!info?.thumbHash) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thumbHash, thumbW, thumbH } = info;
|
||||||
|
const ratio = (thumbW && thumbH) ? (thumbW / thumbH) : 1;
|
||||||
|
const calcWidth = Math.round(size * Math.max(0.6, Math.min(ratio, 2.5)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: size,
|
||||||
|
width: calcWidth,
|
||||||
|
minWidth: Math.round(size * 0.6),
|
||||||
|
maxWidth: Math.round(size * 2.5),
|
||||||
|
borderRadius: size >= 24 ? '4px' : '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.12)',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThumbHashImage
|
||||||
|
thumbHash={thumbHash}
|
||||||
|
width={calcWidth}
|
||||||
|
height={size}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/brand/index.js
Normal file
3
src/components/brand/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb';
|
||||||
|
export { default as BrandLabel } from './BrandLabel';
|
||||||
|
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';
|
||||||
@@ -22,6 +22,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
import ArticlePhoto from '../article/ArticlePhoto';
|
||||||
|
import { BrandLabel } from '../brand';
|
||||||
|
|
||||||
export default class ArticleSearchDialog extends PureComponent {
|
export default class ArticleSearchDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
@@ -291,7 +292,19 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
secondary={
|
secondary={
|
||||||
primaryEan ? (
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
|
||||||
|
{(article.brand?.name || article.brandName) && (
|
||||||
|
<BrandLabel
|
||||||
|
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||||||
|
name={article.brand?.name || article.brandName}
|
||||||
|
size={14}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'caption',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{primaryEan ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
@@ -299,7 +312,8 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
>
|
>
|
||||||
EAN: {primaryEan}
|
EAN: {primaryEan}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null
|
) : null}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { formatPrice } from '../../utils/price';
|
|||||||
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||||
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
||||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||||
|
import { BrandLabel } from '../brand';
|
||||||
|
|
||||||
class ManifestFormLine extends PureComponent {
|
class ManifestFormLine extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -367,6 +368,19 @@ class ManifestFormLine extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Brand */}
|
||||||
|
{(fullArticle.brand?.name || article.brand?.name) && (
|
||||||
|
<BrandLabel
|
||||||
|
brand={fullArticle.brand || article.brand}
|
||||||
|
size={16}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'caption',
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Article Name */}
|
{/* Article Name */}
|
||||||
{(fullArticle.name || article.name) && (
|
{(fullArticle.name || article.name) && (
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
482
src/components/settings/PrinterSettingsPanel.jsx
Normal file
482
src/components/settings/PrinterSettingsPanel.jsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { Component } 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 Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import BackendError from '../BackendError';
|
||||||
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
|
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
const PRINTER_TYPES = ['esc/pos', 'zpl'];
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
address: '',
|
||||||
|
type: 'esc/pos',
|
||||||
|
model: '',
|
||||||
|
serial: '',
|
||||||
|
desc: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formFromPrinter(p) {
|
||||||
|
return {
|
||||||
|
address: p.address ?? '',
|
||||||
|
type: p.type ?? 'esc/pos',
|
||||||
|
model: p.model ?? '',
|
||||||
|
serial: p.serial ?? '',
|
||||||
|
desc: p.desc ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form) {
|
||||||
|
if (!form.address.trim()) return 'Adresse ist erforderlich.';
|
||||||
|
if (!PRINTER_TYPES.includes(form.type)) return 'Ungültiger Druckertyp.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrinterFormDialog extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
form: props.initial ?? EMPTY_FORM,
|
||||||
|
busy: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (!prevProps.open && this.props.open) {
|
||||||
|
this.setState({ form: this.props.initial ?? EMPTY_FORM, busy: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange(field, value) {
|
||||||
|
this.setState((prev) => ({ form: { ...prev.form, [field]: value }, error: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
const { form } = this.state;
|
||||||
|
const validationError = validateForm(form);
|
||||||
|
if (validationError) {
|
||||||
|
this.setState({ error: validationError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ busy: true, error: null });
|
||||||
|
const body = {
|
||||||
|
address: form.address.trim(),
|
||||||
|
type: form.type,
|
||||||
|
model: form.model.trim() || null,
|
||||||
|
serial: form.serial.trim() || null,
|
||||||
|
desc: form.desc.trim() || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await this.props.onSubmit(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (!this._unmounted) {
|
||||||
|
this.setState({ busy: false, error: backendErrorFrom(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._unmounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, title } = this.props;
|
||||||
|
const { form, busy, error } = this.state;
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Adresse"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={form.address}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleChange('address', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="192.168.1.200"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Typ"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
value={form.type}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleChange('type', e.target.value)}
|
||||||
|
>
|
||||||
|
{PRINTER_TYPES.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Modell"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.model}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleChange('model', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="z. B. TM-T88VI"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Seriennummer"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.serial}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleChange('serial', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={form.desc}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => this.handleChange('desc', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="z. B. Bondrucker Bar"
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<Alert severity="error">
|
||||||
|
{typeof error === 'string' ? error : error.message}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={busy}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={this.handleSubmit} disabled={busy}>
|
||||||
|
{busy ? <CircularProgress size={18} /> : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PrinterSettingsPanel extends Component {
|
||||||
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
printers: [],
|
||||||
|
draggingId: null,
|
||||||
|
dragOverId: null,
|
||||||
|
addOpen: false,
|
||||||
|
editPrinter: null,
|
||||||
|
deleteId: null,
|
||||||
|
deleteBusy: false,
|
||||||
|
};
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.handleAdd = this.handleAdd.bind(this);
|
||||||
|
this.handleAddSubmit = this.handleAddSubmit.bind(this);
|
||||||
|
this.handleEditSubmit = this.handleEditSubmit.bind(this);
|
||||||
|
this.handleCloseAdd = this.handleCloseAdd.bind(this);
|
||||||
|
this.handleCloseEdit = this.handleCloseEdit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.setState({ status: 'loading', error: null });
|
||||||
|
try {
|
||||||
|
const data = await this.context.printer.list();
|
||||||
|
requireObject(data, 'GET /api/printer');
|
||||||
|
const printers = requireArray(data.printers, 'printers');
|
||||||
|
this.setState({ status: 'ready', printers });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAdd() {
|
||||||
|
this.setState({ addOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseAdd() {
|
||||||
|
this.setState({ addOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseEdit() {
|
||||||
|
this.setState({ editPrinter: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAddSubmit(body) {
|
||||||
|
const data = await this.context.printer.create(body);
|
||||||
|
requireObject(data, 'POST /api/printer');
|
||||||
|
const printer = data.printer;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
printers: [...prev.printers, printer],
|
||||||
|
addOpen: false,
|
||||||
|
}));
|
||||||
|
enqueueSnackbar('Drucker hinzugefügt.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditSubmit(body) {
|
||||||
|
const { editPrinter } = this.state;
|
||||||
|
const data = await this.context.printer.update(editPrinter.id, body);
|
||||||
|
requireObject(data, 'PATCH /api/printer/:id');
|
||||||
|
const updated = data.printer;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
printers: prev.printers.map((p) => (p.id === updated.id ? updated : p)),
|
||||||
|
editPrinter: null,
|
||||||
|
}));
|
||||||
|
enqueueSnackbar('Drucker gespeichert.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete(id) {
|
||||||
|
this.setState({ deleteId: id, deleteBusy: true });
|
||||||
|
try {
|
||||||
|
await this.context.printer.delete(id);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
printers: prev.printers.filter((p) => p.id !== id),
|
||||||
|
deleteId: null,
|
||||||
|
deleteBusy: false,
|
||||||
|
}));
|
||||||
|
enqueueSnackbar('Drucker gelöscht.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._unmounted) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ deleteId: null, deleteBusy: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragStart(id) {
|
||||||
|
this.setState({ draggingId: id, dragOverId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragOver(e, id) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (id !== this.state.dragOverId) {
|
||||||
|
this.setState({ dragOverId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragLeave() {
|
||||||
|
this.setState({ dragOverId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDrop(targetId) {
|
||||||
|
const { draggingId, printers } = this.state;
|
||||||
|
this.setState({ draggingId: null, dragOverId: null });
|
||||||
|
if (!draggingId || draggingId === targetId) return;
|
||||||
|
|
||||||
|
const fromIdx = printers.findIndex((p) => p.id === draggingId);
|
||||||
|
const toIdx = printers.findIndex((p) => p.id === targetId);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
|
||||||
|
const reordered = [...printers];
|
||||||
|
const [item] = reordered.splice(fromIdx, 1);
|
||||||
|
reordered.splice(toIdx, 0, item);
|
||||||
|
const printerIds = reordered.map((p) => p.id);
|
||||||
|
|
||||||
|
this.setState({ printers: reordered });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.context.printer.order({ printerIds });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._unmounted) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ printers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragEnd() {
|
||||||
|
this.setState({ draggingId: null, dragOverId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._unmounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
status, error, printers,
|
||||||
|
draggingId, dragOverId,
|
||||||
|
addOpen, editPrinter,
|
||||||
|
deleteId, deleteBusy,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={this.handleAdd}
|
||||||
|
>
|
||||||
|
Drucker hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{printers.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
Noch keine Drucker konfiguriert.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 32 }} />
|
||||||
|
<TableCell>Adresse</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell>Modell</TableCell>
|
||||||
|
<TableCell>Beschreibung</TableCell>
|
||||||
|
<TableCell align="right" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{printers.map((printer) => {
|
||||||
|
const isDeleting = deleteId === printer.id && deleteBusy;
|
||||||
|
const isDragging = draggingId === printer.id;
|
||||||
|
const isDragOver = dragOverId === printer.id;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={printer.id}
|
||||||
|
hover
|
||||||
|
draggable
|
||||||
|
onDragStart={() => this.handleDragStart(printer.id)}
|
||||||
|
onDragOver={(e) => this.handleDragOver(e, printer.id)}
|
||||||
|
onDragLeave={() => this.handleDragLeave()}
|
||||||
|
onDrop={() => this.handleDrop(printer.id)}
|
||||||
|
onDragEnd={() => this.handleDragEnd()}
|
||||||
|
sx={{
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
outline: isDragOver ? '2px solid' : 'none',
|
||||||
|
outlineColor: 'primary.main',
|
||||||
|
cursor: 'grab',
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ px: 0.5, color: 'text.disabled' }}>
|
||||||
|
<DragIndicatorIcon fontSize="small" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">{printer.address}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{printer.type}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color={printer.model ? undefined : 'text.disabled'}>
|
||||||
|
{printer.model ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color={printer.desc ? undefined : 'text.disabled'}>
|
||||||
|
{printer.desc ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Stack direction="row" spacing={0} sx={{ justifyContent: 'flex-end' }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={() => this.setState({ editPrinter: printer })}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Löschen"
|
||||||
|
color="error"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={() => void this.handleDelete(printer.id)}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<PrinterFormDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={this.handleCloseAdd}
|
||||||
|
onSubmit={this.handleAddSubmit}
|
||||||
|
title="Drucker hinzufügen"
|
||||||
|
initial={EMPTY_FORM}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PrinterFormDialog
|
||||||
|
open={Boolean(editPrinter)}
|
||||||
|
onClose={this.handleCloseEdit}
|
||||||
|
onSubmit={this.handleEditSubmit}
|
||||||
|
title="Drucker bearbeiten"
|
||||||
|
initial={editPrinter ? formFromPrinter(editPrinter) : EMPTY_FORM}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,22 @@ import DialogTitle from '@mui/material/DialogTitle';
|
|||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
import NumbersIcon from '@mui/icons-material/Numbers';
|
import NumbersIcon from '@mui/icons-material/Numbers';
|
||||||
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import NumberSettingsPanel from './NumberSettingsPanel';
|
import NumberSettingsPanel from './NumberSettingsPanel';
|
||||||
|
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
||||||
import SettingsKeysPanel from './SettingsKeysPanel';
|
import SettingsKeysPanel from './SettingsKeysPanel';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
keys: 'keys',
|
keys: 'keys',
|
||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
|
printers: 'printers',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default class SettingsDialog extends Component {
|
export default class SettingsDialog extends Component {
|
||||||
|
|||||||
@@ -27,16 +27,14 @@ const SETTABLE_KEYS = [
|
|||||||
'ui.theme',
|
'ui.theme',
|
||||||
'keys.tavily',
|
'keys.tavily',
|
||||||
'keys.openrouter',
|
'keys.openrouter',
|
||||||
'labelprinter.addr',
|
'keys.serper',
|
||||||
'labelprinter.type',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const KEY_LABELS = {
|
const KEY_LABELS = {
|
||||||
'ui.theme': 'UI-Theme',
|
'ui.theme': 'UI-Theme',
|
||||||
'keys.tavily': 'Tavily API-Key',
|
'keys.tavily': 'Tavily API-Key',
|
||||||
'keys.openrouter': 'OpenRouter API-Key',
|
'keys.openrouter': 'OpenRouter API-Key',
|
||||||
'labelprinter.addr': 'Labeldrucker-Adresse',
|
'keys.serper': 'Serper API-Key',
|
||||||
'labelprinter.type': 'Labeldrucker-Typ',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function keyLabel(key) {
|
function keyLabel(key) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default as SettingsDialog } from './SettingsDialog';
|
export { default as SettingsDialog } from './SettingsDialog';
|
||||||
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
|
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
|
||||||
export { default as NumberSettingsPanel } from './NumberSettingsPanel';
|
export { default as NumberSettingsPanel } from './NumberSettingsPanel';export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Tile, tileBaseSx } from 'dashgui/tiles';
|
|||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
import ArticlePhoto from '../article/ArticlePhoto';
|
||||||
|
import { BrandLabel } from '../brand';
|
||||||
|
|
||||||
export class ArticleTile extends Tile {
|
export class ArticleTile extends Tile {
|
||||||
hasPhoto() {
|
hasPhoto() {
|
||||||
@@ -21,8 +22,23 @@ export class ArticleTile extends Tile {
|
|||||||
const { article } = this.props;
|
const { article } = this.props;
|
||||||
if (!article) return null;
|
if (!article) return null;
|
||||||
const sku = articleSku(article);
|
const sku = articleSku(article);
|
||||||
|
const brand = article.brand;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{brand?.name ? (
|
||||||
|
<BrandLabel
|
||||||
|
brand={brand}
|
||||||
|
name={brand.name}
|
||||||
|
size={16}
|
||||||
|
spacing={0.5}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'caption',
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 500,
|
||||||
|
noWrap: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{sku ? (
|
{sku ? (
|
||||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
||||||
{sku}
|
{sku}
|
||||||
@@ -83,6 +99,20 @@ export class ArticleTile extends Tile {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{article?.brand?.name ? (
|
||||||
|
<BrandLabel
|
||||||
|
brand={article.brand}
|
||||||
|
name={article.brand.name}
|
||||||
|
size={14}
|
||||||
|
spacing={0.5}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'caption',
|
||||||
|
noWrap: true,
|
||||||
|
sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 },
|
||||||
|
}}
|
||||||
|
sx={{ mb: 0.25 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{label ? (
|
{label ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import InfoIcon from '@mui/icons-material/Info';
|
|||||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||||
import CategoryIcon from '@mui/icons-material/Category';
|
import CategoryIcon from '@mui/icons-material/Category';
|
||||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
|
import CopyrightIcon from '@mui/icons-material/Copyright';
|
||||||
import ListAltIcon from '@mui/icons-material/ListAlt';
|
import ListAltIcon from '@mui/icons-material/ListAlt';
|
||||||
import GroupsIcon from '@mui/icons-material/Groups';
|
import GroupsIcon from '@mui/icons-material/Groups';
|
||||||
import AddBoxIcon from '@mui/icons-material/AddBox';
|
import AddBoxIcon from '@mui/icons-material/AddBox';
|
||||||
@@ -103,6 +104,7 @@ export const SUB_NAV = {
|
|||||||
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
|
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
|
||||||
{ segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
|
{ segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
|
||||||
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
|
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
|
||||||
|
{ segment: 'marken', label: 'Marken', icon: CopyrightIcon },
|
||||||
],
|
],
|
||||||
'/lieferung': [
|
'/lieferung': [
|
||||||
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
|
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { preserveSearchFocus } from './searchFocus';
|
|||||||
import { artikelDetailPath } from './articleRoute';
|
import { artikelDetailPath } from './articleRoute';
|
||||||
import withSearchParams from './withSearchParams';
|
import withSearchParams from './withSearchParams';
|
||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku } from '../../automats';
|
||||||
|
import { BrandLabel } from '../../components/brand';
|
||||||
|
|
||||||
const resultRowSx = {
|
const resultRowSx = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -429,7 +430,24 @@ class ArtikelSearchPage extends Component {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
noWrap
|
noWrap
|
||||||
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
|
{(row.brand?.name || row.brandName) && (
|
||||||
|
<>
|
||||||
|
<BrandLabel
|
||||||
|
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
|
||||||
|
name={row.brand?.name || row.brandName}
|
||||||
|
size={14}
|
||||||
|
spacing={0.5}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'body2',
|
||||||
|
color: 'text.secondary',
|
||||||
|
noWrap: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{' · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{row.sku}
|
{row.sku}
|
||||||
{' · '}
|
{' · '}
|
||||||
Bestand {formatStock(row.stock)}
|
Bestand {formatStock(row.stock)}
|
||||||
|
|||||||
297
src/pages/artikel/marken.jsx
Normal file
297
src/pages/artikel/marken.jsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import CopyrightIcon from '@mui/icons-material/Copyright';
|
||||||
|
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import { setLastArticleSku } from '../../automats';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { BrandThumb, BrandImageSearchDialog } from '../../components/brand';
|
||||||
|
|
||||||
|
function draftsFrom(brands) {
|
||||||
|
return Object.fromEntries(brands.map((b) => [b.id, b.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArtikelMarkenPage extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialBrands = brandAutomat.state.brands || [];
|
||||||
|
this.state = {
|
||||||
|
status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||||
|
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
||||||
|
brands: initialBrands,
|
||||||
|
drafts: draftsFrom(initialBrands),
|
||||||
|
newName: '',
|
||||||
|
busyKey: null,
|
||||||
|
searchBrand: null,
|
||||||
|
};
|
||||||
|
this._cancelled = false;
|
||||||
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.loadBrands = this.loadBrands.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
setLastArticleSku(null);
|
||||||
|
this._cancelled = false;
|
||||||
|
|
||||||
|
this.unsub = brandAutomat.subscribe((state) => {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
if (state.status === 'succeeded') {
|
||||||
|
this.applyBrands(state.brands);
|
||||||
|
} else if (state.status === 'failed') {
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom({ message: state.error }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (brandAutomat.state.status !== 'succeeded') {
|
||||||
|
void this.loadBrands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._cancelled = true;
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBrands(brands) {
|
||||||
|
this.setState((prev) => {
|
||||||
|
const currentDrafts = prev.drafts || {};
|
||||||
|
const nextDrafts = { ...draftsFrom(brands) };
|
||||||
|
for (const b of brands) {
|
||||||
|
if (prev.busyKey !== b.id && currentDrafts[b.id] !== undefined) {
|
||||||
|
nextDrafts[b.id] = currentDrafts[b.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
brands,
|
||||||
|
drafts: nextDrafts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBrands() {
|
||||||
|
if (brandAutomat.state.status !== 'succeeded') {
|
||||||
|
this.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await brandAutomat.actions.load();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.applyBrands(list);
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void this.loadBrands();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCreate() {
|
||||||
|
const name = this.state.newName.trim();
|
||||||
|
if (!name || this.state.busyKey != null) return;
|
||||||
|
|
||||||
|
this.setState({ busyKey: 'new' });
|
||||||
|
try {
|
||||||
|
const row = await brandAutomat.actions.upsert({ name });
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ newName: '', busyKey: null });
|
||||||
|
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRename(brand) {
|
||||||
|
const draft = String(this.state.drafts[brand.id] ?? '').trim();
|
||||||
|
if (!draft) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [brand.id]: brand.name },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft === brand.name || this.state.busyKey != null) return;
|
||||||
|
|
||||||
|
this.setState({ busyKey: brand.id });
|
||||||
|
try {
|
||||||
|
await brandAutomat.actions.upsert({ name: draft });
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
busyKey: null,
|
||||||
|
drafts: { ...prev.drafts, [brand.id]: brand.name },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete(brand) {
|
||||||
|
if (this.state.busyKey != null) return;
|
||||||
|
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
|
||||||
|
|
||||||
|
this.setState({ busyKey: brand.id });
|
||||||
|
try {
|
||||||
|
await brandAutomat.actions.remove(brand.id);
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { status, error, brands, drafts, newName, busyKey } = this.state;
|
||||||
|
const busy = busyKey != null;
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelTileGrid>
|
||||||
|
<PanelTile wide={2} minHeight={280} color="#ddd6fe">
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Marken der ausgewählten Firma.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List dense disablePadding>
|
||||||
|
{brands.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
|
Noch keine Marken angelegt.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
brands.map((b) => (
|
||||||
|
<ListItem
|
||||||
|
key={b.id}
|
||||||
|
disableGutters
|
||||||
|
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<BrandThumb
|
||||||
|
brand={b}
|
||||||
|
size={22}
|
||||||
|
fallback={<CopyrightIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />}
|
||||||
|
/>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label=""
|
||||||
|
value={drafts[b.id] ?? ''}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) =>
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [b.id]: next },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={() => void this.handleRename(b)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [b.id]: b.name },
|
||||||
|
}));
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Logo suchen">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Logo für „${b.name}" suchen`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => this.setState({ searchBrand: b })}
|
||||||
|
>
|
||||||
|
<ImageSearchIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
aria-label={`„${b.name}" löschen`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void this.handleDelete(b)}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Neue Marke"
|
||||||
|
value={newName}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) => this.setState({ newName: next })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={busy || !newName.trim()}
|
||||||
|
onClick={() => void this.handleCreate()}
|
||||||
|
>
|
||||||
|
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</PanelTile>
|
||||||
|
|
||||||
|
<BrandImageSearchDialog
|
||||||
|
open={Boolean(this.state.searchBrand)}
|
||||||
|
brand={this.state.searchBrand}
|
||||||
|
onClose={() => this.setState({ searchBrand: null })}
|
||||||
|
/>
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtikelMarkenPage;
|
||||||
339
src/utils/cropLogo.js
Normal file
339
src/utils/cropLogo.js
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Utility to download, auto-crop (white and transparency margins), and encode brand logos to AVIF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_EDGE = 1000;
|
||||||
|
const AVIF_QUALITY = 85;
|
||||||
|
const AVIF_SPEED = 8;
|
||||||
|
const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-white
|
||||||
|
|
||||||
|
let encodeAvif;
|
||||||
|
|
||||||
|
async function getAvifEncoder() {
|
||||||
|
if (!encodeAvif) {
|
||||||
|
const { default: encode } = await import('@jsquash/avif/encode');
|
||||||
|
encodeAvif = encode;
|
||||||
|
}
|
||||||
|
return encodeAvif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads image from URL with fallback to streamed dataUrl if CORS fails.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string|null} fallbackDataUrl
|
||||||
|
* @returns {Promise<HTMLImageElement>}
|
||||||
|
*/
|
||||||
|
export async function downloadLogoImage(url, fallbackDataUrl) {
|
||||||
|
// Strategy 1: Attempt direct fetch with CORS mode
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { mode: 'cors' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
return await loadImageFromBlob(blob);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// CORS or network failure, proceed to fallbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Attempt loading with HTMLImageElement crossOrigin
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Canvas might still be tainted or image failed to load
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Use the provided dataUrl (streamed WebP/JPEG thumbnail from Serper SSE)
|
||||||
|
if (fallbackDataUrl) {
|
||||||
|
const resp = await fetch(fallbackDataUrl);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
return await loadImageFromBlob(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageFromBlob(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (e) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a pixel is background (near-white or transparent).
|
||||||
|
*/
|
||||||
|
function isBackgroundPixel(r, g, b, a) {
|
||||||
|
if (a <= 25) return true;
|
||||||
|
return r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flood-fills white/transparent pixels from all borders, setting outer background to alpha 0,
|
||||||
|
* and crops strictly to the remaining content bounding box (removing outer and internal margins).
|
||||||
|
*
|
||||||
|
* @param {HTMLImageElement} img
|
||||||
|
* @returns {HTMLCanvasElement} Cropped canvas with transparent background
|
||||||
|
*/
|
||||||
|
export function autoCropWhiteAndTransparent(img) {
|
||||||
|
const width = img.naturalWidth || img.width;
|
||||||
|
const height = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Canvas 2D-Kontext nicht verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
let imgData;
|
||||||
|
try {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = imgData.data;
|
||||||
|
const totalPixels = width * height;
|
||||||
|
const visited = new Uint8Array(totalPixels);
|
||||||
|
|
||||||
|
// Queue for BFS flood fill from edges
|
||||||
|
// Packed format: y * width + x
|
||||||
|
const queue = [];
|
||||||
|
|
||||||
|
// Seed top and bottom rows
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const topIdx = x;
|
||||||
|
const botIdx = (height - 1) * width + x;
|
||||||
|
|
||||||
|
const topOffset = topIdx * 4;
|
||||||
|
if (isBackgroundPixel(data[topOffset], data[topOffset + 1], data[topOffset + 2], data[topOffset + 3])) {
|
||||||
|
visited[topIdx] = 1;
|
||||||
|
queue.push(topIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const botOffset = botIdx * 4;
|
||||||
|
if (isBackgroundPixel(data[botOffset], data[botOffset + 1], data[botOffset + 2], data[botOffset + 3])) {
|
||||||
|
visited[botIdx] = 1;
|
||||||
|
queue.push(botIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed left and right columns
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const leftIdx = y * width;
|
||||||
|
const rightIdx = y * width + (width - 1);
|
||||||
|
|
||||||
|
const leftOffset = leftIdx * 4;
|
||||||
|
if (!visited[leftIdx] && isBackgroundPixel(data[leftOffset], data[leftOffset + 1], data[leftOffset + 2], data[leftOffset + 3])) {
|
||||||
|
visited[leftIdx] = 1;
|
||||||
|
queue.push(leftIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightOffset = rightIdx * 4;
|
||||||
|
if (!visited[rightIdx] && isBackgroundPixel(data[rightOffset], data[rightOffset + 1], data[rightOffset + 2], data[rightOffset + 3])) {
|
||||||
|
visited[rightIdx] = 1;
|
||||||
|
queue.push(rightIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS flood-fill to turn all connected outer background pixels transparent
|
||||||
|
let head = 0;
|
||||||
|
while (head < queue.length) {
|
||||||
|
const curr = queue[head++];
|
||||||
|
const cx = curr % width;
|
||||||
|
const cy = (curr / width) | 0;
|
||||||
|
|
||||||
|
// Set pixel to transparent
|
||||||
|
const offset = curr * 4;
|
||||||
|
data[offset + 3] = 0; // alpha = 0
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
const neighbors = [
|
||||||
|
cx > 0 ? curr - 1 : -1,
|
||||||
|
cx < width - 1 ? curr + 1 : -1,
|
||||||
|
cy > 0 ? curr - width : -1,
|
||||||
|
cy < height - 1 ? curr + width : -1,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const n = neighbors[i];
|
||||||
|
if (n !== -1 && !visited[n]) {
|
||||||
|
const noffset = n * 4;
|
||||||
|
if (isBackgroundPixel(data[noffset], data[noffset + 1], data[noffset + 2], data[noffset + 3])) {
|
||||||
|
visited[n] = 1;
|
||||||
|
queue.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update canvas with cleared transparency
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
// Scan bounding box of non-transparent content
|
||||||
|
let minX = width;
|
||||||
|
let minY = height;
|
||||||
|
let maxX = -1;
|
||||||
|
let maxY = -1;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const rowOffset = y * width * 4;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const offset = rowOffset + x * 4;
|
||||||
|
const alpha = data[offset + 3];
|
||||||
|
const r = data[offset];
|
||||||
|
const g = data[offset + 1];
|
||||||
|
const b = data[offset + 2];
|
||||||
|
|
||||||
|
// Non-transparent and not near-white remnant
|
||||||
|
if (alpha > 20 && !(r >= 248 && g >= 248 && b >= 248)) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no colored content was detected, fall back to any visible alpha
|
||||||
|
if (maxX === -1) {
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const rowOffset = y * width * 4;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const offset = rowOffset + x * 4;
|
||||||
|
if (data[offset + 3] > 20) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If entire image is blank, return original
|
||||||
|
if (maxX === -1 || minX > maxX || minY > maxY) {
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop tightly to bounding box
|
||||||
|
const cropW = maxX - minX + 1;
|
||||||
|
const cropH = maxY - minY + 1;
|
||||||
|
|
||||||
|
const croppedCanvas = document.createElement('canvas');
|
||||||
|
croppedCanvas.width = cropW;
|
||||||
|
croppedCanvas.height = cropH;
|
||||||
|
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
if (!croppedCtx) {
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH);
|
||||||
|
return croppedCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resizes canvas if larger than MAX_EDGE (maintaining aspect ratio),
|
||||||
|
* and encodes to AVIF format.
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {string} [filename='brandpic.avif']
|
||||||
|
* @returns {Promise<File>}
|
||||||
|
*/
|
||||||
|
export async function encodeCanvasToAvif(canvas, filename = 'brandpic.avif') {
|
||||||
|
let targetW = canvas.width;
|
||||||
|
let targetH = canvas.height;
|
||||||
|
|
||||||
|
if (targetW > MAX_EDGE || targetH > MAX_EDGE) {
|
||||||
|
const scale = Math.min(MAX_EDGE / targetW, MAX_EDGE / targetH);
|
||||||
|
targetW = Math.round(targetW * scale);
|
||||||
|
targetH = Math.round(targetH * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCanvas = document.createElement('canvas');
|
||||||
|
finalCanvas.width = targetW;
|
||||||
|
finalCanvas.height = targetH;
|
||||||
|
|
||||||
|
const ctx = finalCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Canvas 2D-Kontext nicht verfügbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, targetW, targetH);
|
||||||
|
|
||||||
|
const finalImageData = ctx.getImageData(0, 0, targetW, targetH);
|
||||||
|
const encode = await getAvifEncoder();
|
||||||
|
|
||||||
|
const avifBuffer = await encode(finalImageData, {
|
||||||
|
quality: AVIF_QUALITY,
|
||||||
|
speed: AVIF_SPEED,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new File([avifBuffer], filename, { type: 'image/avif' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete pipeline:
|
||||||
|
* 1. Download image from url (or fallbackDataUrl)
|
||||||
|
* 2. Auto-crop white & transparency margins
|
||||||
|
* 3. Encode to AVIF
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string|null} fallbackDataUrl
|
||||||
|
* @returns {Promise<File>}
|
||||||
|
*/
|
||||||
|
export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) {
|
||||||
|
let img;
|
||||||
|
try {
|
||||||
|
img = await downloadLogoImage(url, fallbackDataUrl);
|
||||||
|
} catch (downloadErr) {
|
||||||
|
// If url direct download completely failed, try using fallbackDataUrl if available
|
||||||
|
if (fallbackDataUrl) {
|
||||||
|
img = await downloadLogoImage(null, fallbackDataUrl);
|
||||||
|
} else {
|
||||||
|
throw downloadErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let croppedCanvas;
|
||||||
|
try {
|
||||||
|
croppedCanvas = autoCropWhiteAndTransparent(img);
|
||||||
|
} catch {
|
||||||
|
// In case of any canvas security error with external url, fallback to dataUrl
|
||||||
|
if (fallbackDataUrl) {
|
||||||
|
const fallbackImg = await downloadLogoImage(null, fallbackDataUrl);
|
||||||
|
croppedCanvas = autoCropWhiteAndTransparent(fallbackImg);
|
||||||
|
} else {
|
||||||
|
throw new Error('Das Bild konnte aufgrund von CORS-Sicherheitseinstellungen nicht freigestellt werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await encodeCanvasToAvif(croppedCanvas, 'brandpic.avif');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user