Files
dashfront/src/routes/artikel/vorlagen.jsx
2026-10-28 00:08:45 +01:00

601 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PureComponent, lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import AppDialog from '../../components/AppDialog';
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 BackendError from '../../components/BackendError';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats/navigationAutomat';
import { labeltemplate } from '../../api';
import { enqueueSnackbar } from 'notistack';
import {
BLUE_BG,
SURFACE_BASE,
} from '../../colors';
const CanvasEditor = lazy(async () => {
await import('react-canvas-label-editor/style.css');
const mod = await import('react-canvas-label-editor');
return { default: mod.CanvasEditor };
});
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 (
<AppDialog
open={editorOpen}
onClose={this.handleEditorClose}
maxWidth={false}
slotProps={{
paper: {
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}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
},
htmlInput: {
min: MIN_HEIGHT_MM,
max: 300,
step: 1,
},
}}
sx={{ width: 120 }}
/>
</Box>
<Box sx={{ overflowX: 'auto', minHeight: minHeightPx }}>
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: editorHeight, minHeight: minHeightPx }}>
<CircularProgress size={32} />
</Box>
}
>
<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 })}
/>
</Suspense>
</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>
</AppDialog>
);
}
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={BLUE_BG}>
<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: '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: SURFACE_BASE,
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;