601 lines
20 KiB
JavaScript
601 lines
20 KiB
JavaScript
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"</ToggleButton>
|
||
<ToggleButton value="4">4"</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;
|