Files
dashfront/src/automats/entityAutomat.js
2026-10-11 12:49:13 +02:00

213 lines
6.3 KiB
JavaScript

/**
* @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<Entity>} items Loaded records.
* @property {Array<Entity>} 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<EntityState>}
*/
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;