This commit is contained in:
seb
2026-09-27 16:19:08 +02:00
parent 559307675c
commit 0f875f347e
16 changed files with 1768 additions and 47 deletions

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

@@ -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

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

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"