This commit is contained in:
seb
2026-10-04 21:04:40 +02:00
parent 669954b496
commit f765b87336
59 changed files with 1491 additions and 2045 deletions

View 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');
},
},
);

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = {

View 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;
}

View 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;

View 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;

View 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;

View File

@@ -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';

View 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;

View 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;

View 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
View 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;