Compare commits

...

2 Commits

Author SHA1 Message Date
seb
0f875f347e u 2026-09-27 16:19:08 +02:00
seb
559307675c u 2026-09-27 07:08:13 +02:00
25 changed files with 2603 additions and 45 deletions

7
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",
@@ -5415,6 +5416,12 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",

View File

@@ -24,6 +24,7 @@
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",

View File

@@ -442,6 +442,19 @@ export class ApiClient {
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 = {
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
list: (query) => this.#json('GET', '/api/company/setting', { query }),

View File

@@ -581,6 +581,35 @@ export const brand = {
upsert(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.
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }

View File

@@ -7,6 +7,7 @@ const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
const ArtikelMarkenPage = lazy(() => import('./pages/artikel/marken'));
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
@@ -53,6 +54,7 @@ const routes = [
{ index: true, element: <ArtikelUebersichtPage /> },
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
{ path: 'lager', element: <ArtikelLagerPage /> },
{ path: 'marken', element: <ArtikelMarkenPage /> },
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
],
},

View File

@@ -54,6 +54,37 @@ const actions = {
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() {
brandAutomat.setState(initialState);

View File

@@ -2,6 +2,7 @@ import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
@@ -13,14 +14,26 @@ import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
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 ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
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 { company } from '../api';
import { company, number as numberApi } from '../api';
import { StorageManagerContext } from '../api/storageManagerContext';
import authAutomat from '../automats/authAutomat';
import {
@@ -30,6 +43,67 @@ import {
} from '../utils/companyForm';
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 {
static contextType = StorageManagerContext;
@@ -44,11 +118,15 @@ class CompanySetupDialog extends Component {
iso639: defaultIso639(),
taxrate: '19',
reducedtaxrate: '7',
selectedPreset: 'standard',
numbers: { ...PRESETS[0].values },
};
this.handleSelect = this.handleSelect.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleClose = this.handleClose.bind(this);
this.applyPreset = this.applyPreset.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
}
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) {
event.preventDefault();
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const { name, iso639, taxrate, reducedtaxrate, numbers } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canCreate =
@@ -103,8 +203,29 @@ class CompanySetupDialog extends Component {
taxrate: parsedTaxrate,
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.updateCompany();
await this.context.updateNumber?.();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
@@ -126,8 +247,18 @@ class CompanySetupDialog extends Component {
}
render() {
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
this.state;
const {
companies,
loading,
busy,
error,
name,
iso639,
taxrate,
reducedtaxrate,
selectedPreset,
numbers,
} = this.state;
const hasCompanies = companies.length > 0;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
@@ -138,20 +269,25 @@ class CompanySetupDialog extends Component {
parsedReducedtaxrate != null;
return (
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Dialog
open
onClose={this.handleClose}
maxWidth={hasCompanies ? 'sm' : 'md'}
fullWidth
>
<DialogTitle sx={{ pb: 1 }}>
{loading
? 'Unternehmen'
: hasCompanies
? 'Unternehmen auswählen'
: 'Unternehmen anlegen'}
: 'Willkommen bei Dash'}
</DialogTitle>
<DialogContent>
{!loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{hasCompanies
? '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>
) : null}
@@ -191,7 +327,7 @@ class CompanySetupDialog extends Component {
{hasCompanies ? <Divider>oder</Divider> : null}
<Box component="form" onSubmit={this.handleCreate} noValidate>
<Stack spacing={2}>
<Stack spacing={2.5}>
{hasCompanies ? (
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
) : null}
@@ -200,7 +336,7 @@ class CompanySetupDialog extends Component {
fullWidth
required
size="small"
label="Name"
label="Unternehmensname"
value={name}
disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })}
@@ -225,7 +361,7 @@ class CompanySetupDialog extends Component {
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
required
@@ -262,8 +398,143 @@ class CompanySetupDialog extends Component {
/>
</Stack>
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
Anlegen
{/* Nummernkreise Section (when no company was created yet or creating new) */}
<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>
</Stack>
</Box>
@@ -287,3 +558,4 @@ class CompanySetupDialog extends Component {
}
export default CompanySetupDialog;

View File

@@ -2,19 +2,34 @@ import { useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import { thumbHashToRGBA } from 'thumbhash';
function decodeThumbHash(thumbHash) {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
return thumbHashToRGBA(bytes);
export function decodeThumbHash(thumbHash) {
if (!thumbHash || typeof thumbHash !== 'string') return null;
try {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
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;
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();
if (width === 0 || height === 0) return;
const decoded = decodeThumbHash(thumbHash);
if (!decoded) return;
const { w, h, rgba } = decoded;
const { w, h, rgba } = decodeThumbHash(thumbHash);
const src = document.createElement('canvas');
src.width = w;
src.height = h;
@@ -34,24 +49,24 @@ function paintThumbHash(canvas, thumbHash) {
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);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !thumbHash) return;
paintThumbHash(canvas, thumbHash);
paintThumbHash(canvas, thumbHash, width, height);
const parent = canvas.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(() => {
paintThumbHash(canvas, thumbHash);
paintThumbHash(canvas, thumbHash, width, height);
});
observer.observe(parent);
return () => observer.disconnect();
}, [thumbHash]);
}, [thumbHash, width, height]);
return (
<Box

View File

@@ -7,6 +7,9 @@ import { EditablePanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify';
import InputAdornment from '@mui/material/InputAdornment';
import { BrandLabel, BrandThumb } from '../../brand';
class PanelValue extends Component {
render() {
const { label, children } = this.props;
@@ -27,7 +30,12 @@ class PanelValue extends Component {
}
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) {
@@ -43,6 +51,11 @@ const SECOND_ROW_MIN_HEIGHT = 220;
class BrandEdit extends Component {
render() {
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 (
<Autocomplete
fullWidth
@@ -57,6 +70,21 @@ class BrandEdit extends Component {
onChange={(_event, option) => {
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) => (
<TextField
{...params}
@@ -65,6 +93,16 @@ class BrandEdit extends Component {
size="small"
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: {
...params.slotProps?.inputLabel,
shrink: true,
@@ -152,8 +190,19 @@ export default class ArticleViewBrand extends PureComponent {
onSave={this.handleSave}
onError={notifyError}
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}
display={<PanelValue>{value.name || null}</PanelValue>}
edit={({ draft, onChange, inputRef }) => (
<BrandEdit
brands={brands}

View File

@@ -8,6 +8,7 @@ import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { articleEans } from '../../../utils/articleDetail';
import { notifyError } from '../../../utils/notify';
import JsBarcode from 'jsbarcode';
class PanelValue extends Component {
render() {
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;
class EansDisplay extends Component {
render() {
const { sku, eans } = this.props;
return (
<>
<Box sx={{ width: '100%' }}>
<PanelValue label="SKU">{sku}</PanelValue>
<SkuBarcode value={sku} />
{(eans ?? []).map((ean) => (
<PanelValue key={ean} label="EAN">
{ean}
</PanelValue>
))}
</>
</Box>
);
}
}
@@ -65,7 +109,8 @@ class EansEdit extends Component {
return (
<Box sx={{ width: '100%' }}>
<PanelValue label="SKU">{sku}</PanelValue>
<Box sx={{ mt: 3 }}>
<SkuBarcode value={sku} />
<Box sx={{ mt: 2 }}>
<Autocomplete
fullWidth
multiple

View 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ß- &amp; 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>
);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb';
export { default as BrandLabel } from './BrandLabel';
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';

View File

@@ -22,6 +22,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import LinkIcon from '@mui/icons-material/Link';
import { StorageManagerContext } from '../../api/storageManagerContext';
import ArticlePhoto from '../article/ArticlePhoto';
import { BrandLabel } from '../brand';
export default class ArticleSearchDialog extends PureComponent {
static contextType = StorageManagerContext;
@@ -291,15 +292,28 @@ export default class ArticleSearchDialog extends PureComponent {
</Box>
}
secondary={
primaryEan ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
>
EAN: {primaryEan}
</Typography>
) : null
<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
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
>
EAN: {primaryEan}
</Typography>
) : null}
</Box>
}
/>
<Button

View File

@@ -23,6 +23,7 @@ import { formatPrice } from '../../utils/price';
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
import { BrandLabel } from '../brand';
class ManifestFormLine extends PureComponent {
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 */}
{(fullArticle.name || article.name) && (
<Typography

View 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}
/>
</>
);
}
}

View File

@@ -6,18 +6,22 @@ import DialogTitle from '@mui/material/DialogTitle';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import NumbersIcon from '@mui/icons-material/Numbers';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import TuneIcon from '@mui/icons-material/Tune';
import NumberSettingsPanel from './NumberSettingsPanel';
import PrinterSettingsPanel from './PrinterSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel';
const TAB_KEYS = {
keys: 'keys',
numbers: 'numbers',
printers: 'printers',
};
const TABS = [
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ 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 {

View File

@@ -27,16 +27,14 @@ const SETTABLE_KEYS = [
'ui.theme',
'keys.tavily',
'keys.openrouter',
'labelprinter.addr',
'labelprinter.type',
'keys.serper',
];
const KEY_LABELS = {
'ui.theme': 'UI-Theme',
'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key',
'labelprinter.addr': 'Labeldrucker-Adresse',
'labelprinter.type': 'Labeldrucker-Typ',
'keys.serper': 'Serper API-Key',
};
function keyLabel(key) {

View File

@@ -1,3 +1,3 @@
export { default as SettingsDialog } from './SettingsDialog';
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
export { default as NumberSettingsPanel } from './NumberSettingsPanel';
export { default as NumberSettingsPanel } from './NumberSettingsPanel';export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';

View File

@@ -6,6 +6,7 @@ import { Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price';
import { articleSku } from '../../pages/artikel/articleRoute';
import ArticlePhoto from '../article/ArticlePhoto';
import { BrandLabel } from '../brand';
export class ArticleTile extends Tile {
hasPhoto() {
@@ -21,8 +22,23 @@ export class ArticleTile extends Tile {
const { article } = this.props;
if (!article) return null;
const sku = articleSku(article);
const brand = article.brand;
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 ? (
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
{sku}
@@ -83,6 +99,20 @@ export class ArticleTile extends Tile {
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 ? (
<Typography
variant="body2"

View File

@@ -8,6 +8,7 @@ import InfoIcon from '@mui/icons-material/Info';
import ViewListIcon from '@mui/icons-material/ViewList';
import CategoryIcon from '@mui/icons-material/Category';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import CopyrightIcon from '@mui/icons-material/Copyright';
import ListAltIcon from '@mui/icons-material/ListAlt';
import GroupsIcon from '@mui/icons-material/Groups';
import AddBoxIcon from '@mui/icons-material/AddBox';
@@ -103,6 +104,7 @@ export const SUB_NAV = {
{ segment: '', label: 'Liste', title: 'Artikel', icon: ViewListIcon },
{ segment: 'kategorien', label: 'Kategorieen', title: 'Kategorien', icon: CategoryIcon },
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
{ segment: 'marken', label: 'Marken', icon: CopyrightIcon },
],
'/lieferung': [
{ segment: '', label: 'Liste', title: 'Lieferungen', icon: ListAltIcon },

View File

@@ -30,6 +30,7 @@ import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats';
import { BrandLabel } from '../../components/brand';
const resultRowSx = {
alignItems: 'center',
@@ -429,7 +430,24 @@ class ArtikelSearchPage extends Component {
variant="body2"
color="text.secondary"
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}
{' · '}
Bestand {formatStock(row.stock)}

View 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
View 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');
}