/** * @file brandAutomat.js * @description Manages catalog brands and manufacturers for the active company. * * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. * - `sseAutomat`: Subscribes to the single `'brand'` event carrying * `{ sender, brandId }` — any create/rename/delete/picture change triggers * `actions.reload()`, which silently re-fetches the list. Subscribed views * (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then * re-render from the updated state. This is the reload pattern. Events sent * by this tab (matching `sender`) are ignored. * - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`). */ import { Automat } from 'react-automat'; import { brand as brandApi } from '../api'; import authAutomat from './authAutomat'; import sseAutomat, { getSseClientId } from './sseAutomat'; /** * @typedef {Object} BrandState * @property {Array} brands All brands (full list, decoupled from page UI). * @property {number} total Unpaged brand count. * @property {'idle'|'loading'|'succeeded'|'failed'} status * @property {string|null} error */ /** @type {BrandState} */ const initialState = { brands: [], total: 0, status: 'idle', error: null, }; function sortBrands(brands) { return [...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'), ); } let brandAutomat; /** In-flight `load()`/`reload()` promise so concurrent callers share one request. */ let inflightLoad = null; /** Max page size the API accepts; full list is fetched by paging through. */ const FETCH_PAGE_SIZE = 100; async function fetchAllBrands() { let offset = 0; let total = Infinity; const collected = []; for (;;) { const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE }); if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0); const page = Array.isArray(res.brands) ? res.brands : []; collected.push(...page); offset += page.length; if (page.length === 0 || collected.length >= total) break; } return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length }; } const actions = { /** Load all brands for the selected company (pages through the API). */ async load() { if (inflightLoad) return inflightLoad; brandAutomat.setState({ status: 'loading', error: null }); inflightLoad = (async () => { try { const { brands, total } = await fetchAllBrands(); brandAutomat.setState({ brands, total, status: 'succeeded' }); return brands; } catch (err) { brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); throw err; } finally { inflightLoad = null; } })(); return inflightLoad; }, /** * Silently re-fetches all brands while keeping the current state * visible (no loading flash). Subscribed views re-render from the updated * state — this is the reload pattern for SSE-driven refreshes. * * @returns {Promise>} Fresh brands. */ async reload() { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { try { const { brands, total } = await fetchAllBrands(); brandAutomat.setState({ brands, total, status: 'succeeded', error: null }); return brands; } catch (err) { brandAutomat.setState({ error: err?.message ?? String(err) }); throw err; } finally { inflightLoad = null; } })(); return inflightLoad; }, /** * Create a brand (insert). 409 Brand already exists on duplicate name. * @param {{ name: string, gsprpinfo?: string }} body */ async create(body) { const res = await brandApi.create(body); const row = res.brand; const brands = sortBrands([ ...brandAutomat.state.brands.filter((b) => b.id !== row.id), row, ]); brandAutomat.setState({ brands, total: brands.length }); return row; }, /** * Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`. * @param {number} id * @param {{ name?: string, gsprpinfo?: string }} body */ async rename(id, body) { const res = await brandApi.rename(id, body); const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; const brands = sortBrands( brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)), ); brandAutomat.setState({ brands }); return res; }, /** * Delete a brand by id. * @param {number} id */ async remove(id) { await brandApi.delete(id); const brands = brandAutomat.state.brands.filter((b) => b.id !== id); brandAutomat.setState({ brands, total: brands.length }); }, /** * Add / update brand image. * @param {number} id * @param {Blob|File} file */ async addImage(id, file) { const res = await brandApi.addImage(id, file); await brandAutomat.actions.load(); return res.brand; }, /** * Delete brand image. * @param {number} id */ async deleteImage(id) { const res = await brandApi.deleteImage(id); await brandAutomat.actions.load(); return res.brand; }, /** Reset to idle state (called on logout / company switch). */ reset() { brandAutomat.setState(initialState); }, }; /** * Global brand state container. * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. * * @type {Automat} */ brandAutomat = new Automat(initialState, actions, { name: 'brand' }); // Subscribe to auth changes authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; if (auth.status === 'authed' && companyId) { void brandAutomat.actions.load(); } else if (auth.status === 'anon') { brandAutomat.actions.reset(); } }); // SSE: single `brand` event with `{ sender, brandId }` payload for every // create/rename/delete/picture change. Silently reloads the list; subscribed // views re-render from state. Own events are skipped. sseAutomat.actions.on('brand', (event) => { try { const payload = JSON.parse(event?.data ?? '{}'); if (payload && typeof payload.sender === 'string' && payload.sender) { try { if (payload.sender === getSseClientId()) return; } catch {} } } catch { // Unparseable payload: fall through to reload. } void brandAutomat.actions.reload().catch(() => {}); }); export default brandAutomat;