Files
dashfront/src/automats/brandAutomat.js
2026-10-09 19:44:55 +02:00

214 lines
6.5 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.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<object>} 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<Array<object>>} 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<BrandState>}
*/
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;