213 lines
6.3 KiB
JavaScript
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;
|