From 4011ebfc6130f5d1cc5b54b58cf3849490fad8d6 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 18:33:27 +0200 Subject: [PATCH] nav --- src/api/StorageManager.js | 9 ++ src/automats/index.js | 2 + src/automats/navigationAutomat.js | 54 +++++++++++ src/components/AppShell.jsx | 37 +++++++- src/components/article/ArticleView.jsx | 65 ++++++++----- src/pages/artikel/index.jsx | 2 + src/pages/artikel/kategorien.jsx | 2 + src/pages/artikel/lager.jsx | 6 ++ src/pages/artikel/sku.jsx | 3 + src/pages/lieferung/index.jsx | 5 + src/pages/lieferung/lieferanten.jsx | 2 + src/pages/lieferung/neu.jsx | 3 + src/store/articleAutomat.js | 124 +++++++++++++++++++++++++ 13 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 src/automats/navigationAutomat.js create mode 100644 src/store/articleAutomat.js diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 2cfaa8f..81d8ce6 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -2,6 +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'; /** * Stale-while-revalidate cache: readers keep the current promise; update() @@ -102,6 +103,10 @@ export class StorageManager extends ApiClient { requireDefined(article.sku, 'article.sku'); const key = String(article.sku); this.#articlesBySku.set(key, article); + + // Update automat as well + articleAutomat.actions.setArticle(article); + const watchers = this.#articleWatchers.get(key); if (watchers) { for (const onArticle of watchers) onArticle(article); @@ -329,6 +334,10 @@ 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)); + if (this.#articleWatchers.has(sku)) { void this.loadArticle(sku).catch((err) => notifyError(err)); } diff --git a/src/automats/index.js b/src/automats/index.js index a606173..79a904a 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -1 +1,3 @@ export * from './deliveryEditorAutomat'; +export { default as navigationAutomat } from './navigationAutomat'; +export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; diff --git a/src/automats/navigationAutomat.js b/src/automats/navigationAutomat.js new file mode 100644 index 0000000..ea63064 --- /dev/null +++ b/src/automats/navigationAutomat.js @@ -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; diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 39a88f7..3deec9e 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -11,13 +11,34 @@ import UserMenu from './UserMenu'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { artikelQueryPath } from '../pages/artikel/artikelQuery'; import { defaultCategoryPath } from '../pages/artikel/categoryRoute'; +import { navigationAutomat } from '../automats'; const SIDEBAR_WIDTH = 144; const SUB_NAV_WIDTH = 88; 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() { const { activeNav, navigate } = this.props; + const { lastDeliveryPath, lastArticleSku } = this.state; return ( e.preventDefault()} 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) => ({ borderRadius: 0, diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index fb4bbca..6b09518 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -6,6 +6,7 @@ import { buildCategoryPath, defaultCategoryPath, } from '../../pages/artikel/categoryRoute'; +import articleAutomat, { createArticleSelector } from '../../store/articleAutomat'; import ArticleTileTab from './ArticleTileTab'; import ArticlePhotoTile from './ArticlePhotoTile'; @@ -24,34 +25,52 @@ class ArticleView extends PureComponent { constructor(props, context) { super(props, context); - const { article, unwatch } = context.article.watch( - props.sku, - (next) => { - this.setState({ article: next }); - }, - ); - this.state = { article }; - this.unwatchArticle = unwatch; + + const sku = props.sku; + const currentArticle = articleAutomat.state[sku] ?? null; + + this.state = { article: currentArticle }; this.handleArticleDeleted = this.handleArticleDeleted.bind(this); - } - - componentDidUpdate(prevProps) { - const { sku } = this.props; - - 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 }); + + if (!currentArticle) { + articleAutomat.actions.loadArticle(sku).catch((err) => { + console.error('Failed to load article:', err); + }); } } + 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() { - this.unwatchArticle?.(); - this.unwatchArticle = null; + if (this.unsub) { + this.unsub(); + this.unsub = null; + } } handleArticleDeleted() { diff --git a/src/pages/artikel/index.jsx b/src/pages/artikel/index.jsx index 6a47f5a..387c538 100644 --- a/src/pages/artikel/index.jsx +++ b/src/pages/artikel/index.jsx @@ -29,6 +29,7 @@ import { import { preserveSearchFocus } from './searchFocus'; import { artikelDetailPath } from './articleRoute'; import withSearchParams from './withSearchParams'; +import { setLastArticleSku } from '../../automats'; const resultRowSx = { alignItems: 'center', @@ -171,6 +172,7 @@ class ArtikelSearchPage extends Component { componentDidMount() { document.addEventListener('mousedown', preserveSearchFocus, true); window.addEventListener('resize', this.handleWindowResize); + setLastArticleSku(null); // Kick off initial search (empty input = show all products). const { input } = this.state; diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 23a8f0b..6e45622 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -53,6 +53,7 @@ import { enableTerminalCategory, disableTerminalCategory, } from '../../store/posterminalSlice'; +import { setLastArticleSku } from '../../automats'; const gridSx = { display: 'grid', @@ -72,6 +73,7 @@ class ArtikelKategorienPage extends Component { } componentDidMount() { + setLastArticleSku(null); this.ensureTreeLoaded(); this.ensurePosterminalsLoaded(); this.syncCategoryFromUrl(); diff --git a/src/pages/artikel/lager.jsx b/src/pages/artikel/lager.jsx index 8ba047f..c4271fd 100644 --- a/src/pages/artikel/lager.jsx +++ b/src/pages/artikel/lager.jsx @@ -1,6 +1,12 @@ +import { useEffect } from 'react'; import Typography from '@mui/material/Typography'; +import { setLastArticleSku } from '../../automats'; export default function ArtikelLagerPage() { + useEffect(() => { + setLastArticleSku(null); + }, []); + return ( Lager — Inhalt folgt. ); diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index 61f9d80..93d071d 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -2,6 +2,7 @@ import { Component } from 'react'; import { StorageManagerContext } from '../../api/storageManagerContext'; import { ArticleView } from '../../components/article'; import withSkuRoute from './withSkuRoute'; +import { setLastArticleSku } from '../../automats'; class ArtikelSkuPage extends Component { static contextType = StorageManagerContext; @@ -17,11 +18,13 @@ class ArtikelSkuPage extends Component { componentDidMount() { this.installEscape(); + setLastArticleSku(this.props.sku); if (!this.state.ready) 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; diff --git a/src/pages/lieferung/index.jsx b/src/pages/lieferung/index.jsx index a7200d9..90ab664 100644 --- a/src/pages/lieferung/index.jsx +++ b/src/pages/lieferung/index.jsx @@ -1,7 +1,12 @@ import { PureComponent } from 'react'; import { ManifestList } from '../../components/manifest'; +import { setLastDeliveryPath } from '../../automats'; class LieferungListePage extends PureComponent { + componentDidMount() { + setLastDeliveryPath(null); + } + render() { return ; } diff --git a/src/pages/lieferung/lieferanten.jsx b/src/pages/lieferung/lieferanten.jsx index b1b54e7..362b0a2 100644 --- a/src/pages/lieferung/lieferanten.jsx +++ b/src/pages/lieferung/lieferanten.jsx @@ -17,6 +17,7 @@ import { StorageManagerContext } from '../../api/storageManagerContext'; import { requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; +import { setLastDeliveryPath } from '../../automats'; function sortSuppliers(suppliers) { return [...suppliers].sort((a, b) => @@ -48,6 +49,7 @@ class LieferungLieferantenPage extends PureComponent { } componentDidMount() { + setLastDeliveryPath(null); this._cancelled = false; void this.loadSuppliers(); } diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index 9b4ded0..aac80f2 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -30,6 +30,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 { delivery as deliveryApi } from '../../api'; class LieferungNeuPage extends PureComponent { @@ -85,8 +86,10 @@ class LieferungNeuPage extends PureComponent { this.unsubscribe = this.automat.subscribe(this); this._cancelled = false; if (this.props.docId != null) { + setLastDeliveryPath(`/lieferung/${this.props.docId}`); void this.loadEdit(); } else { + setLastDeliveryPath('/lieferung/neu'); void this.loadPartiesOnly(); if (this.props.initialDelivery) { this.automat.actions.setDelivery(this.props.initialDelivery); diff --git a/src/store/articleAutomat.js b/src/store/articleAutomat.js new file mode 100644 index 0000000..f90e132 --- /dev/null +++ b/src/store/articleAutomat.js @@ -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
{article.name}
; + * } + * } + * ``` + */ +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;