u
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
190
src/automats/artikelAutomat.js
Normal file
190
src/automats/artikelAutomat.js
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
48
src/automats/userSettingsAutomat.js
Normal file
48
src/automats/userSettingsAutomat.js
Normal 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;
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
296
src/components/manifest/PdfViewerPanel.jsx
Normal file
296
src/components/manifest/PdfViewerPanel.jsx
Normal 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;
|
||||
@@ -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;
|
||||
381
src/components/manifest/TextEditorPanel.jsx
Normal file
381
src/components/manifest/TextEditorPanel.jsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/main.jsx
19
src/main.jsx
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user