This commit is contained in:
seb
2026-09-19 18:33:27 +02:00
parent d3b4743b66
commit 4011ebfc61
13 changed files with 290 additions and 24 deletions

View File

@@ -2,6 +2,7 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice'; import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient'; import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat';
/** /**
* Stale-while-revalidate cache: readers keep the current promise; update() * Stale-while-revalidate cache: readers keep the current promise; update()
@@ -102,6 +103,10 @@ export class StorageManager extends ApiClient {
requireDefined(article.sku, 'article.sku'); requireDefined(article.sku, 'article.sku');
const key = String(article.sku); const key = String(article.sku);
this.#articlesBySku.set(key, article); this.#articlesBySku.set(key, article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key); const watchers = this.#articleWatchers.get(key);
if (watchers) { if (watchers) {
for (const onArticle of watchers) onArticle(article); for (const onArticle of watchers) onArticle(article);
@@ -329,6 +334,10 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => { #onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length); const sku = event.type.slice('article-'.length);
// Update automat on SSE article events
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
if (this.#articleWatchers.has(sku)) { if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err)); void this.loadArticle(sku).catch((err) => notifyError(err));
} }

View File

@@ -1 +1,3 @@
export * from './deliveryEditorAutomat'; export * from './deliveryEditorAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';

View File

@@ -0,0 +1,54 @@
import { Automat } from 'react-automat';
/**
* Navigation automat to store last visited delivery and article paths.
* Used to return users to their previous context when clicking main menu items.
*
* State shape:
* {
* lastDeliveryPath: string | null, // Last visited delivery path (e.g., '/lieferung/19' or '/lieferung/neu')
* lastArticleSku: string | null, // Last visited article SKU
* }
*/
const initialState = {
lastDeliveryPath: null,
lastArticleSku: null,
};
const navigationAutomat = new Automat(
initialState,
{
/**
* Set the last visited delivery path.
* @param {string|null} path - Full path like '/lieferung/19' or '/lieferung/neu'
*/
setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path });
},
/**
* Set the last visited article SKU.
* @param {string|null} sku
*/
setLastArticleSku(sku) {
navigationAutomat.setState({ lastArticleSku: sku });
},
/**
* Clear all navigation history.
*/
clearNavigation() {
navigationAutomat.setState({
lastDeliveryPath: null,
lastArticleSku: null,
});
},
},
{
name: 'navigation',
persist: false,
}
);
export default navigationAutomat;
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions;

View File

@@ -11,13 +11,34 @@ import UserMenu from './UserMenu';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery'; import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath } from '../pages/artikel/categoryRoute'; import { defaultCategoryPath } from '../pages/artikel/categoryRoute';
import { navigationAutomat } from '../automats';
const SIDEBAR_WIDTH = 144; const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 88; const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent { class AppSidebar extends PureComponent {
constructor(props) {
super(props);
this.state = {
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
lastArticleSku: navigationAutomat.state.lastArticleSku,
};
}
componentDidMount() {
this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastDeliveryPath: state.lastDeliveryPath,
lastArticleSku: state.lastArticleSku,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() { render() {
const { activeNav, navigate } = this.props; const { activeNav, navigate } = this.props;
const { lastDeliveryPath, lastArticleSku } = this.state;
return ( return (
<Box <Box
@@ -45,7 +66,21 @@ class AppSidebar extends PureComponent {
selected={active} selected={active}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
navigate(path === '/artikel' ? artikelQueryPath() : path); if (path === '/artikel') {
if (lastArticleSku) {
navigate(`/artikel/${lastArticleSku}`);
} else {
navigate(artikelQueryPath());
}
} else if (path === '/lieferung') {
if (lastDeliveryPath) {
navigate(lastDeliveryPath);
} else {
navigate(path);
}
} else {
navigate(path);
}
}} }}
sx={(theme) => ({ sx={(theme) => ({
borderRadius: 0, borderRadius: 0,

View File

@@ -6,6 +6,7 @@ import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
} from '../../pages/artikel/categoryRoute'; } from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
import ArticleTileTab from './ArticleTileTab'; import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile'; import ArticlePhotoTile from './ArticlePhotoTile';
@@ -24,34 +25,52 @@ class ArticleView extends PureComponent {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
const { article, unwatch } = context.article.watch(
props.sku, const sku = props.sku;
(next) => { const currentArticle = articleAutomat.state[sku] ?? null;
this.setState({ article: next });
}, this.state = { article: currentArticle };
);
this.state = { article };
this.unwatchArticle = unwatch;
this.handleArticleDeleted = this.handleArticleDeleted.bind(this); this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
}
componentDidUpdate(prevProps) { if (!currentArticle) {
const { sku } = this.props; articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
if (prevProps.sku !== sku) { });
this.unwatchArticle?.();
const { article: next, unwatch } = this.context.article.watch(
sku,
(updated) => this.setState({ article: updated }),
);
this.unwatchArticle = unwatch;
this.setState({ article: next });
} }
} }
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((err) => {
console.error('Failed to load article:', err);
});
}
this.subscribeToArticle();
}
}
subscribeToArticle() {
if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({
article: selector(state),
}));
}
componentWillUnmount() { componentWillUnmount() {
this.unwatchArticle?.(); if (this.unsub) {
this.unwatchArticle = null; this.unsub();
this.unsub = null;
}
} }
handleArticleDeleted() { handleArticleDeleted() {

View File

@@ -29,6 +29,7 @@ import {
import { preserveSearchFocus } from './searchFocus'; import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute'; import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams'; import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats';
const resultRowSx = { const resultRowSx = {
alignItems: 'center', alignItems: 'center',
@@ -171,6 +172,7 @@ class ArtikelSearchPage extends Component {
componentDidMount() { componentDidMount() {
document.addEventListener('mousedown', preserveSearchFocus, true); document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize); window.addEventListener('resize', this.handleWindowResize);
setLastArticleSku(null);
// Kick off initial search (empty input = show all products). // Kick off initial search (empty input = show all products).
const { input } = this.state; const { input } = this.state;

View File

@@ -53,6 +53,7 @@ import {
enableTerminalCategory, enableTerminalCategory,
disableTerminalCategory, disableTerminalCategory,
} from '../../store/posterminalSlice'; } from '../../store/posterminalSlice';
import { setLastArticleSku } from '../../automats';
const gridSx = { const gridSx = {
display: 'grid', display: 'grid',
@@ -72,6 +73,7 @@ class ArtikelKategorienPage extends Component {
} }
componentDidMount() { componentDidMount() {
setLastArticleSku(null);
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.ensurePosterminalsLoaded(); this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl(); this.syncCategoryFromUrl();

View File

@@ -1,6 +1,12 @@
import { useEffect } from 'react';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { setLastArticleSku } from '../../automats';
export default function ArtikelLagerPage() { export default function ArtikelLagerPage() {
useEffect(() => {
setLastArticleSku(null);
}, []);
return ( return (
<Typography color="text.secondary">Lager — Inhalt folgt.</Typography> <Typography color="text.secondary">Lager — Inhalt folgt.</Typography>
); );

View File

@@ -2,6 +2,7 @@ import { Component } from 'react';
import { StorageManagerContext } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
import { ArticleView } from '../../components/article'; import { ArticleView } from '../../components/article';
import withSkuRoute from './withSkuRoute'; import withSkuRoute from './withSkuRoute';
import { setLastArticleSku } from '../../automats';
class ArtikelSkuPage extends Component { class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -17,11 +18,13 @@ class ArtikelSkuPage extends Component {
componentDidMount() { componentDidMount() {
this.installEscape(); this.installEscape();
setLastArticleSku(this.props.sku);
if (!this.state.ready) void this.ensureArticle(); if (!this.state.ready) void this.ensureArticle();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) { if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku);
const ready = const ready =
this.context.getArticle(this.props.sku) != null && this.context.getArticle(this.props.sku) != null &&
this.context.getCategoryState() != null; this.context.getCategoryState() != null;

View File

@@ -1,7 +1,12 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { ManifestList } from '../../components/manifest'; import { ManifestList } from '../../components/manifest';
import { setLastDeliveryPath } from '../../automats';
class LieferungListePage extends PureComponent { class LieferungListePage extends PureComponent {
componentDidMount() {
setLastDeliveryPath(null);
}
render() { render() {
return <ManifestList kind="delivery" />; return <ManifestList kind="delivery" />;
} }

View File

@@ -17,6 +17,7 @@ import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../utils/apiShape'; import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastDeliveryPath } from '../../automats';
function sortSuppliers(suppliers) { function sortSuppliers(suppliers) {
return [...suppliers].sort((a, b) => return [...suppliers].sort((a, b) =>
@@ -48,6 +49,7 @@ class LieferungLieferantenPage extends PureComponent {
} }
componentDidMount() { componentDidMount() {
setLastDeliveryPath(null);
this._cancelled = false; this._cancelled = false;
void this.loadSuppliers(); void this.loadSuppliers();
} }

View File

@@ -30,6 +30,7 @@ import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail'; import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText'; import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats';
import { delivery as deliveryApi } from '../../api'; import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent { class LieferungNeuPage extends PureComponent {
@@ -85,8 +86,10 @@ class LieferungNeuPage extends PureComponent {
this.unsubscribe = this.automat.subscribe(this); this.unsubscribe = this.automat.subscribe(this);
this._cancelled = false; this._cancelled = false;
if (this.props.docId != null) { if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
void this.loadEdit(); void this.loadEdit();
} else { } else {
setLastDeliveryPath('/lieferung/neu');
void this.loadPartiesOnly(); void this.loadPartiesOnly();
if (this.props.initialDelivery) { if (this.props.initialDelivery) {
this.automat.actions.setDelivery(this.props.initialDelivery); this.automat.actions.setDelivery(this.props.initialDelivery);

124
src/store/articleAutomat.js Normal file
View File

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