Files
dashfront/src/automats/brandAutomat.js
2026-10-12 01:31:41 +02:00

160 lines
5.6 KiB
JavaScript

/**
* @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<object>} brands All brands (full list, decoupled from page UI).
* @property {Array<object>} 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<BrandState>}
*/
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;