This commit is contained in:
seb
2026-09-29 09:30:43 +02:00
parent c4f8ebb41b
commit 18419b1615
33 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
import { Navigate, useParams } from 'react-router-dom';
import { KATEGORIEN_BASE } from './categoryRoute';
export default function RedirectArtikelKategorien() {
const { '*': rest = '' } = useParams();
const suffix = rest ? `/${rest}` : '';
return <Navigate to={`${KATEGORIEN_BASE}${suffix}`} replace />;
}

118
src/routes/artikel/_sku.jsx Normal file
View File

@@ -0,0 +1,118 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { StorageManagerContext } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { ArticleView } from '../../components/article';
import {
artikelAutomat,
createArticleSelector,
getArticle,
setLastArticleSku,
} from '../../automats';
import withSkuRoute from './withSkuRoute';
/** Full-screen loading state while the shared article cache resolves. */
function ArticleLoading() {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={32} />
</Box>
);
}
class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext;
constructor(props, context) {
super(props, context);
this.state = {
article: getArticle(props.sku),
ready: context.getCategoryState() != null,
error: null,
};
}
componentDidMount() {
this._mounted = true;
this.installEscape();
setLastArticleSku(this.props.sku);
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
void this.ensureArticle();
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku);
this.setState({ article: getArticle(this.props.sku), error: null });
this.unsub?.();
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
void this.ensureArticle();
}
}
componentWillUnmount() {
this._mounted = false;
this.removeEscape();
this.unsub?.();
this.unsub = null;
}
/**
* Delegates the article fetch to the shared artikelAutomat cache; also waits
* for the category tree/company/session the view tiles depend on.
*/
async ensureArticle() {
const sku = this.props.sku;
if (!sku) return;
try {
await Promise.all([
artikelAutomat.actions.loadArticle(sku),
this.context.categoryTreePromise,
this.context.companyPromise,
this.context.authMePromise,
]);
if (this._mounted) this.setState({ ready: true });
} catch (error) {
if (this._mounted) this.setState({ error });
}
}
handleRetry = () => {
this.setState({ error: null });
void this.ensureArticle();
};
installEscape() {
this._onKeyDown = (e) => {
if (e.key !== 'Escape') return;
e.preventDefault();
this.props.navigate(-1);
};
document.addEventListener('keydown', this._onKeyDown);
}
removeEscape() {
if (this._onKeyDown) {
document.removeEventListener('keydown', this._onKeyDown);
this._onKeyDown = null;
}
}
render() {
const { sku } = this.props;
const { article, ready, error } = this.state;
if (error) {
return <BackendError error={error} sx={{ mt: 2 }} onRetry={this.handleRetry} />;
}
if (!article || !ready) return <ArticleLoading />;
return <ArticleView sku={sku} />;
}
}
export default withSkuRoute(ArtikelSkuPage);

View File

@@ -0,0 +1,555 @@
import { Component, PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import CategorySortable from './CategorySortable';
import CategoryPickerDialog from '../../components/article/CategoryPickerDialog';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
import { notifyError } from '../../utils/notify';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import {
ArticleTile,
BackTile,
CategoryTile,
} from '../../components/tiles';
import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import {
buildCategoryPath,
defaultCategoryPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
import {
categoryDisplayName,
getCategoryBreadcrumbs,
getCategoryChildren,
getCategoryDescendantIds,
} from '../../utils/categoryUtils';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
const { categories, rootCategoryId, renameMode, treeStatus, error } = s;
return {
categories,
rootCategoryId,
renameMode,
treeStatus,
error,
};
}
function selectTerminals(s) {
return { terminals: s.terminals };
}
const gridSx = {
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`,
gap: 2,
};
class ArtikelKategorienPage extends PureComponent {
constructor(props) {
super(props);
const catState = selectFromCategory(categoryAutomat.state);
const { categories, rootCategoryId, treeStatus } = catState;
let resolvedCategoryId = null;
if (treeStatus === 'succeeded' && categories && rootCategoryId != null) {
resolvedCategoryId = resolveCategoryIdFromSlugPath(
categories,
rootCategoryId,
props.categorySlugPath,
);
}
this.handle = categoryAutomat.key(props.categorySlugPath ?? '');
const view = this.handle?.state;
const catId = view?.data?.categoryId ?? resolvedCategoryId;
this.state = {
drafts: {},
...catState,
resolvedCategoryId: catId,
articles: view?.data?.articles ?? [],
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null,
};
this.gridRef = createRef();
this.sortableModule = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
componentDidMount() {
setLastArticleSku(null);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
companyId: s.current?.id ?? null,
}));
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.subscribeViewHandle();
this.syncSortable();
}
componentDidUpdate(prevProps, prevState) {
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded();
}
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
this.syncCategoryFromUrl();
}
if (
this.props.categorySlugPath !== prevProps.categorySlugPath ||
this.state.categories !== prevState.categories ||
this.state.rootCategoryId !== prevState.rootCategoryId
) {
this.syncCategoryFromUrl();
}
if (
this.state.renameMode !== prevState.renameMode ||
(this.state.renameMode && this.state.resolvedCategoryId !== prevState.resolvedCategoryId)
) {
this.syncSortable();
}
}
componentWillUnmount() {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this._unsubHandle?.();
this.sortableModule?.destroy();
}
subscribeViewHandle() {
this._unsubHandle?.();
this._unsubHandle = null;
if (this.handle) {
this._unsubHandle = this.handle.subscribe(this, (item) => {
const catId = item.data?.categoryId ?? this.state.resolvedCategoryId;
const categories = this.state.categories;
return {
resolvedCategoryId: catId,
articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: item.loaded,
loading: item.loading,
routeError: item.error,
};
});
void this.handle.load().catch(() => {});
}
}
#ensureModules = () => {
const el = this.gridRef.current;
if (!el) return;
if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, {
onReorder: ({ categoryIds }) =>
categoryAutomat.actions.reorderCategories({
parentId: this.state.resolvedCategoryId,
categoryIds,
}),
});
}
};
syncSortable = () => {
this.#ensureModules();
if (this.state.renameMode) {
this.sortableModule?.destroy();
this.sortableModule?.activate();
} else {
this.sortableModule?.destroy();
}
};
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return;
void categoryAutomat.actions.loadTree();
};
ensurePosterminalsLoaded = () => {
const { companyId } = this.state;
if (!companyId) return;
void posterminalAutomat.actions.load();
};
syncCategoryFromUrl = () => {
const {
categories,
rootCategoryId,
treeStatus,
resolvedCategoryId,
} = this.state;
const { categorySlugPath, navigate } = this.props;
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
const segments = (categorySlugPath ?? '').split('/').filter(Boolean);
const hasLegacyHomePrefix = segments[0] === ROOT_CATEGORY_NAME;
const resolvedId = resolveCategoryIdFromSlugPath(
categories,
rootCategoryId,
categorySlugPath,
);
if (resolvedId == null) {
navigate(defaultCategoryPath(), { replace: true });
return;
}
if (hasLegacyHomePrefix) {
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
}
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
if (this.handle !== nextHandle) {
this.handle = nextHandle;
const view = this.handle.state;
const catId = view?.data?.categoryId ?? resolvedId;
this.setState({
resolvedCategoryId: catId,
articles: view?.data?.articles ?? [],
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
});
this.subscribeViewHandle();
} else if (resolvedId !== resolvedCategoryId) {
this.setState({
resolvedCategoryId: resolvedId,
childCategories: getCategoryChildren(categories, resolvedId),
});
}
};
navigateToCategoryById = (categoryId) => {
const { categories, rootCategoryId } = this.state;
this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
};
handleDraftChange(categoryId, value) {
this.setState((state) => ({
drafts: { ...state.drafts, [categoryId]: value },
}));
}
handleRenameSubmit = async (categoryId, name) => {
const trimmed = String(name ?? '').trim();
if (!trimmed) {
enqueueSnackbar('Name ist erforderlich.', { variant: 'error' });
return;
}
try {
await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
} catch (err) {
notifyError(err);
}
};
handleOpenMoveCategory = (category) => {
this.setState({ moveCategory: category });
};
handleCloseMoveCategory = () => {
this.setState({ moveCategory: null });
};
handleConfirmMoveCategory = async (targetParentId) => {
const { moveCategory } = this.state;
if (!moveCategory || targetParentId == null) return;
if (String(targetParentId) === String(moveCategory.parentId)) {
this.setState({ moveCategory: null });
return;
}
try {
await categoryAutomat.actions.moveCategory({
categoryId: moveCategory.id,
parentId: targetParentId,
});
enqueueSnackbar(`Kategorie „${moveCategory.name}“ verschoben.`, { variant: 'success' });
this.setState({ moveCategory: null });
} catch (err) {
notifyError(err);
}
};
handleRetryTree = () => {
if (this.state.companyId) void categoryAutomat.actions.loadTree();
};
handleRetryArticles = () => {
if (this.handle) {
void this.handle.reload().catch(() => {});
}
};
handleNavigateBack = () => {
const { categories, resolvedCategoryId, rootCategoryId } = this.state;
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
};
handleArticleClick = (article) => {
const path = artikelDetailPath(article);
if (path) this.props.navigate(path);
};
render() {
const {
companyId,
categories,
rootCategoryId,
treeStatus,
error,
resolvedCategoryId,
articles,
loaded,
loading,
routeError,
renameMode,
terminals,
drafts,
moveCategory,
} = this.state;
if (!companyId) {
return (
<Typography color="text.secondary">
Kein Unternehmen ausgewählt.
</Typography>
);
}
if (treeStatus === 'loading' || treeStatus === 'idle') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (treeStatus === 'failed') {
return (
<BackendError
error={error}
onRetry={this.handleRetryTree}
/>
);
}
if (rootCategoryId == null) {
return (
<Typography color="error">
rootCategoryId fehlt.
</Typography>
);
}
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
const childCategories =
this.state.childCategories && this.state.childCategories.length > 0
? this.state.childCategories
: getCategoryChildren(categories, resolvedCategoryId);
const canGoBack =
resolvedCategoryId != null &&
rootCategoryId != null &&
String(resolvedCategoryId) !== String(rootCategoryId) &&
categories?.[String(resolvedCategoryId)]?.parentId != null &&
categories[String(resolvedCategoryId)].parentId !== 0;
const showArticlesLoading = loading;
return (
<>
<CategoryBreadcrumbs
crumbs={breadcrumbs}
onNavigate={this.navigateToCategoryById}
/>
{routeError && (
<BackendError
error={routeError}
sx={{ mb: 2 }}
onRetry={this.handleRetryArticles}
/>
)}
<Box ref={this.gridRef} sx={gridSx}>
{canGoBack && (
<BackTile
onClick={this.handleNavigateBack}
data-id={breadcrumbs.length >= 2 ? String(breadcrumbs[breadcrumbs.length - 2].id) : undefined}
className="sortable-item not-sortable"
/>
)}
{childCategories.map((item) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
const draftName =
drafts[item.id] !== undefined
? drafts[item.id]
: label;
const editProps = renameMode
? {
edit: true,
'data-id': String(item.id),
className: 'sortable-item',
label: draftName,
terminals,
categoryPosterminalIds: item.posterminalIds || [],
onTerminalToggle: (terminalId, enabled) => {
const action = enabled
? posterminalAutomat.actions.enableCategory
: posterminalAutomat.actions.disableCategory;
void action({ terminalId, categoryId: item.id })
.then(() => {
categoryAutomat.actions.patchCategoryPosterminalIds({
categoryId: item.id,
terminalId,
enabled,
});
})
.catch(notifyError);
},
onPlacementClick: () => this.handleOpenMoveCategory(item),
onOpen: () => this.navigateToCategoryById(item.id),
onPhotoUpload: () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const avifFile = await prepareArticlePhoto(file);
await categoryAutomat.actions.uploadCategoryImage({
categoryId: item.id,
file: avifFile,
});
} catch (err) {
notifyError(err);
}
};
input.click();
},
onPhotoDelete: () => {
void categoryAutomat.actions.deleteCategoryImage(item.id)
.catch(notifyError);
},
nameInput: (
<TextField
value={draftName}
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void this.handleRenameSubmit(item.id, draftName);
}
}}
variant="standard"
size="small"
slotProps={{ inputLabel: { shrink: true }, input: { spellCheck: false } }}
sx={{
width: '100%',
'& .MuiInput-underline:before, & .MuiInput-underline:after': {
borderBottom: 'none',
},
'& .MuiInputBase-input': {
textAlign: 'center',
fontWeight: 600,
py: '6px',
},
}}
/>
),
}
: {
label,
onClick: () => this.navigateToCategoryById(item.id),
};
return (
<CategoryTile
key={item.id}
thumbHash={item.thumbHash}
fullHash={item.fullHash}
{...editProps}
/>
);
})}
{!renameMode && articles.map((item, index) => (
<ArticleTile
key={item.id}
label={item.name != null ? item.name.trim() : undefined}
article={item}
disabled={!articleSku(item)}
onClick={() => this.handleArticleClick(item)}
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
{renameMode && loaded && (
<NewArticleTile
parentId={resolvedCategoryId}
onOpenArticle={this.handleArticleClick}
sx={{ gridColumnStart: 1 }}
/>
)}
</Box>
{showArticlesLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{moveCategory && (
<CategoryPickerDialog
open={Boolean(moveCategory)}
onClose={this.handleCloseMoveCategory}
categories={categories}
rootCategoryId={rootCategoryId}
title={`„${categoryDisplayName(moveCategory.name, moveCategory.id, rootCategoryId)}“ verschieben nach…`}
confirmLabel="Hierher verschieben"
mode="single"
selectedId={moveCategory.parentId}
excludeIds={getCategoryDescendantIds(categories, moveCategory.id)}
onSelectCategory={this.handleConfirmMoveCategory}
/>
)}
</>
);
}
}
export default withCategoryRoute(ArtikelKategorienPage);

View File

@@ -0,0 +1,421 @@
import { PureComponent, createRef } 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 IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import Sortable from 'sortablejs';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import withNavigate from '../../components/withNavigate';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats';
import warehouseAutomat from '../../automats/warehouseAutomat';
function draftsFrom(warehouses) {
return Object.fromEntries(warehouses.map((row) => [row.id, row.name]));
}
function getInitialParams(props) {
const searchParams =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const prefill =
props.location?.state?.name ??
props.location?.state?.newName ??
searchParams?.get('name') ??
searchParams?.get('newName') ??
'';
const returnTo =
props.location?.state?.returnTo ??
searchParams?.get('returnTo') ??
null;
return { prefill: prefill.trim(), returnTo };
}
class ArtikelLagerPage extends PureComponent {
constructor(props) {
super(props);
const { prefill } = getInitialParams(props);
const initialWarehouses = warehouseAutomat.state.warehouses || [];
this.state = {
status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading',
error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null,
warehouses: initialWarehouses,
drafts: draftsFrom(initialWarehouses),
newName: prefill,
busyKey: null,
};
this._cancelled = false;
this.listRef = createRef();
this._sortable = null;
this.handleCreate = this.handleCreate.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.loadWarehouses = this.loadWarehouses.bind(this);
this._initSortable = this._initSortable.bind(this);
this._destroySortable = this._destroySortable.bind(this);
}
componentDidMount() {
setLastArticleSku(null);
const { prefill } = getInitialParams(this.props);
if (prefill && !this.state.newName) {
this.setState({ newName: prefill });
}
this._cancelled = false;
this.unsub = warehouseAutomat.subscribe((state) => {
if (this._cancelled) return;
if (state.status === 'ready') {
this.applyWarehouses(state.warehouses);
} else if (state.status === 'failed') {
this.setState({ status: 'failed', error: backendErrorFrom(state.error) });
}
});
void this.loadWarehouses();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.location !== this.props.location) {
const { prefill } = getInitialParams(this.props);
if (prefill && prefill !== this.state.newName) {
this.setState({ newName: prefill });
}
}
// Re-initialise Sortable when warehouses change or list mounts
if (prevState.warehouses !== this.state.warehouses && this.listRef.current) {
this._initSortable();
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
this._destroySortable();
}
_initSortable() {
this._destroySortable();
const el = this.listRef.current;
if (!el) return;
this._sortable = Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
this.setState((prev) => {
const next = [...prev.warehouses];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
return { warehouses: next };
}, () => {
const ids = this.state.warehouses.map((w) => w.id);
void this._persistOrder(ids);
});
},
});
}
_destroySortable() {
this._sortable?.destroy();
this._sortable = null;
}
async _persistOrder(warehouseIds) {
try {
await warehouseAutomat.actions.reorder(warehouseIds);
} catch (err) {
if (this._cancelled) return;
notifyError(err);
// Roll back optimistic reorder
void this.refreshFromServer();
}
}
applyWarehouses(warehouses) {
// Preserve order returned by the server (already sorted by sort/name from backend)
this.setState((prev) => {
const currentDrafts = prev.drafts || {};
const nextDrafts = { ...draftsFrom(warehouses) };
// Preserve unsaved in-progress edits for still-existing items if not busy
for (const wh of warehouses) {
if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) {
nextDrafts[wh.id] = currentDrafts[wh.id];
}
}
return {
status: 'ready',
error: null,
warehouses,
drafts: nextDrafts,
};
});
}
async loadWarehouses() {
if (warehouseAutomat.state.status !== 'ready') {
this.setState({ status: 'loading', error: null });
}
try {
const list = await warehouseAutomat.actions.load();
if (this._cancelled) return;
this.applyWarehouses(list);
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.loadWarehouses();
}
async refreshFromServer() {
const list = await warehouseAutomat.actions.reload();
if (this._cancelled) return;
this.applyWarehouses(list);
}
async handleCreate() {
const name = this.state.newName.trim();
if (!name || this.state.busyKey != null) return;
const { returnTo } = getInitialParams(this.props);
this.setState({ busyKey: 'new' });
try {
const newWarehouse = await warehouseAutomat.actions.create({ name });
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ newName: '', busyKey: null });
enqueueSnackbar(`Lager „${newWarehouse.name || name}" angelegt.`, { variant: 'success' });
if (returnTo && this.props.navigate) {
this.props.navigate(returnTo);
}
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ busyKey: null });
}
}
async handleRename(wh) {
const draft = String(this.state.drafts[wh.id] ?? '').trim();
if (!draft) {
this.setState((prev) => ({
drafts: { ...prev.drafts, [wh.id]: wh.name },
}));
return;
}
if (draft === wh.name || this.state.busyKey != null) return;
this.setState({ busyKey: wh.id });
try {
await warehouseAutomat.actions.update(wh.id, { name: draft });
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ busyKey: null });
enqueueSnackbar('Lager gespeichert.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState((prev) => ({
busyKey: null,
drafts: { ...prev.drafts, [wh.id]: wh.name },
}));
}
}
async handleDelete(wh) {
if (this.state.busyKey != null) return;
if (!window.confirm(`Lager „${wh.name}" wirklich löschen?`)) {
return;
}
this.setState({ busyKey: wh.id });
try {
await warehouseAutomat.actions.remove(wh.id);
await this.refreshFromServer();
if (this._cancelled) return;
this.setState({ busyKey: null });
enqueueSnackbar('Lager gelöscht.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ busyKey: null });
}
}
render() {
const { status, error, warehouses, drafts, newName, busyKey } = this.state;
const busy = busyKey != null;
const { returnTo } = getInitialParams(this.props);
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 (
<>
{/* SortableJS ghost / chosen styling */}
<style>{`
.sortable-ghost { opacity: 0.35 !important; }
.sortable-chosen { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.drag-handle { cursor: grab; touch-action: none; }
.drag-handle:active { cursor: grabbing; }
`}</style>
<PanelTileGrid>
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
<Stack spacing={2}>
{returnTo ? (
<Alert
severity="info"
sx={{ py: 0.5, alignItems: 'center' }}
action={
<Button
color="inherit"
size="small"
onClick={() => this.props.navigate?.(returnTo)}
>
Zurück
</Button>
}
>
{newName
? `Lagername „${newName}" übernommen.`
: 'Neues Lager anlegen.'}
</Alert>
) : null}
<Typography variant="body2" color="text.secondary">
Reihenfolge per Drag &amp; Drop anpassen.
</Typography>
<List dense disablePadding ref={this.listRef}>
{warehouses.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Noch keine Lager angelegt.
</Typography>
) : (
warehouses.map((wh) => (
<ListItem
key={wh.id}
disableGutters
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
data-id={wh.id}
>
<Box
className="drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
color: 'text.disabled',
flexShrink: 0,
'&:hover': { color: 'text.secondary' },
transition: 'color 0.15s',
}}
>
<DragIndicatorIcon fontSize="small" />
</Box>
<WarehouseIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<SingleLineTextEdit
size="small"
label=""
value={drafts[wh.id] ?? ''}
disabled={busy}
onChange={(next) =>
this.setState((prev) => ({
drafts: { ...prev.drafts, [wh.id]: next },
}))
}
onBlur={() => void this.handleRename(wh)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
if (event.key === 'Escape') {
event.preventDefault();
this.setState((prev) => ({
drafts: { ...prev.drafts, [wh.id]: wh.name },
}));
event.target.blur();
}
}}
sx={{ flex: 1 }}
/>
<IconButton
edge="end"
size="small"
aria-label={`„${wh.name}" löschen`}
disabled={busy}
onClick={() => void this.handleDelete(wh)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItem>
))
)}
</List>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
size="small"
label="Neues Lager"
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>
</Stack>
</PanelTile>
</PanelTileGrid>
</>
);
}
}
export default withNavigate(ArtikelLagerPage);

View File

@@ -0,0 +1,293 @@
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">
<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;

View File

@@ -0,0 +1,594 @@
import { PureComponent } from 'react';
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 InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditIcon from '@mui/icons-material/Edit';
import LabelIcon from '@mui/icons-material/Label';
import { CanvasEditor } from 'react-canvas-label-editor';
import 'react-canvas-label-editor/style.css';
import BackendError from '../../components/BackendError';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats';
import { labeltemplate } from '../../api';
import { enqueueSnackbar } from 'notistack';
const MIN_HEIGHT_MM = 10;
const DEFAULT_HEIGHT_MM = 25;
const DEFAULT_DPI = 203;
function getCanvasWidth(widthClass, dpi) {
if (widthClass === '4') {
return dpi === 300 ? 1248 : 832;
}
return dpi === 300 ? 640 : 448;
}
function getMinHeightPx(dpi) {
return Math.max(80, Math.round((MIN_HEIGHT_MM * dpi) / 25.4));
}
function mmToPx(mm, dpi) {
return Math.round((Number(mm) * dpi) / 25.4);
}
function pxToMm(px, dpi) {
return Number(((px / dpi) * 25.4).toFixed(1));
}
class ArtikelVorlagenPage extends PureComponent {
constructor(props) {
super(props);
const initialDpi = DEFAULT_DPI;
const initialWidthClass = '2';
const initialHeightMm = DEFAULT_HEIGHT_MM;
this.state = {
status: 'loading',
error: null,
templates: [],
// Editor dialog state
editorOpen: false,
editorLoading: false,
editorTemplate: null, // null = new, object = edit
editorName: '',
editorObjects: [],
editorDpi: initialDpi,
editorWidthClass: initialWidthClass,
editorWidth: getCanvasWidth(initialWidthClass, initialDpi),
editorHeightMm: initialHeightMm,
editorHeight: mmToPx(initialHeightMm, initialDpi),
editorClipboard: null,
saving: false,
// Delete busy
deletingId: null,
};
this._cancelled = false;
this._currentEditId = null;
this.handleRetry = this.handleRetry.bind(this);
this.handleOpenNew = this.handleOpenNew.bind(this);
this.handleOpenEdit = this.handleOpenEdit.bind(this);
this.handleEditorClose = this.handleEditorClose.bind(this);
this.handleEditorSave = this.handleEditorSave.bind(this);
this.handleWidthClassChange = this.handleWidthClassChange.bind(this);
this.handleDpiChange = this.handleDpiChange.bind(this);
this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
}
componentDidMount() {
setLastArticleSku(null);
this._cancelled = false;
void this.loadTemplates();
}
componentWillUnmount() {
this._cancelled = true;
}
async loadTemplates() {
this.setState({ status: 'loading', error: null });
try {
const { labeltemplates } = await labeltemplate.list();
if (this._cancelled) return;
this.setState({ status: 'ready', templates: labeltemplates || [] });
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.loadTemplates();
}
handleOpenNew() {
this._currentEditId = null;
const dpi = DEFAULT_DPI;
const widthClass = '2';
const width = getCanvasWidth(widthClass, dpi);
const heightMm = DEFAULT_HEIGHT_MM;
const height = mmToPx(heightMm, dpi);
this.setState({
editorOpen: true,
editorLoading: false,
editorTemplate: null,
editorName: 'Neue Vorlage',
editorObjects: [],
editorDpi: dpi,
editorWidthClass: widthClass,
editorWidth: width,
editorHeightMm: heightMm,
editorHeight: height,
});
}
async handleOpenEdit(tmpl) {
const fetchId = tmpl.id;
this._currentEditId = fetchId;
const initialDpi = tmpl.dpi === 300 ? 300 : 203;
const initialWidthClass = (tmpl.width || 0) >= 800 ? '4' : '2';
const initialWidth = tmpl.width || getCanvasWidth(initialWidthClass, initialDpi);
const initialHeight = tmpl.height || mmToPx(DEFAULT_HEIGHT_MM, initialDpi);
const initialHeightMm = pxToMm(initialHeight, initialDpi);
this.setState({
editorOpen: true,
editorLoading: true,
editorTemplate: tmpl,
editorName: tmpl.name,
editorObjects: [],
editorDpi: initialDpi,
editorWidthClass: initialWidthClass,
editorWidth: initialWidth,
editorHeightMm: initialHeightMm,
editorHeight: initialHeight,
});
try {
const { labeltemplate: full } = await labeltemplate.get(fetchId);
if (this._cancelled || this._currentEditId !== fetchId || !this.state.editorOpen) return;
const content = full?.content || {};
const dpi = full?.dpi === 300 ? 300 : 203;
const widthClass = (full?.width || 0) >= 800 ? '4' : '2';
const width = full?.width || getCanvasWidth(widthClass, dpi);
const height = full?.height || mmToPx(DEFAULT_HEIGHT_MM, dpi);
const heightMm = pxToMm(height, dpi);
this.setState({
editorLoading: false,
editorTemplate: full,
editorName: full.name ?? tmpl.name,
editorObjects: content.objects || [],
editorDpi: dpi,
editorWidthClass: widthClass,
editorWidth: width,
editorHeightMm: heightMm,
editorHeight: height,
});
} catch (err) {
if (this._cancelled || this._currentEditId !== fetchId) return;
notifyError(err);
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
}
}
handleWidthClassChange(event, nextValue) {
if (!nextValue) return;
const width = getCanvasWidth(nextValue, this.state.editorDpi);
this.setState({
editorWidthClass: nextValue,
editorWidth: width,
});
}
handleDpiChange(event, nextValue) {
if (!nextValue) return;
const dpi = Number(nextValue);
const width = getCanvasWidth(this.state.editorWidthClass, dpi);
const mm = parseFloat(this.state.editorHeightMm) || DEFAULT_HEIGHT_MM;
const minHeightPx = getMinHeightPx(dpi);
const height = Math.max(minHeightPx, mmToPx(mm, dpi));
this.setState({
editorDpi: dpi,
editorWidth: width,
editorHeight: height,
});
}
handleHeightMmChange(event) {
const val = event.target.value;
const dpi = this.state.editorDpi;
const num = parseFloat(val);
const minHeightPx = getMinHeightPx(dpi);
const height = !isNaN(num) && num > 0
? Math.max(minHeightPx, mmToPx(num, dpi))
: this.state.editorHeight;
this.setState({
editorHeightMm: val,
editorHeight: height,
});
}
handleHeightMmBlur() {
const dpi = this.state.editorDpi;
const num = parseFloat(this.state.editorHeightMm);
const safeMm = !isNaN(num) && num >= MIN_HEIGHT_MM ? num : MIN_HEIGHT_MM;
const minHeightPx = getMinHeightPx(dpi);
this.setState({
editorHeightMm: safeMm,
editorHeight: Math.max(minHeightPx, mmToPx(safeMm, dpi)),
});
}
handleEditorClose() {
if (this.state.saving) return;
this._currentEditId = null;
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
}
async handleEditorSave() {
const {
editorTemplate, editorName, editorObjects,
editorHeight, editorWidth, editorDpi,
} = this.state;
const name = editorName.trim();
if (!name) return;
this.setState({ saving: true });
const body = {
name,
width: editorWidth,
height: editorHeight,
dpi: editorDpi,
type: 'zpl',
content: { objects: editorObjects },
};
try {
if (editorTemplate) {
const { labeltemplate: updated } = await labeltemplate.update(editorTemplate.id, body);
if (this._cancelled) return;
this.setState((prev) => ({
saving: false,
editorOpen: false,
templates: prev.templates.map((t) => (t.id === updated.id ? updated : t)),
}));
enqueueSnackbar(`Vorlage „${updated.name}" gespeichert.`, { variant: 'success' });
} else {
const { labeltemplate: created } = await labeltemplate.create(body);
if (this._cancelled) return;
this.setState((prev) => ({
saving: false,
editorOpen: false,
templates: [...prev.templates, created],
}));
enqueueSnackbar(`Vorlage „${created.name}" angelegt.`, { variant: 'success' });
}
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
}
async handleDelete(tmpl) {
if (this.state.deletingId != null) return;
if (!window.confirm(`Vorlage „${tmpl.name}" wirklich löschen?`)) return;
this.setState({ deletingId: tmpl.id });
try {
await labeltemplate.delete(tmpl.id);
if (this._cancelled) return;
this.setState((prev) => ({
deletingId: null,
templates: prev.templates.filter((t) => t.id !== tmpl.id),
}));
enqueueSnackbar('Vorlage gelöscht.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ deletingId: null });
}
}
renderEditor() {
const {
editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
editorClipboard, saving,
} = this.state;
const minHeightPx = getMinHeightPx(editorDpi);
return (
<Dialog
open={editorOpen}
onClose={this.handleEditorClose}
maxWidth={false}
PaperProps={{
sx: {
width: '90vw',
maxWidth: 1000,
bgcolor: 'background.paper',
},
}}
>
<DialogTitle sx={{ pb: 1 }}>
{editorTemplate ? `„${editorTemplate.name}" bearbeiten` : 'Neue Vorlage'}
</DialogTitle>
<DialogContent sx={{ pt: '8px !important' }}>
{editorLoading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 10, gap: 2 }}>
<CircularProgress />
<Typography variant="body2" color="text.secondary">
Vorlage wird geladen…
</Typography>
</Box>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<TextField
label="Name"
size="small"
value={editorName}
onChange={(e) => this.setState({ editorName: e.target.value })}
disabled={saving}
sx={{ flex: 1, minWidth: 200 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Breite:</Typography>
<ToggleButtonGroup
size="small"
value={editorWidthClass}
exclusive
onChange={this.handleWidthClassChange}
disabled={saving}
>
<ToggleButton value="2">2&quot;</ToggleButton>
<ToggleButton value="4">4&quot;</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Auflösung:</Typography>
<ToggleButtonGroup
size="small"
value={String(editorDpi)}
exclusive
onChange={this.handleDpiChange}
disabled={saving}
>
<ToggleButton value="203">203 DPI</ToggleButton>
<ToggleButton value="300">300 DPI</ToggleButton>
</ToggleButtonGroup>
</Box>
<TextField
label="Höhe"
size="small"
type="number"
value={editorHeightMm}
onChange={this.handleHeightMmChange}
onBlur={this.handleHeightMmBlur}
disabled={saving}
InputProps={{
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
}}
inputProps={{
min: MIN_HEIGHT_MM,
max: 300,
step: 1,
}}
sx={{ width: 120 }}
/>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<CanvasEditor
width={editorWidth}
height={editorHeight}
minHeight={minHeightPx}
showWidthUI={false}
showHeightUI={false}
dpi={editorDpi}
objects={editorObjects}
onChange={(objects) => this.setState({ editorObjects: objects })}
clipboard={editorClipboard}
onCopy={(obj) => this.setState({ editorClipboard: obj })}
/>
</Box>
</>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={this.handleEditorClose} disabled={saving}>
Abbrechen
</Button>
<Button
variant="contained"
onClick={this.handleEditorSave}
disabled={saving || editorLoading || !editorName.trim()}
>
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
{saving ? 'Speichert…' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
);
}
render() {
const { status, error, templates, deletingId } = 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 (
<>
<PanelTileGrid>
<PanelTile wide={3} color="#dbeafe">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', mb: 2 }}>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={this.handleOpenNew}
>
Neue Vorlage
</Button>
</Box>
{templates.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
py: 6,
color: 'text.disabled',
}}
>
<LabelIcon sx={{ fontSize: 48 }} />
<Typography variant="body2">Noch keine Vorlagen angelegt.</Typography>
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 2,
}}
>
{templates.map((tmpl) => (
<Box
key={tmpl.id}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Thumbnail */}
<Box
onClick={() => this.handleOpenEdit(tmpl)}
sx={{
height: 100,
bgcolor: '#f8f8f8',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
cursor: 'pointer',
}}
>
{tmpl.thumb ? (
<Box
component="img"
src={`data:image/png;base64,${tmpl.thumb}`}
alt={tmpl.name}
sx={{
maxWidth: '100%',
maxHeight: '100%',
imageRendering: 'pixelated',
objectFit: 'contain',
}}
/>
) : (
<LabelIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
)}
</Box>
{/* Footer */}
<Box sx={{ px: 1.5, py: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography
variant="body2"
sx={{ flex: 1, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={tmpl.name}
>
{tmpl.name}
</Typography>
<Tooltip title="Bearbeiten">
<span>
<IconButton
size="small"
aria-label={`„${tmpl.name}" bearbeiten`}
onClick={() => this.handleOpenEdit(tmpl)}
disabled={deletingId != null}
>
<EditIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Löschen">
<span>
<IconButton
size="small"
aria-label={`„${tmpl.name}" löschen`}
onClick={() => void this.handleDelete(tmpl)}
disabled={deletingId === tmpl.id}
>
{deletingId === tmpl.id
? <CircularProgress size={16} />
: <DeleteOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
{/* Dimensions chip */}
<Divider />
<Typography
variant="caption"
color="text.disabled"
sx={{ px: 1.5, py: 0.5 }}
>
{Number(((tmpl.width / (tmpl.dpi || 203)) * 25.4).toFixed(0))} × {Number(((tmpl.height / (tmpl.dpi || 203)) * 25.4).toFixed(0))} mm ({tmpl.width} × {tmpl.height} px) · {tmpl.dpi || 203} DPI
</Typography>
</Box>
))}
</Box>
)}
</PanelTile>
</PanelTileGrid>
{this.renderEditor()}
</>
);
}
}
export default ArtikelVorlagenPage;