160 lines
5.6 KiB
JavaScript
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; |