u
This commit is contained in:
251
src/automats/carrierAutomat.js
Normal file
251
src/automats/carrierAutomat.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file carrierAutomat.js
|
||||
* @description Manages cached shipping carriers / Lieferarten (`GET /api/carrier`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { carrier as carrierApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Carrier
|
||||
* @property {number} id Carrier identifier.
|
||||
* @property {string} name Carrier name.
|
||||
* @property {number} sort Sort order.
|
||||
* @property {boolean} hasPackages Whether carrier uses package tracking.
|
||||
* @property {boolean} isReverse Whether carrier is used for returns.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CarrierState
|
||||
* @property {Array<Carrier>} carriers Company-scoped carriers.
|
||||
* @property {Array<Carrier>} filteredCarriers Filtered carriers according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {CarrierState} */
|
||||
const initialState = {
|
||||
carriers: [],
|
||||
filteredCarriers: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let carrierAutomat;
|
||||
|
||||
const applyFilter = (carriers, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers;
|
||||
};
|
||||
|
||||
const updateStateWithCarriers = (carriers, extra = {}) => {
|
||||
const list = Array.isArray(carriers) ? carriers : [];
|
||||
const filteredCarriers = applyFilter(list, carrierAutomat.state.filter);
|
||||
carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped carrier list.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Carrier list.
|
||||
*/
|
||||
async function fetchCarriers() {
|
||||
const data = await carrierApi.list();
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.carriers)) return data.carriers;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the carrier list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
function loadCarriers() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
carrierAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active carrier search filter and updates filteredCarriers. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
carrierAutomat.setState({
|
||||
filter,
|
||||
filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the carrier list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
load() {
|
||||
return loadCarriers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the carrier list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Fresh carriers.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload.
|
||||
* @returns {Promise<Carrier>} Created carrier.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await carrierApi.create(body);
|
||||
const created = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload.
|
||||
* @returns {Promise<Carrier>} Updated carrier.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await carrierApi.update(id, body);
|
||||
const updated = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await carrierApi.delete(id);
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Persists a new sort order for all carriers.
|
||||
*
|
||||
* @param {Array<string|number>} carrierIds IDs in the desired order.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reorder(carrierIds) {
|
||||
const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c]));
|
||||
const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean);
|
||||
if (reordered.length === carrierAutomat.state.carriers.length) {
|
||||
updateStateWithCarriers(reordered);
|
||||
}
|
||||
await carrierApi.order({ carrierIds });
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Carrier>} carriers Carriers to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCarriers(carriers) {
|
||||
updateStateWithCarriers(carriers, {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
carrierAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped carrier cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<CarrierState>}
|
||||
*/
|
||||
carrierAutomat = new Automat(initialState, actions, { name: 'carrier' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the carrier list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
carrierAutomat.actions.clear();
|
||||
} else {
|
||||
void carrierAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default carrierAutomat;
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
export { default as carrierAutomat } from './carrierAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export {
|
||||
|
||||
@@ -36,6 +36,7 @@ import deliveryAutomat from './deliveryAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
import shipmentAutomat from './shipmentAutomat';
|
||||
import carrierAutomat from './carrierAutomat';
|
||||
|
||||
|
||||
/**
|
||||
@@ -223,6 +224,12 @@ function connect() {
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'carrier', () => {
|
||||
void carrierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] carrier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
|
||||
Reference in New Issue
Block a user