u
This commit is contained in:
61
src/automats/articleDetailAutomat.js
Normal file
61
src/automats/articleDetailAutomat.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { KeyedAutomat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleHistoryEntry
|
||||
* @property {string|number} id History entry id.
|
||||
* @property {string} date Timestamp (ISO).
|
||||
* @property {string} text Human-readable change description.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSupplierRow
|
||||
* @property {string|number} id Supplier link id.
|
||||
* @property {string|number} entityId Supplier id.
|
||||
* @property {string|null} sku Supplier SKU.
|
||||
* @property {string|null} name Supplier name.
|
||||
* @property {number|null} pu Pack unit.
|
||||
* @property {number|null} stock Supplier stock.
|
||||
* @property {number|null} netPrice Net purchase price.
|
||||
* @property {number|null} taxRate Tax rate in percent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-SKU article change history (`GET /api/article/{sku}/history`).
|
||||
*
|
||||
* The backend returns the full list; pagination happens client-side in
|
||||
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
|
||||
*
|
||||
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
|
||||
*/
|
||||
export const articleHistoryAutomat = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'articleHistory',
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getBySkuHistory(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/history');
|
||||
return requireArray(res.history, 'history');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||
*
|
||||
* @type {KeyedAutomat<Array<ArticleSupplierRow>>}
|
||||
*/
|
||||
export const articleSupplierAutomat = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'articleSuppliers',
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getSuppliers(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||
return requireArray(res.suppliers, 'suppliers');
|
||||
},
|
||||
},
|
||||
);
|
||||
44
src/automats/articleIndexAutomat.js
Normal file
44
src/automats/articleIndexAutomat.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { articleIndex as articleIndexApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleIndexRecord
|
||||
* @property {string|number} id Article id.
|
||||
* @property {string} sku Article SKU.
|
||||
* @property {string} name Article name.
|
||||
* @property {Array<string>} eans Article EANs.
|
||||
* @property {string|null} categories Comma-joined category ids.
|
||||
* @property {boolean} isFather Whether the article has child variants.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company article search index (`GET /api/articleIndex`).
|
||||
*
|
||||
* Backs the article search page, the manifest article picker, and the
|
||||
* optimistic name patches applied by `artikelAutomat`.
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const articleIndexAutomat = createListAutomat({
|
||||
name: 'articleIndex',
|
||||
fetch: () => articleIndexApi.listRecords(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Patches a single record's name in the cached index (optimistic update).
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @param {string} name New article name.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setArticleIndexName(sku, name) {
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items.length) return;
|
||||
articleIndexAutomat.actions.replace(
|
||||
items.map((row) =>
|
||||
String(row.sku) === String(sku) ? { ...row, name } : row,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default articleIndexAutomat;
|
||||
108
src/automats/articleSearchAutomat.js
Normal file
108
src/automats/articleSearchAutomat.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import articleIndexAutomat from './articleIndexAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchResult
|
||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||
* @property {number} totalCount Number of matching records.
|
||||
* @property {Array<object>} recordset Matching article index records.
|
||||
* @property {Error|null} error Search failure, or `null`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchState
|
||||
* @property {ArticleSearchResult} result Latest search outcome.
|
||||
* @property {string} target Active search term.
|
||||
* @property {boolean} loading Whether a search is in flight.
|
||||
*/
|
||||
|
||||
/** @type {ArticleSearchResult} */
|
||||
const emptyResult = {
|
||||
categoryFacets: {},
|
||||
totalCount: 0,
|
||||
recordset: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
/** @type {ArticleSearchState} */
|
||||
const initialState = {
|
||||
result: emptyResult,
|
||||
target: '',
|
||||
loading: false,
|
||||
};
|
||||
|
||||
let articleSearchAutomat;
|
||||
|
||||
/**
|
||||
* Matches one index record against a lowercased search term.
|
||||
*
|
||||
* @param {object} row Article index record.
|
||||
* @param {string} query Lowercased search term.
|
||||
* @returns {boolean} Whether the record matches.
|
||||
*/
|
||||
function matches(row, query) {
|
||||
if (String(row.name ?? '').toLowerCase().includes(query)) return true;
|
||||
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
|
||||
return (row.eans ?? []).some((ean) => String(ean).includes(query));
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Runs a client-side search over the article index.
|
||||
*
|
||||
* The full index is already cached by `articleIndexAutomat`, so this does not
|
||||
* hit the network once the index has loaded.
|
||||
*
|
||||
* @param {string} target Search term; an empty string returns everything.
|
||||
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
||||
*/
|
||||
async search(target) {
|
||||
articleSearchAutomat.setState({ target, loading: true });
|
||||
try {
|
||||
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
||||
const query = String(target ?? '').trim().toLowerCase();
|
||||
const recordset = query
|
||||
? articles.filter((row) => matches(row, query))
|
||||
: articles;
|
||||
const result = {
|
||||
categoryFacets: {},
|
||||
totalCount: recordset.length,
|
||||
recordset,
|
||||
error: null,
|
||||
};
|
||||
articleSearchAutomat.setState({ result, loading: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = { ...emptyResult, error };
|
||||
articleSearchAutomat.setState({ result, loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset the result set. */
|
||||
reset() {
|
||||
articleSearchAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Article search state for the `/artikel` overview page.
|
||||
*
|
||||
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
|
||||
* instead of receiving a callback.
|
||||
*
|
||||
* @type {Automat<ArticleSearchState>}
|
||||
*/
|
||||
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
||||
|
||||
/**
|
||||
* Selects the latest search result.
|
||||
*
|
||||
* @param {ArticleSearchState} state Automat state.
|
||||
* @returns {ArticleSearchResult} Search outcome.
|
||||
*/
|
||||
export function selectSearchResult(state) {
|
||||
return state.result;
|
||||
}
|
||||
|
||||
export default articleSearchAutomat;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArtikelState
|
||||
@@ -125,6 +127,117 @@ const actions = {
|
||||
artikelAutomat.setState({ bySku });
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku with optimistic name handling.
|
||||
*
|
||||
* A `name` in the body is applied to both caches immediately so the tile
|
||||
* does not flicker, then rolled back from the server on failure.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock? }`).
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async update(sku, body) {
|
||||
const key = String(sku);
|
||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||
if (nameChange) {
|
||||
setArticleIndexName(key, body.name);
|
||||
actions.setArticleName(key, body.name);
|
||||
}
|
||||
try {
|
||||
const res = await articleApi.update(key, body);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||
articleIndexAutomat.actions.reset();
|
||||
if (hasArticle(key)) {
|
||||
void actions.loadArticle(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/eans → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<string>} eans Replacement EAN list.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setEans(sku, eans) {
|
||||
const res = await articleApi.setEansBySku(String(sku), eans);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {{ brandId: number|null }} body Brand payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setBrand(sku, body) {
|
||||
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/categories → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<number>} categoryIds Replacement category ids.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setCategories(sku, categoryIds) {
|
||||
const res = await articleApi.setCategories(String(sku), categoryIds);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/prices → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<object>} prices Net price rows.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setPrices(sku, prices) {
|
||||
const res = await articleApi.setPrices(String(sku), prices);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Weight payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setWeight(sku, body) {
|
||||
const res = await articleApi.setWeight(String(sku), body);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/track_stock → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {boolean|object} body Flag or full body.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setTrackStock(sku, body) {
|
||||
const res = await articleApi.setTrackStock(String(sku), body);
|
||||
if (res?.article) actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets every cached article.
|
||||
*
|
||||
@@ -187,4 +300,12 @@ export function selectArticle(sku) {
|
||||
return artikelAutomat.select(createArticleSelector(sku));
|
||||
}
|
||||
|
||||
// Drop cached articles on logout. A company switch keeps them; the SSE hub
|
||||
// reconnects and revalidates whatever is on screen.
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
actions.clear();
|
||||
}
|
||||
});
|
||||
|
||||
export default artikelAutomat;
|
||||
|
||||
@@ -191,4 +191,16 @@ const actions = {
|
||||
*/
|
||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
|
||||
/**
|
||||
* Re-reads `GET /api/auth/me` from the server.
|
||||
*
|
||||
* Called after login, logout, and company switches so the automat reflects
|
||||
* the current server session rather than an optimistic guess.
|
||||
*
|
||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
|
||||
*/
|
||||
export function bootstrapAuthMe() {
|
||||
return auth.me();
|
||||
}
|
||||
|
||||
export default authAutomat;
|
||||
|
||||
@@ -145,6 +145,27 @@ export function disposeAllCategoryViewAutomats() {
|
||||
categoryViews.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parsed category map and root id, or `null` while the tree is
|
||||
* still loading. Replaces the former `StorageManager.getCategoryState()`.
|
||||
*
|
||||
* @returns {{ categories: Record<string,object>, rootCategoryId: unknown }|null}
|
||||
*/
|
||||
export function getCategoryState() {
|
||||
const state = categoryAutomat.state;
|
||||
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
|
||||
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the category tree has loaded.
|
||||
*
|
||||
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
|
||||
*/
|
||||
export function whenCategoryTreeReady() {
|
||||
return waitForCategoryTree();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const actions = {
|
||||
|
||||
148
src/automats/createListAutomat.js
Normal file
148
src/automats/createListAutomat.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ListAutomatState
|
||||
* @property {Array<object>} items Loaded records (empty until the first success).
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a company-scoped list store following the brand/unit/warehouse
|
||||
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
||||
* silently, and `reset()` clears on logout.
|
||||
*
|
||||
* This replaces the stale-while-revalidate list caches that previously lived
|
||||
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
||||
* customergroup, number).
|
||||
*
|
||||
* @template T
|
||||
* @param {object} options Factory options.
|
||||
* @param {string} options.name Automat registry name (also used for logging).
|
||||
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
||||
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
||||
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
||||
*/
|
||||
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
||||
const initialState = { items: [], status: 'idle', error: null };
|
||||
|
||||
let automat;
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Fetches and normalizes the record list.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Records from the backend.
|
||||
*/
|
||||
const fetchRecords = async () => {
|
||||
const items = await fetchItems();
|
||||
return sort ? sort(items) : items;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the list, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the list while keeping the current state, so
|
||||
* subscribers do not flash a loading state.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Fresh records.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<T>} items Records to cache.
|
||||
* @returns {Array<T>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-fetches the list once the selected company is known.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
ensureLoaded() {
|
||||
if (automat.state.status === 'ready') {
|
||||
return Promise.resolve(automat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout). */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
automat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name });
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the record list of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {Array<object>} Current records.
|
||||
*/
|
||||
export function selectListItems(automat) {
|
||||
return automat.state.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the load status of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
||||
*/
|
||||
export function selectListStatus(automat) {
|
||||
return automat.state.status;
|
||||
}
|
||||
30
src/automats/customergroupAutomat.js
Normal file
30
src/automats/customergroupAutomat.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { customergroup as customergroupApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomerGroup
|
||||
* @property {string|number} id Customer group id.
|
||||
* @property {string} name Group name.
|
||||
* @property {number} sort Sort index.
|
||||
* @property {boolean} editable False for reserved POS / Lieferanten groups.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company customer groups (`GET /api/customergroup`).
|
||||
*
|
||||
* Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must
|
||||
* not be renamed or deleted by the UI.
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const customergroupAutomat = createListAutomat({
|
||||
name: 'customergroup',
|
||||
fetch: async () => {
|
||||
const data = await customergroupApi.list();
|
||||
return data.customergroups ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)),
|
||||
});
|
||||
|
||||
export default customergroupAutomat;
|
||||
32
src/automats/deliveryAutomat.js
Normal file
32
src/automats/deliveryAutomat.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { delivery as deliveryApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeliverySummary
|
||||
* @property {string|number} id Delivery id.
|
||||
* @property {string} number Server-allocated manifest number.
|
||||
* @property {string|null} externalNumber User-entered reference.
|
||||
* @property {string} type Always `'delivery'`.
|
||||
* @property {string|number|null} supplierId Supplier id.
|
||||
* @property {string} date Delivery date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company goods receipts (`GET /api/delivery`).
|
||||
*
|
||||
* Backs the delivery list page and the quote/order manifest lists share the
|
||||
* same pattern through their own automats.
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const deliveryAutomat = createListAutomat({
|
||||
name: 'delivery',
|
||||
fetch: async () => {
|
||||
const data = await deliveryApi.list();
|
||||
return data.deliveries ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default deliveryAutomat;
|
||||
31
src/automats/entityAutomat.js
Normal file
31
src/automats/entityAutomat.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { entity as entityApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Entity
|
||||
* @property {string|number} id Entity id.
|
||||
* @property {string|number} customergroupId Owning customer group id.
|
||||
* @property {string|null} firstName First name.
|
||||
* @property {string} name Last / company name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company customers and contacts (`GET /api/entity`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const entityAutomat = createListAutomat({
|
||||
name: 'entity',
|
||||
fetch: async () => {
|
||||
const data = await entityApi.list();
|
||||
return data.entities ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => {
|
||||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||||
if (byName !== 0) return byName;
|
||||
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||||
}),
|
||||
});
|
||||
|
||||
export default entityAutomat;
|
||||
@@ -23,6 +23,25 @@ export {
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
|
||||
/** Article search over the cached index (replaces StorageManager.doSearch). */
|
||||
export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat';
|
||||
|
||||
/** Company-scoped list stores (replaced the old StorageManager caches). */
|
||||
export { default as articleIndexAutomat, setArticleIndexName } from './articleIndexAutomat';
|
||||
export { default as deliveryAutomat } from './deliveryAutomat';
|
||||
export { default as quoteAutomat } from './quoteAutomat';
|
||||
export { default as orderAutomat } from './orderAutomat';
|
||||
export { default as entityAutomat } from './entityAutomat';
|
||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
/** Per-SKU article detail views keyed by SKU. */
|
||||
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
|
||||
/** Single shared GET /api/events connection. */
|
||||
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
|
||||
|
||||
export { default as artikelAutomat } from './artikelAutomat';
|
||||
export {
|
||||
getArticle,
|
||||
@@ -68,6 +87,8 @@ export {
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
getCategoryState,
|
||||
whenCategoryTreeReady,
|
||||
} from './categoryAutomat';
|
||||
|
||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||
|
||||
30
src/automats/numberAutomat.js
Normal file
30
src/automats/numberAutomat.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { number as numberApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberSequence
|
||||
* @property {string|number} id Sequence id.
|
||||
* @property {string} type Document type key (e.g. `delivery`, `entity`).
|
||||
* @property {string} prefix Prefix rendered before the counter.
|
||||
* @property {number} padding Zero-padding width.
|
||||
* @property {number} next Next counter value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company document number sequences (`GET /api/number`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const numberAutomat = createListAutomat({
|
||||
name: 'number',
|
||||
fetch: async () => {
|
||||
const data = await numberApi.list();
|
||||
return data.numbers ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
||||
),
|
||||
});
|
||||
|
||||
export default numberAutomat;
|
||||
28
src/automats/orderAutomat.js
Normal file
28
src/automats/orderAutomat.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { order as orderApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} OrderSummary
|
||||
* @property {string|number} id Order id.
|
||||
* @property {string} number Order number.
|
||||
* @property {string} type Always `'order'`.
|
||||
* @property {string|number|null} entityId Customer id.
|
||||
* @property {string} date Delivery date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company sales orders (`GET /api/order`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const orderAutomat = createListAutomat({
|
||||
name: 'order',
|
||||
fetch: async () => {
|
||||
const data = await orderApi.list();
|
||||
return data.orders ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default orderAutomat;
|
||||
28
src/automats/quoteAutomat.js
Normal file
28
src/automats/quoteAutomat.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { quote as quoteApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuoteSummary
|
||||
* @property {string|number} id Quote id.
|
||||
* @property {string} number Quote number.
|
||||
* @property {string} type Always `'quote'`.
|
||||
* @property {string|number|null} entityId Customer id.
|
||||
* @property {string} date Expiry date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company sales quotes (`GET /api/quote`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const quoteAutomat = createListAutomat({
|
||||
name: 'quote',
|
||||
fetch: async () => {
|
||||
const data = await quoteApi.list();
|
||||
return data.quotes ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default quoteAutomat;
|
||||
269
src/automats/sseAutomat.js
Normal file
269
src/automats/sseAutomat.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
import artikelAutomat from './artikelAutomat';
|
||||
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
import { articleIndexAutomat } from './articleIndexAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
|
||||
import warehouseAutomat from './warehouseAutomat';
|
||||
import attributeAutomat from './attributeAutomat';
|
||||
import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} SseState
|
||||
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
||||
* @property {string|null} companyId Company the stream is bound to.
|
||||
*/
|
||||
|
||||
/** @type {SseState} */
|
||||
const initialState = {
|
||||
status: 'disconnected',
|
||||
companyId: null,
|
||||
};
|
||||
|
||||
/** Event name prefix for per-article signals. */
|
||||
const ARTICLE_EVENT_PREFIX = 'article-';
|
||||
|
||||
let sseAutomat;
|
||||
/** @type {EventSource | null} */
|
||||
let eventSource = null;
|
||||
|
||||
/**
|
||||
* Handlers for statically named events (`category`, `articles`, …).
|
||||
* @type {Map<string, Set<(event: MessageEvent) => void>>}
|
||||
*/
|
||||
const handlers = new Map();
|
||||
|
||||
/**
|
||||
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
||||
* at least one view is open on that SKU.
|
||||
* @type {Map<string, Set<(sku: string) => void>>}
|
||||
*/
|
||||
const articleWatchers = new Map();
|
||||
|
||||
function disconnect() {
|
||||
if (!eventSource) return;
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches every registered handler to a freshly opened stream.
|
||||
*
|
||||
* @param {EventSource} es Open event stream.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachHandlers(es) {
|
||||
for (const [event, set] of handlers) {
|
||||
for (const handler of set) {
|
||||
es.addEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
for (const [sku] of articleWatchers) {
|
||||
attachArticleListener(sku);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the single `article-<sku>` listener for a watched SKU.
|
||||
*
|
||||
* The listener resolves the handler set at call time, so a SKU that lost all
|
||||
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
||||
* reclaimed whenever the stream is rebuilt on a company switch.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachArticleListener(sku) {
|
||||
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
||||
const set = articleWatchers.get(sku);
|
||||
if (!set || set.size === 0) return;
|
||||
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
||||
for (const handler of set) handler(changed);
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('/api/events', window.location.origin).href;
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
es.addEventListener('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[sse] category tree refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
|
||||
// Article index changed: refresh the index and open category views.
|
||||
es.addEventListener('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('supplier', () => {
|
||||
void supplierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = () => {
|
||||
console.error('[sse] event stream error');
|
||||
sseAutomat.setState({ status: 'error' });
|
||||
};
|
||||
|
||||
eventSource = es;
|
||||
attachHandlers(es);
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Closes and reopens the event stream. Required after a company switch,
|
||||
* because the stream is scoped to the selected company at connect time.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
reconnect() {
|
||||
disconnect();
|
||||
connect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a handler for a statically named backend event.
|
||||
*
|
||||
* Registration order does not matter: handlers added while disconnected are
|
||||
* attached on the next connect.
|
||||
*
|
||||
* @param {string} event Event name (e.g. `category`, `articles`).
|
||||
* @param {(event: MessageEvent) => void} handler Signal handler.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
on(event, handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
eventSource?.addEventListener(event, handler);
|
||||
return () => {
|
||||
eventSource?.removeEventListener(event, handler);
|
||||
set.delete(handler);
|
||||
if (set.size === 0) handlers.delete(event);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one SKU.
|
||||
*
|
||||
* The backend emits this event whenever that article changes. Exactly one
|
||||
* listener is attached per SKU while at least one caller is subscribed.
|
||||
*
|
||||
* @param {string} sku Article SKU to watch.
|
||||
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
watchArticle(sku, handler) {
|
||||
const key = String(sku);
|
||||
let set = articleWatchers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
articleWatchers.set(key, set);
|
||||
attachArticleListener(key);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
return () => {
|
||||
const current = articleWatchers.get(key);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) articleWatchers.delete(key);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global `GET /api/events` connection.
|
||||
*
|
||||
* Owns the single `EventSource` for the tab and dispatches signals into the
|
||||
* domain automats. Reconnects whenever the selected company changes.
|
||||
*
|
||||
* @type {Automat<SseState>}
|
||||
*/
|
||||
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||
|
||||
/**
|
||||
* Refreshes the cached article plus any open history/supplier view.
|
||||
*
|
||||
* Background refreshes stay silent: an article that is not on screen has no
|
||||
* visible subscriber to notify, so failures are swallowed.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function invalidateArticle(sku) {
|
||||
const key = String(sku);
|
||||
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
||||
if (articleHistoryAutomat.has(key)) {
|
||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
if (articleSupplierAutomat.has(key)) {
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
if (companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
});
|
||||
|
||||
export default sseAutomat;
|
||||
Reference in New Issue
Block a user