diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 81d8ce6..9198fa5 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -382,7 +382,6 @@ export class StorageManager extends ApiClient { const companyCache = createCache('GET /company', () => this.#loadCompanies()); const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree()); const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals()); - const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const customergroupCache = createCache('GET /customergroup', () => this.#loadCustomergroups(), @@ -424,11 +423,6 @@ export class StorageManager extends ApiClient { posterminalPromise: { get: () => posterminalCache.promise }, updatePosterminal: { value: posterminalCache.update }, - supplierPromise: { get: () => supplierCache.promise }, - updateSupplier: { value: supplierCache.update }, - /** Settled GET /supplier list, or undefined. */ - suppliers: { get: () => supplierCache.value }, - deliveryPromise: { get: () => deliveryCache.promise }, updateDelivery: { value: deliveryCache.update }, /** Settled GET /delivery list, or undefined. */ @@ -580,12 +574,6 @@ export class StorageManager extends ApiClient { return requireArray(data.posterminals, 'posterminals'); } - async #loadSuppliers() { - const data = await this.supplier.list(); - requireObject(data, 'GET /api/supplier'); - return requireArray(data.suppliers, 'suppliers'); - } - async #loadDeliveries() { const data = await this.delivery.list(); requireObject(data, 'GET /api/delivery'); diff --git a/src/automats/index.js b/src/automats/index.js index d16b160..641e3a9 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -3,3 +3,4 @@ export { default as authAutomat } from './authAutomat'; export * from './authAutomat'; export { default as navigationAutomat } from './navigationAutomat'; export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; +export { default as supplierAutomat } from './supplierAutomat'; diff --git a/src/automats/supplierAutomat.js b/src/automats/supplierAutomat.js new file mode 100644 index 0000000..f1ee0fd --- /dev/null +++ b/src/automats/supplierAutomat.js @@ -0,0 +1,201 @@ +import { Automat } from 'react-automat'; +import { supplier as supplierApi } from '../api'; +import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} Supplier + * @property {string|number} id Supplier identifier. + * @property {string} name Supplier name. + */ + +/** + * @typedef {Object} SupplierState + * @property {Array} suppliers Company-scoped suppliers. + * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. + * @property {Error|null} error Last load error. + */ + +/** @type {SupplierState} */ +const initialState = { + suppliers: [], + status: 'idle', + error: null, +}; + +let supplierAutomat; + +/** + * Fetches the company-scoped supplier list. + * + * @returns {Promise>} Supplier list. + */ +async function fetchSuppliers() { + const data = await supplierApi.list(); + requireObject(data, 'GET /api/supplier'); + return requireArray(data.suppliers, 'suppliers'); +} + +/** In-flight `load()` promise so concurrent consumers share one request. */ +let inflightLoad = null; + +/** + * Loads the supplier list once, deduplicating concurrent callers. + * + * @returns {Promise>} Loaded suppliers. + */ +function loadSuppliers() { + if (inflightLoad) return inflightLoad; + inflightLoad = (async () => { + supplierAutomat.setState({ status: 'loading', error: null }); + try { + const suppliers = await fetchSuppliers(); + supplierAutomat.setState({ suppliers, status: 'ready', error: null }); + return suppliers; + } catch (error) { + supplierAutomat.setState({ status: 'failed', error }); + throw error; + } finally { + inflightLoad = null; + } + })(); + return inflightLoad; +} + +const actions = { + /** + * Loads the supplier list, exposing a loading/failed lifecycle. + * + * @returns {Promise>} Loaded suppliers. + */ + load() { + return loadSuppliers(); + }, + + /** + * Silently re-fetches the supplier list while keeping the current state. + * + * @returns {Promise>} Fresh suppliers. + */ + async reload() { + try { + const suppliers = await fetchSuppliers(); + supplierAutomat.setState({ suppliers, status: 'ready', error: null }); + return suppliers; + } catch (error) { + supplierAutomat.setState({ error }); + throw error; + } + }, + + /** + * Creates a supplier and refreshes the cached list. + * + * @param {{name: string}} body Creation payload. + * @returns {Promise} Created supplier. + */ + async create(body) { + const data = await supplierApi.create(body); + requireObject(data, 'POST /api/supplier'); + requireObject(data.supplier, 'supplier'); + requireDefined(data.supplier.id, 'supplier.id'); + await supplierAutomat.actions.reload(); + return data.supplier; + }, + + /** + * Renames a supplier and refreshes the cached list. + * + * @param {string|number} id Supplier identifier. + * @param {{name: string}} body Update payload. + * @returns {Promise} Updated supplier. + */ + async update(id, body) { + const data = await supplierApi.update(id, body); + requireObject(data, 'PATCH /api/supplier/:id'); + requireObject(data.supplier, 'supplier'); + requireDefined(data.supplier.id, 'supplier.id'); + await supplierAutomat.actions.reload(); + return data.supplier; + }, + + /** + * Deletes a supplier and refreshes the cached list. + * + * @param {string|number} id Supplier identifier. + * @returns {Promise} + */ + async remove(id) { + await supplierApi.delete(id); + await supplierAutomat.actions.reload(); + }, + + /** + * Replaces the cached list without a round-trip. + * + * @param {Array} suppliers Suppliers to cache. + * @returns {void} + */ + setSuppliers(suppliers) { + supplierAutomat.setState({ + suppliers: Array.isArray(suppliers) ? suppliers : [], + status: 'ready', + error: null, + }); + }, + + /** + * Forgets the cached list and returns to the initial state. + * + * @returns {void} + */ + clear() { + supplierAutomat.setState({ ...initialState }); + }, +}; + +/** + * Company-scoped supplier cache. Loads automatically once an authenticated + * session with a selected company exists and is cleared when auth is gone. + * + * @type {Automat} + */ +supplierAutomat = new Automat(initialState, actions, { name: 'supplier' }); + +/** + * Derives the cache key from the auth state: the selected company while + * authenticated, otherwise `null`. + * + * @param {import('./authAutomat').AuthState} authState Auth automat state. + * @returns {string|null} Cache key. + */ +function authKey(authState) { + if (authState?.status !== 'authed' || !authState.user) return null; + const companyId = authState.user.selectedCompanyId; + return companyId == null ? null : String(companyId); +} + +let lastAuthKey; + +/** + * Loads the supplier list when auth/company becomes available and forgets it + * when the session ends. + * + * @param {import('./authAutomat').AuthState} authState Auth automat state. + * @returns {void} + */ +function syncWithAuth(authState) { + const key = authKey(authState); + if (key === lastAuthKey) return; + lastAuthKey = key; + if (key == null) { + supplierAutomat.actions.clear(); + } else { + void supplierAutomat.actions.load().catch(() => {}); + } +} + +authAutomat.subscribe(syncWithAuth); +syncWithAuth(authAutomat.state); + +export default supplierAutomat; \ No newline at end of file diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index 48eec92..b4567a1 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -23,6 +23,7 @@ import { enqueueSnackbar } from 'notistack'; import { StorageManagerContext } from '../../../api/storageManagerContext'; import { notifyError } from '../../../utils/notify'; import { formatPrice } from '../../../utils/price'; +import supplierAutomat from '../../../automats/supplierAutomat'; import ArticleSupplierDialog from '../ArticleSupplierDialog'; export default class ArticleSupplierTab extends PureComponent { @@ -80,7 +81,7 @@ export default class ArticleSupplierTab extends PureComponent { // 2. Fetch available company suppliers and company metadata try { const [companySuppliers, company] = await Promise.all([ - this.context.suppliers ?? this.context.supplierPromise, + supplierAutomat.actions.load(), this.context.company ?? this.context.companyPromise, ]); this.setState({ diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index 372b0c6..ae8d0f7 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -346,13 +346,13 @@ class ManifestFormLine extends PureComponent { {/* Row 2: Matched Supplier Article Details */} - td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}> + td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}> {this.renderSecondLine()} diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 75698e2..caebfa8 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -41,7 +41,7 @@ class ManifestLines extends PureComponent { - + {columns.map((col) => ( {col.label} ))} - + diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index c8a790e..5eefc1a 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -1,3 +1,5 @@ +import supplierAutomat from '../../automats/supplierAutomat'; + /** Groups that are not sales customers (filter client-side). */ export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']); @@ -424,7 +426,10 @@ export const DELIVERY_LINE_COLUMNS = [ */ export async function loadParties(kind, ctx) { if (kind === 'delivery') { - const list = ctx.suppliers ?? (await ctx.supplierPromise); + if (supplierAutomat.state.status === 'ready') { + return sortSuppliers(supplierAutomat.state.suppliers); + } + const list = await supplierAutomat.actions.load(); return sortSuppliers(list); } const [entities, groups] = await Promise.all([ diff --git a/src/main.jsx b/src/main.jsx index 4491f5b..fe1da17 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -9,6 +9,7 @@ import { StorageManagerProvider } from './api/storageManagerContext'; import App from './App'; import { store } from './store'; import { getActiveTheme } from './theme'; +import './automats/supplierAutomat'; import './index.css'; // Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically diff --git a/src/pages/lieferung/lieferanten.jsx b/src/pages/lieferung/lieferanten.jsx index 8fa4b8f..ccd73e6 100644 --- a/src/pages/lieferung/lieferanten.jsx +++ b/src/pages/lieferung/lieferanten.jsx @@ -16,11 +16,10 @@ import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; -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'; +import supplierAutomat from '../../automats/supplierAutomat'; import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat'; @@ -51,8 +50,6 @@ function getInitialParams(props) { } class LieferungLieferantenPage extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); const { prefill } = getInitialParams(props); @@ -109,7 +106,7 @@ class LieferungLieferantenPage extends PureComponent { async loadSuppliers() { this.setState({ status: 'loading', error: null }); try { - const list = this.context.suppliers ?? (await this.context.supplierPromise); + const list = await supplierAutomat.actions.load(); if (this._cancelled) return; this.applySuppliers(list); } catch (err) { @@ -123,7 +120,7 @@ class LieferungLieferantenPage extends PureComponent { } async refreshFromServer() { - const list = await this.context.updateSupplier(); + const list = await supplierAutomat.actions.reload(); if (this._cancelled) return; this.applySuppliers(list); } @@ -136,11 +133,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: 'new' }); try { - const data = await this.context.supplier.create({ name }); - requireObject(data, 'POST /api/supplier'); - requireObject(data.supplier, 'supplier'); - requireDefined(data.supplier.id, 'supplier.id'); - const newSupplier = data.supplier; + const newSupplier = await supplierAutomat.actions.create({ name }); // Assign the new supplier to the active delivery draft & clear unmatched name deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id); @@ -174,10 +167,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: supplier.id }); try { - const data = await this.context.supplier.update(supplier.id, { name: draft }); - requireObject(data, 'PATCH /api/supplier/:id'); - requireObject(data.supplier, 'supplier'); - requireDefined(data.supplier.id, 'supplier.id'); + await supplierAutomat.actions.update(supplier.id, { name: draft }); await this.refreshFromServer(); if (this._cancelled) return; this.setState({ busyKey: null }); @@ -200,7 +190,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: supplier.id }); try { - await this.context.supplier.delete(supplier.id); + await supplierAutomat.actions.remove(supplier.id); await this.refreshFromServer(); if (this._cancelled) return; this.setState({ busyKey: null });