This commit is contained in:
seb
2026-10-08 22:44:51 +02:00
parent 22063e6944
commit ee93f71b27
11 changed files with 1047 additions and 67 deletions

View File

@@ -1,39 +1,236 @@
/**
* @file orderAutomat.js
* @description Manages cached company sales orders (`GET /api/order`).
* Built via `createListAutomat`.
* @description Manages cached and paginated company sales orders (`GET /api/order`).
*
* 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: Calls to `load({ offset, limit })`, `setPage(page)`, or `reload()`.
*/
import { Automat } from 'react-automat';
import { order as orderApi } from '../api';
import { createListAutomat } from './createListAutomat';
import authAutomat from './authAutomat';
/**
* @typedef {Object} OrderSummary
* @property {string|number} id Order id.
* @typedef {Object} OrderLine
* @property {number} id
* @property {string|null} sku
* @property {string|null} name
* @property {number} pu
* @property {number} amount
* @property {number} net
* @property {number} rebate
* @property {number} taxrate
* @property {string|null} comment
*/
/**
* @typedef {Object} Order
* @property {number} id Order id.
* @property {string} number Order number.
* @property {string} type Always `'order'`.
* @property {string|number|null} entityId Customer id.
* @property {'order'} type Always `'order'`.
* @property {number|null} entityId Customer id.
* @property {string|null} externalNumber External reference.
* @property {string} date Delivery date (ISO).
* @property {string} createdAt Creation timestamp (ISO).
* @property {OrderLine[]} lines Order line items.
*/
/**
* Company sales orders (`GET /api/order`).
*
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
* @typedef {Object} OrderAutomatState
* @property {Order[]} items Loaded orders for the current page.
* @property {number} total Total unpaged count for pagination calculation.
* @property {number} offset Current 0-based offset.
* @property {number} limit Current page limit.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error.
*/
export const orderAutomat = createListAutomat({
name: 'order',
fetch: async () => {
const data = await orderApi.list();
return data.orders ?? [];
const initialState = {
items: [],
total: 0,
offset: 0,
limit: 20,
status: 'idle',
error: null,
};
let automat;
let inflightLoad = null;
const actions = {
/**
* Loads paginated orders, deduplicating concurrent callers.
*
* @param {{ offset?: number, limit?: number }} [params]
* @returns {Promise<Order[]>}
*/
load({ offset, limit } = {}) {
const currentOffset = offset ?? automat.state.offset ?? 0;
const currentLimit = limit ?? automat.state.limit ?? 20;
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
automat.setState({ status: 'loading', error: null });
try {
const res = await orderApi.list({ offset: currentOffset, limit: currentLimit });
const orders = res?.orders ?? [];
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? currentOffset),
limit: Number(res?.limit ?? currentLimit),
status: 'ready',
error: null,
});
return orders;
} catch (error) {
automat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
sort: (items) =>
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
/**
* Silently re-fetches the current page of orders without flashing loading state.
*
* @returns {Promise<Order[]>}
*/
async reload() {
const { offset, limit } = automat.state;
try {
const res = await orderApi.list({ offset, limit });
const orders = res?.orders ?? [];
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? offset),
limit: Number(res?.limit ?? limit),
status: 'ready',
error: null,
});
return orders;
} catch (error) {
automat.setState({ error });
throw error;
}
},
/**
* Navigates to a 1-based page using formula: offset = (page - 1) * limit
*
* @param {number} page 1-based page number.
* @returns {Promise<Order[]>}
*/
setPage(page) {
const limit = automat.state.limit || 20;
const offset = Math.max(0, (page - 1) * limit);
return actions.load({ offset, limit });
},
/**
* Sets pagination offset directly.
*
* @param {number} offset 0-based offset.
* @returns {Promise<Order[]>}
*/
setOffset(offset) {
return actions.load({ offset, limit: automat.state.limit });
},
/**
* Sets page limit (records to return) and resets offset to 0.
*
* @param {number} limit Records per page (1..100).
* @returns {Promise<Order[]>}
*/
setLimit(limit) {
return actions.load({ offset: 0, limit });
},
/**
* Ensures orders are loaded once the selected company is known.
*
* @returns {Promise<Order[]>}
*/
ensureLoaded() {
if (automat.state.status === 'ready') {
return Promise.resolve(automat.state.items);
}
return actions.load();
},
/**
* Replaces cached orders without network round-trip.
*
* @param {Order[]} items
* @returns {Order[]}
*/
replace(items) {
automat.setState({
items,
total: items.length,
status: 'ready',
error: null,
});
return items;
},
/**
* Resets to idle state (called on logout).
*/
reset() {
inflightLoad = null;
automat.setState(initialState);
},
};
export const orderAutomat = new Automat(initialState, actions, { name: 'order' });
automat = orderAutomat;
// 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();
}
});
/** Selects current orders */
export function selectOrders(state = automat.state) {
return state.items;
}
/** Selects total unpaged count */
export function selectOrderTotal(state = automat.state) {
return state.total;
}
/**
* Calculates client pagination metadata.
* Current Page: Math.floor(offset / limit) + 1
* Total Pages: Math.ceil(total / limit)
* Next Offset: (page - 1) * limit
*/
export function selectOrderPagination(state = automat.state) {
const total = state.total ?? 0;
const offset = state.offset ?? 0;
const limit = state.limit || 20;
const currentPage = Math.floor(offset / limit) + 1;
const pageCount = Math.max(1, Math.ceil(total / limit));
return {
total,
offset,
limit,
currentPage,
pageCount,
};
}
export default orderAutomat;