/** * @file entityAutomat.js * @description Manages cached customer and contact entities (`GET /api/entity`) * for the active company. Follows the brand/warehouse/customergroup automat pattern * with filtering, deduplicated loads, silent reloads, and CRUD actions. * * Upstream Triggers: * - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`, * and `actions.reset()` on `'anon'`. * - UI / Routes: Mutating actions (`create`, `update`, `remove`, `filter`). */ import { Automat } from 'react-automat'; import { entity as entityApi } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; import customergroupAutomat from './customergroupAutomat'; /** * @typedef {Object} Entity * @property {string|number} id Entity id. * @property {string|number} customergroupId Owning customer group id. * @property {string} [number] Customer number. * @property {string|null} [firstName] First name. * @property {string} name Last / company name. * @property {string} [createdAt] Created timestamp. * @property {string} [updatedAt] Updated timestamp. */ /** * @typedef {Object} EntityState * @property {Array} items Loaded records. * @property {Array} filteredItems Records matching active search filter. * @property {string} filter Active search/filter string. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {Error|string|null} error Last load error. */ /** @type {EntityState} */ const initialState = { items: [], filteredItems: [], filter: '', status: 'idle', error: null, }; const applyFilter = (entities, filter) => { const query = String(filter ?? '').trim().toLowerCase(); if (!query) return entities; const groups = customergroupAutomat?.state?.items ?? []; const groupsById = Object.fromEntries(groups.map((g) => [g.id, g])); return entities.filter((e) => { const name = String(e.name ?? '').toLowerCase(); const firstName = String(e.firstName ?? '').toLowerCase(); const number = String(e.number ?? '').toLowerCase(); const full1 = `${firstName} ${name}`.trim(); const full2 = `${name} ${firstName}`.trim(); const group = String(groupsById[e.customergroupId]?.name ?? '').toLowerCase(); return ( name.includes(query) || firstName.includes(query) || number.includes(query) || full1.includes(query) || full2.includes(query) || group.includes(query) ); }); }; const sortEntities = (items) => [...items].sort((a, b) => { const byName = String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'); if (byName !== 0) return byName; return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de'); }); let entityAutomat; const updateStateWithEntities = (entities, extra = {}) => { const list = sortEntities(Array.isArray(entities) ? entities : []); const filteredItems = applyFilter(list, entityAutomat.state.filter); entityAutomat.setState({ items: list, filteredItems, ...extra }); return list; }; async function fetchEntities() { const data = await entityApi.list(); requireObject(data, 'GET /api/entity'); return requireArray(data.entities, 'entities'); } let inflightLoad = null; function loadEntities() { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { entityAutomat.setState({ status: 'loading', error: null }); try { const entities = await fetchEntities(); updateStateWithEntities(entities, { status: 'ready', error: null }); return entities; } catch (error) { entityAutomat.setState({ status: 'failed', error }); throw error; } finally { inflightLoad = null; } })(); return inflightLoad; } const actions = { /** Sets the active search filter query and updates filteredItems. */ filter(query) { const filter = String(query ?? ''); entityAutomat.setState({ filter, filteredItems: applyFilter(entityAutomat.state.items, filter), }); }, /** Loads the list, deduplicating concurrent callers. */ load() { return loadEntities(); }, /** Silently refreshes the entities without flashing loading state. */ async reload() { try { const entities = await fetchEntities(); updateStateWithEntities(entities, { status: 'ready', error: null }); return entities; } catch (error) { entityAutomat.setState({ error }); throw error; } }, /** Replaces the cached list without an API call. */ replace(items) { return updateStateWithEntities(items, { status: 'ready', error: null }); }, /** Returns cached items if ready, otherwise loads. */ ensureLoaded() { if (entityAutomat.state.status === 'ready') { return Promise.resolve(entityAutomat.state.items); } return actions.load(); }, /** Resets state to initial idle values. */ reset() { inflightLoad = null; entityAutomat.setState(initialState); }, /** Creates a customer entity and refreshes the cache. */ async create(body) { const data = await entityApi.create(body); requireObject(data, 'POST /api/entity'); requireObject(data.entity, 'entity'); requireDefined(data.entity.id, 'entity.id'); await entityAutomat.actions.reload(); return data.entity; }, /** Updates an entity name/fields and refreshes the cache. */ async update(id, body) { const data = await entityApi.update(id, body); requireObject(data, 'PATCH /api/entity/:id'); requireObject(data.entity, 'entity'); requireDefined(data.entity.id, 'entity.id'); await entityAutomat.actions.reload(); return data.entity; }, rename(id, body) { return actions.update(id, body); }, /** Deletes an entity and refreshes the cache. */ async remove(id) { await entityApi.delete(id); await entityAutomat.actions.reload(); }, }; /** * Company customers and contacts (`GET /api/entity`). * * @type {Automat} */ entityAutomat = new Automat(initialState, actions, { name: 'entity', }); export { entityAutomat }; // Load on session/company establishment, reset on logout. authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; if (auth.status === 'authed' && companyId) { void actions.load().catch(() => {}); } else if (auth.status === 'anon') { actions.reset(); } }); export default entityAutomat;