/** * @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.load(true)`, which silently re-fetches the list. Subscribed views * (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then * re-render from the updated state. 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 {Array} filteredBrands Filtered brands according to active filter query. * @property {string} filter Active search/filter string. * @property {'idle'|'loading'|'succeeded'|'failed'} status * @property {string|null} error */ /** @type {BrandState} */ const initialState = { brands: [], filteredBrands: [], filter: '', status: 'idle', error: null, }; const sortBrands = (brands) => [...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); const applyFilter = (brands, filter) => { const query = String(filter ?? '').trim().toLowerCase(); return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands; }; let brandAutomat; let inflightLoad = null; const FETCH_PAGE_SIZE = 100; // #region fetch async function fetchAllBrands() { let offset = 0, total = Infinity; const collected = []; while (offset === 0 || collected.length < total) { 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) break; } return { brands: sortBrands(collected) }; } const updateStateWithBrands = (brands, extra = {}) => { const filteredBrands = applyFilter(brands, brandAutomat.state.filter); brandAutomat.setState({ brands, filteredBrands, ...extra }); return brands; }; const actions = { /** Sets the active brand search filter and updates filteredBrands. */ filter(query) { const filter = String(query ?? ''); brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) }); }, /** * Load all brands for the selected company (pages through the API). * @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'. */ async load(silent = false) { if (inflightLoad) return inflightLoad; if (!silent) { brandAutomat.setState({ status: 'loading', error: null }); } return (inflightLoad = fetchAllBrands() .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null })) .catch((err) => { const error = err?.message ?? String(err); brandAutomat.setState(silent ? { error } : { status: 'failed', error }); throw err; }) .finally(() => { inflightLoad = null; })); }, // #region add async create(body) { const res = await brandApi.create(body); const row = res.brand; updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row])); return row; }, // #region rename async rename(id, body) { const res = await brandApi.rename(id, body); const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)))); return res; }, // #region del async remove(id) { await brandApi.delete(id); updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id)); }, async removeBrands(ids) { const idSet = new Set(ids); for (const id of ids) { await brandApi.delete(id); } updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id))); await actions.load(true); }, // #region addPic async addImage(id, file) { const res = await brandApi.addImage(id, file); await brandAutomat.actions.load(true); return res.brand; }, // #region delPic async deleteImage(id) { const res = await brandApi.deleteImage(id); const updated = res.brand ?? { pics: [] }; updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b))); return res.brand; } }; /** * Global brand state container. * @type {Automat} */ brandAutomat = new Automat(initialState, actions, { name: 'brand' }); authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; if (auth.status === 'authed' && companyId) void brandAutomat.actions.load(); else if (auth.status === 'anon') brandAutomat.setState(initialState); }); // #region onSse sseAutomat.actions.on('brand', (event) => { try { const payload = JSON.parse(event?.data ?? '{}'); if (payload?.sender && payload.sender === getSseClientId()) return; } catch { } void brandAutomat.actions.load(true).catch(() => { }); }); export default brandAutomat;