This commit is contained in:
seb
2026-09-21 20:21:57 +02:00
parent 2046f96768
commit 536e7b1e2a
20 changed files with 3047 additions and 672 deletions

View File

@@ -2,7 +2,7 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat';
import artikelAutomat from '../automats/artikelAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
@@ -75,8 +75,6 @@ export class StorageManager extends ApiClient {
#events = null;
/** @type {ReturnType<typeof createCache> | null} */
#articleIndexCache = null;
/** @type {Map<string, object>} sku → article detail */
#articlesBySku = new Map();
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
@@ -92,39 +90,41 @@ export class StorageManager extends ApiClient {
this.watchArticleSuppliers(sku, onSuppliers);
}
/** Settled article detail for sku, or undefined. */
/** Cached article detail from the shared artikelAutomat, or null. */
getArticle(sku) {
return this.#articlesBySku.get(String(sku));
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/** Keep local article detail in sync after GET/PATCH/PUT responses. */
/**
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
* notify any StorageManager-level watchers.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
this.#articlesBySku.set(key, article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const stored = artikelAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key);
if (watchers) {
for (const onArticle of watchers) onArticle(stored);
}
return stored;
}
/**
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
* @param {string} sku
*/
async loadArticle(sku) {
const article = await artikelAutomat.actions.loadArticle(sku);
const watchers = this.#articleWatchers.get(String(sku));
if (watchers) {
for (const onArticle of watchers) onArticle(article);
}
return article;
}
/**
* GET /api/article/{sku}, cache detail, return article.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await this.article.get(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
return this.setArticle(res.article);
}
/**
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
* @param {string} sku
@@ -334,12 +334,12 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length);
// Update automat on SSE article events
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
// Refresh the shared cache; silently when nothing is watching this SKU.
if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err));
void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
} else {
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
}
const historyWatchers = this.#historyWatchers.get(sku);
if (historyWatchers) {
@@ -360,9 +360,9 @@ export class StorageManager extends ApiClient {
);
}
/** Patch local article detail name if already cached; no-op otherwise. */
/** Patch cached article detail name if already cached; no-op otherwise. */
setArticleName(sku, name) {
const current = this.#articlesBySku.get(String(sku));
const current = this.getArticle(sku);
if (!current) return;
this.setArticle({ ...current, name });
}

View File

@@ -0,0 +1,190 @@
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';
/**
* @typedef {Object} ArtikelState
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
*/
/** @type {ArtikelState} */
const initialState = {
bySku: {},
};
/**
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
*
* @type {Map<string, Promise<object>>}
*/
const inflight = new Map();
/** @type {Automat<ArtikelState>} */
let artikelAutomat;
const actions = {
/**
* Loads an article into the shared cache, deduplicating concurrent callers.
* Resolves with the cached article when it is already present.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Loaded article detail.
*/
loadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) {
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
}
const cached = artikelAutomat.state.bySku[key];
if (cached) return Promise.resolve(cached);
const pending = inflight.get(key);
if (pending) return pending;
const request = articleApi
.getBySku(key)
.then((res) => {
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, request);
return request;
},
/**
* Silently re-fetches an article, keeping the cached copy until the new one
* arrives. Use for SSE invalidation and pull-to-refresh.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Fresh article detail.
*/
async reloadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
const res = await articleApi.getBySku(key);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
},
/**
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
* response so every subscriber sees the same object.
*
* @param {object} article Article detail with at least a `sku`.
* @returns {object} The stored article.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
return article;
},
/**
* Patches the cached article name without a round-trip (optimistic update).
*
* @param {string} sku Article SKU.
* @param {string} name New name.
* @returns {object|null} Updated article, or `null` when not cached.
*/
setArticleName(sku, name) {
const key = String(sku);
const current = artikelAutomat.state.bySku[key];
if (!current) return null;
return artikelAutomat.actions.setArticle({ ...current, name });
},
/**
* Drops an article from the cache (e.g. after deletion), forcing the next
* `loadArticle` to hit the backend.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
clearArticle(sku) {
const key = String(sku);
if (!(key in artikelAutomat.state.bySku)) return;
const bySku = { ...artikelAutomat.state.bySku };
delete bySku[key];
artikelAutomat.setState({ bySku });
},
/**
* Forgets every cached article.
*
* @returns {void}
*/
clear() {
artikelAutomat.setState({ bySku: {} });
},
};
/**
* Shared cache for article detail payloads. State lives in module scope so
* navigating between articles (and remounting the view) reuses the cache
* instead of re-fetching.
*
* @type {Automat<ArtikelState>}
*/
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
/**
* Reads a cached article synchronously.
*
* @param {string} sku Article SKU.
* @returns {object|null} Cached article detail, or `null`.
*/
export function getArticle(sku) {
if (sku == null) return null;
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/**
* Whether an article detail payload is currently cached.
*
* @param {string} sku Article SKU.
* @returns {boolean} `true` when cached.
*/
export function hasArticle(sku) {
return getArticle(sku) != null;
}
/**
* Builds a selector for one SKU, so a component can subscribe to just that
* article instead of every cache change.
*
* @param {string} sku Article SKU.
* @returns {(state: ArtikelState) => object|null} Selector function.
*/
export function createArticleSelector(sku) {
const key = sku == null ? null : String(sku);
return (state) => (key == null ? null : state.bySku[key] ?? null);
}
/**
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
*
* @param {string} sku Article SKU.
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
*/
export function selectArticle(sku) {
return artikelAutomat.select(createArticleSelector(sku));
}
export default artikelAutomat;

View File

@@ -3,4 +3,13 @@ export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as artikelAutomat } from './artikelAutomat';
export {
getArticle,
hasArticle,
selectArticle,
createArticleSelector,
} from './artikelAutomat';

View File

@@ -0,0 +1,48 @@
import { Automat } from 'react-automat';
/**
* @typedef {'top'|'right'|'below'} ViewerPosition
*/
/**
* @typedef {Object} UserSettingsState
* @property {ViewerPosition} viewerPosition Preferred docking position of the
* attachment viewer (text editor / PDF preview) relative to the article list.
*/
/** @type {UserSettingsState} */
const initialState = {
viewerPosition: 'top',
};
/**
* Persisted per-user UI preferences. Unlike the other automats this one is
* backed by IndexedDB (`persist: true`) so settings survive reloads and
* sessions.
*
* @type {Automat<UserSettingsState>}
*/
const userSettingsAutomat = new Automat(
initialState,
{
/**
* Set the docking position of the attachment viewer.
*
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
* @returns {void}
*/
setViewerPosition(position) {
if (position !== 'top' && position !== 'right' && position !== 'below') {
return;
}
userSettingsAutomat.setState({ viewerPosition: position });
},
},
{
name: 'userSettings',
persist: true,
}
);
export default userSettingsAutomat;
export const { setViewerPosition } = userSettingsAutomat.actions;

View File

@@ -6,7 +6,7 @@ import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile';
@@ -25,18 +25,11 @@ class ArticleView extends PureComponent {
constructor(props, context) {
super(props, context);
const sku = props.sku;
const currentArticle = articleAutomat.state[sku] ?? null;
this.state = { article: currentArticle };
this.state = { article: getArticle(props.sku) };
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
if (!currentArticle) {
articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.ensureArticle(props.sku);
}
componentDidMount() {
@@ -45,23 +38,24 @@ class ArticleView extends PureComponent {
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
const currentArticle = articleAutomat.state[this.props.sku] ?? null;
this.setState({ article: currentArticle });
if (!currentArticle) {
articleAutomat.actions.loadArticle(this.props.sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.setState({ article: getArticle(this.props.sku) });
this.ensureArticle(this.props.sku);
this.subscribeToArticle();
}
}
/** Loads through the shared cache; resolves instantly when already cached. */
ensureArticle(sku) {
if (!sku) return;
artikelAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
subscribeToArticle() {
if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: selector(state),
}));
}

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import DeleteIcon from '@mui/icons-material/Delete';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { artikelAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify';
export default class ArticleSettingsTab extends Component {
@@ -24,6 +25,7 @@ export default class ArticleSettingsTab extends Component {
this.setState({ busy: true });
try {
await this.context.article.delete(articleData.sku);
artikelAutomat.actions.clearArticle(articleData.sku);
onArticleDeleted?.(articleData);
} catch (err) {
notifyError(err);

View File

@@ -33,10 +33,11 @@ class ManifestLines extends PureComponent {
onSearchArticle,
onRemoveSupplierArticle,
busySupplierArticleLineKeys = {},
wide = 3,
} = this.props;
return (
<PanelTile wide={3} minHeight={280} color={linesColor} title="Artikel">
<PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
<Stack spacing={1.5}>
<TableContainer>
<Table size="small" stickyHeader>

View File

@@ -0,0 +1,296 @@
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 Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline PDF viewer rendered as a panel tile, mirroring TextEditorPanel's
* "window" behaviour: it can sit above, right of, or below the article list,
* and stays sticky when docked to the right. Mount it only while a PDF
* attachment is selected; unmounting closes it.
*/
export class PdfViewerPanel extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
url: null,
loading: false,
error: null,
};
this.handleOpenExternal = this.handleOpenExternal.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadUrl();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.revokeUrl();
this.loadUrl();
}
}
componentWillUnmount() {
this.revokeUrl();
}
revokeUrl() {
if (this._objectUrl) {
URL.revokeObjectURL(this._objectUrl);
this._objectUrl = null;
}
}
async loadUrl() {
const { attachment, docId } = this.props;
if (!attachment) return;
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this._objectUrl = URL.createObjectURL(attachment.file);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
}
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true, error: null });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
this._objectUrl = URL.createObjectURL(blob);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
} catch (err) {
console.error('Fehler beim Laden der PDF-Datei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false, error: 'Fehler beim Laden der Datei' });
return;
}
}
this.setState({ url: null, loading: false, error: null });
}
handleOpenExternal() {
const { url } = this.state;
if (!url) return;
window.open(url, '_blank');
}
handleDownload() {
const { attachment } = this.props;
const { url } = this.state;
if (!url) return;
const link = document.createElement('a');
link.href = url;
link.download = attachment?.name || 'Dokument.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
render() {
const {
onClose,
attachment,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) return null;
const { url, loading, error } = this.state;
const filename = attachment?.name || 'Dokument.pdf';
return (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<PictureAsPdfIcon color="error" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
PDF-Vorschau
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: '#ffffff',
overflow: 'hidden',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
{error ? (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Typography color="text.secondary">{error}</Typography>
</Box>
) : (
url && (
<Box
component="iframe"
title={filename}
src={url}
sx={{ flex: 1, width: '100%', border: 'none' }}
/>
)
)}
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<OpenInNewIcon />}
onClick={this.handleOpenExternal}
disabled={loading || !url}
>
In neuem Tab
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !url}
>
Download
</Button>
</Box>
<Button size="small" onClick={onClose}>
Schließen
</Button>
</Box>
</Box>
</PanelTile>
);
}
}
export default PdfViewerPanel;

View File

@@ -1,340 +0,0 @@
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 IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
export class TextEditorDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
content: '',
initialContent: '',
loading: false,
};
this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) {
if (this.props.open) {
this.loadContent();
} else {
this.setState({ content: '', initialContent: '', loading: false });
}
}
}
async loadContent() {
const { attachment, open, docId, onContentLoad } = this.props;
if (!open || !attachment) return;
if (typeof attachment.text === 'string') {
this.setState({
content: attachment.text,
initialContent: attachment.text,
loading: false,
});
return;
}
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
loading: false,
});
}
handleChange(e) {
this.setState({ content: e.target.value });
}
handleSave() {
const { onSave, onClose } = this.props;
const { content } = this.state;
onSave?.(content);
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
onClose?.();
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.state.content);
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
} catch {
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
render() {
const { open, onClose, attachment, readOnly = false } = this.props;
if (!open) return null;
const { content, initialContent, loading } = this.state;
const filename = attachment?.name || 'Dokument.txt';
const isDirty = content !== initialContent;
const linesCount = content ? content.split('\n').length : 0;
const charCount = content ? content.length : 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
slotProps={{
paper: {
sx: {
height: '85vh',
maxHeight: 850,
display: 'flex',
flexDirection: 'column',
},
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 2.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<TextSnippetOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{linesCount} Zeilen · {charCount} Zeichen
</Typography>
</Box>
{isDirty && (
<Chip label="Ungespeichert" size="small" color="warning" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
</Box>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent
sx={{
p: 2,
flex: 1,
display: 'flex',
flexDirection: 'column',
bgcolor: '#fafafa',
position: 'relative',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(250, 250, 250, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
inputProps={{
wrap: 'off',
spellCheck: false,
}}
slotProps={{
htmlInput: {
wrap: 'off',
spellCheck: false,
},
input: {
readOnly,
sx: {
height: '100%',
alignItems: 'flex-start',
fontFamily: '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.72rem',
lineHeight: 1.45,
tabSize: 4,
bgcolor: '#ffffff',
p: 1.5,
'& textarea': {
height: '100% !important',
whiteSpace: 'pre !important',
overflowWrap: 'normal !important',
wordWrap: 'normal !important',
wordBreak: 'normal !important',
overflowX: 'auto !important',
overflowY: 'auto !important',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
},
},
},
}}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
'& .MuiInputBase-root': {
flex: 1,
},
}}
/>
</DialogContent>
<DialogActions
sx={{
px: 2.5,
py: 1.5,
borderTop: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</Button>
{!readOnly && (
<Button
size="small"
variant="contained"
startIcon={<SaveOutlinedIcon />}
onClick={this.handleSave}
disabled={loading || !isDirty}
>
Speichern
</Button>
)}
</Box>
</DialogActions>
</Dialog>
);
}
}
export default TextEditorDialog;

View File

@@ -0,0 +1,381 @@
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 TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline text-file viewer/editor rendered as a full-width panel tile below the
* manifest header ("Kopfdaten"). Mount it only while a text attachment is
* selected; unmounting closes it.
*/
export class TextEditorPanel extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
content: '',
initialContent: '',
loading: false,
};
this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.loadContent();
}
}
async loadContent() {
const { attachment, docId, onContentLoad } = this.props;
if (!attachment) return;
if (typeof attachment.text === 'string') {
this.setState({
content: attachment.text,
initialContent: attachment.text,
loading: false,
});
return;
}
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
loading: false,
});
}
handleChange(e) {
this.setState({ content: e.target.value });
}
handleSave() {
const { onSave, onClose } = this.props;
const { content } = this.state;
onSave?.(content);
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
onClose?.();
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.state.content);
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
} catch {
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
render() {
const {
onClose,
attachment,
readOnly = false,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) return null;
const { content, initialContent, loading } = this.state;
const filename = attachment?.name || 'Dokument.txt';
const isDirty = content !== initialContent;
const linesCount = content ? content.split('\n').length : 0;
const charCount = content ? content.length : 0;
return (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<TextSnippetOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{linesCount} Zeilen · {charCount} Zeichen
</Typography>
</Box>
{isDirty && (
<Chip
label="Ungespeichert"
size="small"
color="warning"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
slotProps={{
htmlInput: {
wrap: 'off',
spellCheck: false,
},
input: {
readOnly,
sx: {
height: '100%',
alignItems: 'flex-start',
fontFamily:
'"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.72rem',
lineHeight: 1.45,
tabSize: 4,
bgcolor: '#ffffff',
p: 1.5,
'& textarea': {
height: '100% !important',
whiteSpace: 'pre !important',
overflowWrap: 'normal !important',
wordWrap: 'normal !important',
wordBreak: 'normal !important',
overflowX: 'auto !important',
overflowY: 'auto !important',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
},
},
},
}}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
'& .MuiInputBase-root': {
flex: 1,
},
}}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</Button>
{!readOnly && (
<Button
size="small"
variant="contained"
startIcon={<SaveOutlinedIcon />}
onClick={this.handleSave}
disabled={loading || !isDirty}
>
Speichern
</Button>
)}
</Box>
</Box>
</Box>
</PanelTile>
);
}
}
export default TextEditorPanel;

View File

@@ -5,7 +5,8 @@ export { default as ManifestLines } from './ManifestLines';
export { default as ManifestFormLine } from './ManifestFormLine';
export { default as Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog';
export { default as TextEditorPanel } from './TextEditorPanel';
export { default as PdfViewerPanel } from './PdfViewerPanel';
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId';
export {

View File

@@ -1,9 +1,8 @@
import { PureComponent } from 'react';
import { PureComponent, createRef } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
@@ -12,6 +11,7 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import BugReportIcon from '@mui/icons-material/BugReport';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ClearIcon from '@mui/icons-material/Clear';
import CloseIcon from '@mui/icons-material/Close';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -162,11 +162,175 @@ export function formatState(value) {
}
}
/** Maximum number of retained action log entries (ring buffer). */
const MAX_LOG = 300;
/** Marks action functions that already carry the logging proxy. */
const LOGGED = Symbol('automatInspectorLogged');
/** Compact single-line rendering limit for args/results in the log. */
const LOG_VALUE_LIMIT = 240;
/**
* Floating dev button plus overlay listing all registered Automats with live
* state, readiness flags, subscriber counts and available actions.
* Ring buffer of captured action calls, oldest first. Kept at module scope so
* entries survive component re-renders (and are shared by every inspector).
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number}>}
* @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
*/
const actionLog = [];
/** Monotonic id source for log entries. */
let logSeq = 0;
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
const logListeners = new Set();
/** Notifies every log listener, swallowing individual errors. */
function notifyLog() {
for (const listener of logListeners) {
try {
listener();
} catch {
/* listeners are dev-only; never let one break logging */
}
}
}
/**
* Appends an entry to the ring buffer and notifies listeners.
*
* @param {object} entry Log entry.
*/
function pushLog(entry) {
actionLog.push(entry);
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
notifyLog();
}
/**
* Renders a value as a compact single-line string for the log.
*
* @param {*} value Value to summarize.
* @param {number} [limit] Maximum length before truncation.
* @returns {string} Compact representation.
*/
function compact(value, limit = LOG_VALUE_LIMIT) {
const text = formatState(value).replace(/\s+/g, ' ').trim();
return text.length > limit ? `${text.slice(0, limit)} …` : text;
}
/**
* Formats a timestamp as `HH:MM:SS.mmm`.
*
* @param {Date} date Timestamp.
* @returns {string} Formatted time.
*/
function formatTime(date) {
const pad = (n, len = 2) => String(n).padStart(len, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
}
/**
* Wraps every action of an automat with a logging proxy that records call
* arguments, duration, return value and errors. Idempotent: already wrapped
* actions are left untouched, and the original function is always invoked with
* the caller's `this` and arguments.
*
* @param {import('react-automat').Automat} automat Automat instance.
*/
function installActionLogging(automat) {
const actions = automat.actions;
if (!actions) return;
for (const key of Object.keys(actions)) {
const original = actions[key];
if (typeof original !== 'function' || original[LOGGED]) continue;
const wrapped = function loggedAction(...args) {
const started = performance.now();
const entry = {
id: ++logSeq,
time: new Date(),
automat: automat.name ?? '(anonym)',
action: key,
args: compact(args),
status: 'running',
duration: null,
result: '',
error: null,
};
pushLog(entry);
const settle = (status, payload, err) => {
entry.status = status;
entry.duration = performance.now() - started;
if (status === 'error') entry.error = err?.message ?? String(err);
else entry.result = compact(payload);
notifyLog();
};
let result;
try {
result = original.apply(this, args);
} catch (err) {
settle('error', undefined, err);
throw err;
}
if (result && typeof result.then === 'function') {
return result.then(
(value) => {
settle('ok', value);
return value;
},
(err) => {
settle('error', undefined, err);
throw err;
},
);
}
settle('ok', result);
return result;
};
try {
Object.defineProperty(wrapped, 'name', { value: key });
} catch {
/* name is cosmetic */
}
wrapped[LOGGED] = true;
actions[key] = wrapped;
}
}
/** Marks the registry Map once its `set` has been wrapped. */
const WATCHED = Symbol('automatInspectorWatched');
/**
* Wraps the live registry's `set` so automats created after mount get their
* actions instrumented immediately instead of on the next poll tick.
*/
function watchRegistry() {
const registry = window.__AUTOMATS__;
if (!(registry instanceof Map) || registry[WATCHED]) return;
const originalSet = registry.set.bind(registry);
registry.set = (name, automat) => {
const result = originalSet(name, automat);
installActionLogging(automat);
return result;
};
registry[WATCHED] = true;
}
/**
* Floating dev button plus a docked side panel listing all registered Automats
* with live state, readiness flags, subscriber counts and available actions.
*
* The panel is rendered in-flow (not as an overlay), so the page stays fully
* interactive while the action log is watched live. It relies on being placed
* as a flex sibling of the app content (see `main.jsx`).
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number, logOpen: boolean, log: Array<object>}>}
*/
class AutomatInspector extends PureComponent {
constructor(props) {
@@ -177,11 +341,15 @@ class AutomatInspector extends PureComponent {
expanded: {},
query: '',
revision: 0,
logOpen: false,
log: [],
};
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
this.unsubs = new Map();
this.frame = null;
this.logFrame = null;
this.logRef = createRef();
this.handleToggle = this.handleToggle.bind(this);
this.handleClose = this.handleClose.bind(this);
@@ -193,24 +361,35 @@ class AutomatInspector extends PureComponent {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.syncRegistry = this.syncRegistry.bind(this);
this.scheduleUpdate = this.scheduleUpdate.bind(this);
this.scheduleLogUpdate = this.scheduleLogUpdate.bind(this);
this.handleToggleLog = this.handleToggleLog.bind(this);
this.handleClearLog = this.handleClearLog.bind(this);
this.handleCopyLog = this.handleCopyLog.bind(this);
}
componentDidMount() {
this.syncRegistry();
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
window.addEventListener('keydown', this.handleKeyDown);
logListeners.add(this.scheduleLogUpdate);
this.scheduleLogUpdate();
}
componentWillUnmount() {
window.clearInterval(this.registryTimer);
window.removeEventListener('keydown', this.handleKeyDown);
logListeners.delete(this.scheduleLogUpdate);
this.unsubscribeAll();
if (this.frame) cancelAnimationFrame(this.frame);
if (this.logFrame) cancelAnimationFrame(this.logFrame);
}
/** Mirrors the registry keys into state and keeps subscriptions in sync. */
syncRegistry() {
const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b));
watchRegistry();
const registry = getRegistry();
for (const automat of registry.values()) installActionLogging(automat);
const names = Array.from(registry.keys()).sort((a, b) => a.localeCompare(b));
const changed =
names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]);
if (changed) this.setState({ names });
@@ -249,6 +428,58 @@ class AutomatInspector extends PureComponent {
});
}
/**
* Copies the module-level log ring buffer into state, coalesced per frame so
* bursts of action calls do not cause a render each. Skipped while the drawer
* is closed; opening it re-syncs via `handleToggle`.
*/
scheduleLogUpdate() {
if (!this.state.open || this.logFrame) return;
this.logFrame = requestAnimationFrame(() => {
this.logFrame = null;
this.setState({ log: actionLog.slice() });
});
}
componentDidUpdate(prevProps, prevState) {
const grew = this.state.log.length > prevState.log.length;
const opened = this.state.logOpen && !prevState.logOpen;
if (this.state.logOpen && (grew || opened)) {
const node = this.logRef.current;
if (node) node.scrollTop = node.scrollHeight;
}
}
handleToggleLog() {
this.setState((prev) => ({ logOpen: !prev.logOpen, log: actionLog.slice() }));
}
handleClearLog() {
actionLog.length = 0;
notifyLog();
}
/** Copies the whole log buffer to the clipboard as plain text. */
async handleCopyLog() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).');
}
const text = actionLog
.map(
(entry) =>
`${formatTime(entry.time)} ${entry.automat}.${entry.action}(${entry.args})` +
`${entry.status === 'error' ? ` ! ${entry.error}` : ` → ${entry.result}`}` +
`${entry.duration != null ? ` [${Math.round(entry.duration)}ms]` : ''}`,
)
.join('\n');
await navigator.clipboard.writeText(text);
enqueueSnackbar(`Log kopiert (${actionLog.length} Einträge)`, { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
handleKeyDown(event) {
if (!event.altKey || !event.shiftKey) return;
if (event.key?.toLowerCase() !== 'a') return;
@@ -262,6 +493,7 @@ class AutomatInspector extends PureComponent {
if (open) {
this.syncRegistry();
this.subscribeToAll();
this.scheduleLogUpdate();
} else {
this.unsubscribeAll();
}
@@ -434,8 +666,116 @@ class AutomatInspector extends PureComponent {
);
}
render() {
const { open, names, expanded, query } = this.state;
/**
* Renders one captured action call as a single log line.
*
* @param {object} entry Log entry.
* @returns {React.ReactElement} Log line.
*/
renderLogLine(entry) {
const color =
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
return (
<Box key={entry.id} component="div">
<Box component="span" sx={{ color: 'text.disabled' }}>
{formatTime(entry.time)}{' '}
</Box>
<Box component="span" sx={{ fontWeight: 600 }}>
{entry.automat}.{entry.action}
</Box>
<Box component="span" sx={{ color: 'text.secondary' }}>
({entry.args})
</Box>
{entry.status === 'error' ? (
<Box component="span" sx={{ color: 'error.main' }}>
{' '}
✗ {entry.error}
</Box>
) : entry.status === 'ok' ? (
<Box component="span" sx={{ color: 'text.disabled' }}>
{' '}
→ {entry.result}
</Box>
) : null}{' '}
<Box component="span" sx={{ color, ml: 0.5 }}>
{entry.status === 'running'
? '…'
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
</Box>
</Box>
);
}
/** Bottom-docked, collapsible log of every captured automat action call. */
renderLog() {
const { logOpen, log } = this.state;
const errorCount = log.filter((entry) => entry.status === 'error').length;
return (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
<ButtonBase
component="div"
onClick={this.handleToggleLog}
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
>
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
<Typography variant="caption" sx={{ fontWeight: 600 }}>
Action-Log
</Typography>
<Chip size="small" variant="outlined" label={log.length} />
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
</ButtonBase>
<Tooltip title="Log als Text kopieren">
<span>
<IconButton size="small" onClick={this.handleCopyLog} disabled={log.length === 0}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Log leeren">
<span>
<IconButton size="small" onClick={this.handleClearLog} disabled={log.length === 0}>
<ClearIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
{logOpen ? (
<Box
ref={this.logRef}
component="div"
sx={{
m: 0,
mx: 1.5,
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: 'action.hover',
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.6,
maxHeight: '40vh',
overflow: 'auto',
}}
>
{log.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Noch keine Action-Aufrufe protokolliert.
</Typography>
) : (
log.map((entry) => this.renderLogLine(entry))
)}
</Box>
) : null}
</Box>
);
}
/** Docked side panel with the automat list, filter and action log. */
renderPanel() {
const { names, expanded, query } = this.state;
const registry = getRegistry();
const needle = query.trim().toLowerCase();
const entries = names
@@ -444,91 +784,108 @@ class AutomatInspector extends PureComponent {
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
return (
<>
<Badge
badgeContent={names.length}
color="secondary"
overlap="circular"
invisible={names.length === 0}
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }}
<Box
component="aside"
aria-label="Automat-Inspector"
sx={{
width: { xs: '100%', sm: 560 },
maxWidth: '100%',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
height: '100vh',
borderLeft: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: (theme) => theme.zIndex.appBar,
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
<Fab
size="small"
color="primary"
aria-label="Automat-Inspector umschalten"
aria-pressed={open}
onClick={this.handleToggle}
>
<BugReportIcon fontSize="small" />
</Fab>
<BugReportIcon fontSize="small" color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
Automat-Inspector
</Typography>
<Chip size="small" label={`${names.length} registriert`} />
<Tooltip title="Registry neu einlesen">
<IconButton size="small" onClick={this.handleRefresh}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Badge>
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
<IconButton size="small" onClick={this.handleToggleAll}>
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Drawer
anchor="right"
open={open}
onClose={this.handleClose}
slotProps={{
transition: { timeout: 0 },
paper: {
sx: {
width: { xs: '100%', sm: 560 },
maxWidth: '100%',
display: 'flex',
flexDirection: 'column',
},
},
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
>
<BugReportIcon fontSize="small" color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
Automat-Inspector
<Box sx={{ px: 2, py: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Name filtern …"
value={query}
onChange={this.handleQueryChange}
/>
</Box>
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
{entries.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
</Typography>
<Chip size="small" label={`${names.length} registriert`} />
<Tooltip title="Registry neu einlesen">
<IconButton size="small" onClick={this.handleRefresh}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
<IconButton size="small" onClick={this.handleToggleAll}>
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
) : (
<Stack spacing={1}>
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
</Stack>
)}
</Box>
<Box sx={{ px: 2, py: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Name filtern …"
value={query}
onChange={this.handleQueryChange}
/>
</Box>
{this.renderLog()}
</Box>
);
}
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
{entries.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
</Typography>
) : (
<Stack spacing={1}>{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}</Stack>
)}
</Box>
</Drawer>
render() {
const { open, names } = this.state;
return (
<>
{open ? null : (
<Badge
badgeContent={names.length}
color="secondary"
overlap="circular"
invisible={names.length === 0}
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }}
>
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
<Fab
size="small"
color="primary"
aria-label="Automat-Inspector umschalten"
aria-pressed={open}
onClick={this.handleToggle}
>
<BugReportIcon fontSize="small" />
</Fab>
</Tooltip>
</Badge>
)}
{open ? this.renderPanel() : null}
</>
);
}

View File

@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
@@ -43,12 +44,18 @@ createRoot(document.getElementById('root')).render(
>
<StorageManagerProvider>
<AuthBootstrap>
<App />
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
{/* Flex row so the dev inspector docks beside the app instead of
overlaying it; the app content shrinks while the panel is open. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<App />
</Box>
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</Box>
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>

View File

@@ -1,8 +1,25 @@
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';
import { setLastArticleSku } from '../../automats';
/** 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;
@@ -10,43 +27,66 @@ class ArtikelSkuPage extends Component {
constructor(props, context) {
super(props, context);
this.state = {
ready:
context.getArticle(props.sku) != null &&
context.getCategoryState() != null,
article: getArticle(props.sku),
ready: context.getCategoryState() != null,
error: null,
};
}
componentDidMount() {
this._mounted = true;
this.installEscape();
setLastArticleSku(this.props.sku);
if (!this.state.ready) void this.ensureArticle();
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);
const ready =
this.context.getArticle(this.props.sku) != null &&
this.context.getCategoryState() != null;
if (ready !== this.state.ready) this.setState({ ready });
if (!ready) void this.ensureArticle();
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() {
await Promise.all([
this.context.loadArticle(this.props.sku),
this.context.categoryTreePromise,
this.context.companyPromise,
this.context.authMePromise,
]);
this.setState({ ready: true });
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;
@@ -65,7 +105,12 @@ class ArtikelSkuPage extends Component {
render() {
const { sku } = this.props;
if (!this.state.ready) return null;
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} />;
}
}

View File

@@ -20,7 +20,8 @@ import {
emptyLine,
lineFromApi,
manifestConfig,
TextEditorDialog,
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
@@ -32,7 +33,7 @@ import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent {
@@ -54,6 +55,9 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '',
textEditorOpen: false,
textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition,
pdfViewerOpen: false,
pdfViewerAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
@@ -77,6 +81,8 @@ class LieferungNeuPage extends PureComponent {
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
this.handleClosePdfViewer = this.handleClosePdfViewer.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
@@ -90,6 +96,9 @@ class LieferungNeuPage extends PureComponent {
componentDidMount() {
this.unsubscribe = this.automat.subscribe(this);
this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({
viewerPosition: state.viewerPosition,
}));
this._cancelled = false;
if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
@@ -131,6 +140,7 @@ class LieferungNeuPage extends PureComponent {
this._skuDebounceTimers = null;
}
this.unsubscribe?.();
this._settingsUnsub?.();
}
async loadPartiesOnly() {
@@ -862,30 +872,21 @@ class LieferungNeuPage extends PureComponent {
this.setState({
textEditorOpen: true,
textEditorAttachment: attachment,
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
return;
}
if (isPdf) {
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
return;
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
void this.ensureAttachmentFile(attachment);
return;
}
this.setState({
pdfViewerOpen: true,
pdfViewerAttachment: attachment,
textEditorOpen: false,
textEditorAttachment: null,
});
void this.ensureAttachmentFile(attachment);
return;
}
if (attachment.file instanceof Blob || attachment.file instanceof File) {
@@ -913,6 +914,88 @@ class LieferungNeuPage extends PureComponent {
});
}
handleClosePdfViewer() {
this.setState({
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
}
handleTextEditorPositionChange(position) {
setViewerPosition(position);
}
renderViewerPanels(wide) {
const {
textEditorOpen,
textEditorAttachment,
pdfViewerOpen,
pdfViewerAttachment,
viewerPosition,
} = this.state;
const docId = this.props.docId ?? this.state.id;
const panels = [];
if (textEditorOpen && textEditorAttachment) {
panels.push(
<TextEditorPanel
key="text"
docId={docId}
attachment={textEditorAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>,
);
}
if (pdfViewerOpen && pdfViewerAttachment) {
panels.push(
<PdfViewerPanel
key="pdf"
docId={docId}
attachment={pdfViewerAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleClosePdfViewer}
/>,
);
}
if (panels.length === 0) return null;
return (
<Box
sx={{
gridColumn: { xs: '1 / -1', sm: wide >= 2 ? 'span 2' : 'span 1', md: `span ${wide}` },
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 0,
...(viewerPosition === 'right'
? { position: 'sticky', top: 16, alignSelf: 'flex-start' }
: {}),
}}
>
{panels}
</Box>
);
}
isViewerOpen() {
return Boolean(
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
);
}
handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
@@ -1067,6 +1150,7 @@ class LieferungNeuPage extends PureComponent {
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
/>
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
{partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
{this.state.unmatchedSupplierName ? (
@@ -1120,6 +1204,11 @@ class LieferungNeuPage extends PureComponent {
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 3
}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
@@ -1137,6 +1226,8 @@ class LieferungNeuPage extends PureComponent {
/>
</>
)}
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(3)}
{notes && notes.length > 0 && (
<PanelTile wide={3} color="#fffbeb">
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
@@ -1162,14 +1253,6 @@ class LieferungNeuPage extends PureComponent {
onAbort={this.handleAbortParse}
/>
<TextEditorDialog
open={this.state.textEditorOpen}
docId={this.props.docId ?? this.state.id}
attachment={this.state.textEditorAttachment}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}

View File

@@ -1,124 +0,0 @@
import { Automat, fetchJson } from 'react-automat';
import { article } from '../api/index';
import { requireObject, requireDefined } from '../utils/apiShape';
/**
* Article automat store.
* Registered as window.artikel via the name option.
* State shape: { [sku: string]: article | null }
*
* Usage in PureComponent:
* ```jsx
* import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
*
* class ArticleView extends PureComponent {
* constructor(props) {
* super(props);
* const sku = props.sku;
* const currentArticle = articleAutomat.state[sku] ?? null;
* this.state = { article: currentArticle };
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(sku).catch(console.error);
* }
* }
*
* componentDidMount() {
* this.subscribeToArticle();
* }
*
* componentDidUpdate(prevProps) {
* if (prevProps.sku !== this.props.sku) {
* const currentArticle = articleAutomat.state[this.props.sku] ?? null;
* this.setState({ article: currentArticle });
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(this.props.sku).catch(console.error);
* }
*
* this.subscribeToArticle();
* }
* }
*
* subscribeToArticle() {
* if (this.unsub) this.unsub();
* const selector = createArticleSelector(this.props.sku);
* this.unsub = articleAutomat.subscribe(this, (state) => ({
* article: selector(state),
* }));
* }
*
* componentWillUnmount() {
* if (this.unsub) {
* this.unsub();
* this.unsub = null;
* }
* }
*
* render() {
* const { article } = this.state;
* if (!article) return null;
* return <div>{article.name}</div>;
* }
* }
* ```
*/
const articleAutomat = new Automat(
{},
{
/**
* Load article by SKU and update the store.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await article.getBySku(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
articleAutomat.setState({
[sku]: res.article,
});
return res.article;
},
/**
* Update article state after a successful mutation.
* @param {object} article
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
articleAutomat.setState({
[article.sku]: article,
});
},
/**
* Clear article from store (e.g., after deletion).
* @param {string} sku
*/
clearArticle(sku) {
articleAutomat.setState({
[sku]: null,
});
},
},
{
name: 'artikel',
}
);
/**
* Create a selector function for a specific SKU.
* This avoids closure issues when the SKU changes.
* @param {string} sku - The article SKU
* @returns {(state: object) => object|null} Selector function
*/
export function createArticleSelector(sku) {
return (state) => state[sku] ?? null;
}
export default articleAutomat;