u
This commit is contained in:
@@ -1,42 +1,212 @@
|
||||
/**
|
||||
* @file entityAutomat.js
|
||||
* @description Manages cached customer and contact entities (`GET /api/entity`)
|
||||
* for the active company. Built via `createListAutomat`.
|
||||
* for the active company. Follows the brand/warehouse/customergroup automat pattern
|
||||
* with filtering, deduplicated loads, silent reloads, and CRUD actions.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
||||
* - `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 { createListAutomat } from './createListAutomat';
|
||||
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|null} firstName First name.
|
||||
* @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 {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
* @type {Automat<EntityState>}
|
||||
*/
|
||||
export const entityAutomat = createListAutomat({
|
||||
entityAutomat = new Automat(initialState, actions, {
|
||||
name: 'entity',
|
||||
fetch: async () => {
|
||||
const data = await entityApi.list();
|
||||
return data.entities ?? [];
|
||||
},
|
||||
sort: (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');
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user