This commit is contained in:
seb
2026-10-13 00:33:38 +02:00
parent 1ad9b14aa6
commit 4c5ccf7f64
12 changed files with 1152 additions and 645 deletions

View File

@@ -1,10 +1,10 @@
/**
* @file orderAutomat.js
* @description Manages cached and paginated company sales orders (`GET /api/order`).
* @description Manages cached and chunk-paginated company sales orders (`GET /api/order`).
*
* Upstream Triggers:
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
* - UI / Routes: On-demand calls to `ensureLoaded()`, `load()`, or `reload()`.
* - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
*/
import { Automat } from 'react-automat';
@@ -38,47 +38,39 @@ import authAutomat from './authAutomat';
/**
* @typedef {Object} OrderAutomatState
* @property {Order[]} items Loaded orders for the current page.
* @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
* @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 CHUNK_SIZE = 50;
const PREFETCH_CHUNKS = 2;
const initialState = {
items: [],
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
items: null,
total: 0,
offset: 0,
limit: 20,
hasMore: false,
loadingMore: false,
status: 'idle',
error: null,
};
let automat;
let inflightLoad = null;
let inflightMore = null;
const FETCH_PAGE_SIZE = 100;
const loadedChunks = new Set();
const inflightChunks = new Map();
// MARK: pure order helpers
export function isOrderPaid(order) {
if (!order) return false;
return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt);
}
export function isOrderDelivered(order) {
if (!order) return false;
return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
}
export function getOrderDate(order) {
if (!order) return null;
const raw = order.date || order.createdAt;
if (!raw) return null;
const d = new Date(raw);
@@ -87,7 +79,6 @@ export function getOrderDate(order) {
/**
* Calculates cutoff date for negative seek.
* Negative seek filters orders older than or equal to (today + months).
* @param {number} months Negative integer (e.g. -1 for 1 month ago, -12 for 1 year ago)
* @returns {Date|null}
*/
@@ -101,7 +92,6 @@ export function getNegativeSeekCutoff(months) {
/**
* Formats negative seek duration in human readable German.
* e.g. -1 -> "-1 Monat", -2 -> "-2 Monate", -12 -> "-1 Jahr", -13 -> "-1 Jahr 1 Monat"
* @param {number} months
* @returns {string}
*/
@@ -120,8 +110,8 @@ export function formatNegativeSeek(months) {
return `-${parts.join(' ')}`;
}
// MARK: fetches a single page from GET /api/order
async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) {
// MARK: fetches a single chunk from GET /api/order
async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) {
const res = await orderApi.list({ offset, limit });
const orders = Array.isArray(res?.orders) ? res.orders : [];
const total = Number(res?.total ?? orders.length);
@@ -129,9 +119,9 @@ async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) {
}
/**
* Client-side filter over number, external reference, customer name, paid/delivered flags, and negative seek.
* Client-side filter helper over loaded orders.
* @param {Order[]} items
* @param {string|object} filterOrOpts String filter query or options object { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }
* @param {string|object} filterOrOpts String filter query or options object
* @param {(order: Order) => string} [nameOf] Customer name resolver.
*/
export function filterOrders(items, filterOrOpts, nameOf) {
@@ -147,11 +137,11 @@ export function filterOrders(items, filterOrOpts, nameOf) {
const cutoff = getNegativeSeekCutoff(seekMonths);
return items.filter((o) => {
if (!o) return false;
if (onlyUnpaid && isOrderPaid(o)) return false;
if (onlyUndelivered && isOrderDelivered(o)) return false;
if (cutoff) {
const orderDate = getOrderDate(o);
// Older than cutoff point: order date <= cutoff
if (!orderDate || orderDate.getTime() > cutoff.getTime()) {
return false;
}
@@ -166,230 +156,173 @@ export function filterOrders(items, filterOrOpts, nameOf) {
}
const actions = {
/** Sets the active search filter. */
filter(query) {
automat.setState({ filter: String(query ?? '') });
},
/** Sets whether to filter for only unpaid orders. */
setOnlyUnpaid(onlyUnpaid) {
automat.setState({ onlyUnpaid: Boolean(onlyUnpaid) });
},
/** Sets whether to filter for only undelivered orders. */
setOnlyUndelivered(onlyUndelivered) {
automat.setState({ onlyUndelivered: Boolean(onlyUndelivered) });
},
/** Increments seek by 1 month. */
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
},
/** Increments seek by 1 year. */
addSeekYear() {
const seekMonths = automat.state.seekMonths ?? 0;
const seekYears = (automat.state.seekYears ?? 0) + 1;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekYears, negativeSeekMonths: totalMonths });
},
/** Cumulative negative seek: adds months (e.g. -1 or -12) to current negativeSeekMonths. */
addNegativeSeek(months) {
if (months === -12) {
actions.addSeekYear();
} else if (months === -1) {
actions.addSeekMonth();
} else {
const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
const next = current + months;
automat.setState({ negativeSeekMonths: Math.min(0, next) });
// MARK: returns current items and triggers chunk 0 if not loaded
getItems() {
if (automat.state.items === null) {
void actions.requireChunk(0);
return null;
}
return automat.state.items;
},
/** Resets negative seek back to 0 (all dates). */
resetNegativeSeek() {
automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
},
/** Resets all filters. */
resetFilters() {
automat.setState({
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
});
},
/**
* Synchronously returns current items and triggers loading if idle.
* On remounts, directly returns cached items from the automat.
*
* @param {(order: Order) => string} [nameOf] Optional customer name resolver.
* @returns {Order[]}
*/
getOrLoad(nameOf) {
if (automat.state.status === 'idle') {
void actions.load().catch(() => {});
// MARK: ensures chunkIndex and lookahead chunks are loaded/loading
requireChunk(chunkIndex) {
const start = Math.max(0, Number(chunkIndex) || 0);
const p = actions.loadChunk(start);
for (let c = start + 1; c <= start + PREFETCH_CHUNKS; c++) {
const total = automat.state.total;
if (total > 0 && c * CHUNK_SIZE >= total) {
break;
}
void actions.loadChunk(c);
}
const { items, filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state;
return filterOrders(items, { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }, nameOf);
return p;
},
/**
* Loads the initial 100 orders, deduplicating concurrent callers.
*
* @returns {Promise<Order[]>}
*/
load() {
if (inflightLoad) return inflightLoad;
automat.setState({ status: 'loading', error: null });
inflightLoad = fetchOrdersPage(0, FETCH_PAGE_SIZE)
// MARK: fetches a single chunk and populates prefilled array
loadChunk(chunkIndex) {
const index = Math.max(0, Number(chunkIndex) || 0);
if (loadedChunks.has(index)) {
return Promise.resolve(automat.state.items);
}
if (inflightChunks.has(index)) {
return inflightChunks.get(index);
}
const offset = index * CHUNK_SIZE;
const limit = CHUNK_SIZE;
if (automat.state.items === null && automat.state.status === 'idle') {
automat.setState({ status: 'loading', error: null });
}
const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => {
let items = automat.state.items;
const currentTotal = total > 0 ? total : (orders.length + offset);
if (items === null) {
// Pre-fill items array with that many empty (null) items
items = new Array(currentTotal).fill(null);
} else if (items.length !== currentTotal) {
const next = new Array(currentTotal).fill(null);
const copyLen = Math.min(items.length, currentTotal);
for (let i = 0; i < copyLen; i++) {
next[i] = items[i];
}
items = next;
} else {
// Shallow copy to notify subscribers and PureComponents
items = [...items];
}
// Fill in real received items
for (let i = 0; i < orders.length; i++) {
const idx = offset + i;
if (idx < items.length) {
items[idx] = orders[i];
}
}
loadedChunks.add(index);
automat.setState({
items: orders,
total,
offset: 0,
limit: FETCH_PAGE_SIZE,
hasMore: orders.length < total && orders.length >= FETCH_PAGE_SIZE,
loadingMore: false,
items,
total: currentTotal,
status: 'ready',
error: null,
});
return orders;
})
.catch((error) => {
automat.setState({ status: 'failed', error });
throw error;
})
.finally(() => { inflightLoad = null; });
return inflightLoad;
},
/**
* Loads the next batch of 100 orders when pagination approaches the end.
*
* @returns {Promise<Order[]>}
*/
loadMore(minNeeded) {
const { items, total, status, loadingMore } = automat.state;
if (status !== 'ready' || loadingMore) return Promise.resolve(items);
if (total > 0 && items.length >= total) return Promise.resolve(items);
if (inflightMore) return inflightMore;
// Trigger lookahead prefetching if first chunk just arrived
if (index === 0) {
for (let c = 1; c <= PREFETCH_CHUNKS; c++) {
if (c * CHUNK_SIZE < currentTotal) {
void actions.loadChunk(c);
}
}
}
automat.setState({ loadingMore: true });
const offset = items.length;
const fetchLimit =
minNeeded && minNeeded > offset
? Math.max(FETCH_PAGE_SIZE, minNeeded - offset)
: FETCH_PAGE_SIZE;
inflightMore = fetchOrdersPage(offset, fetchLimit)
.then(({ orders: nextBatch, total: newTotal }) => {
const existingIds = new Set(automat.state.items.map((o) => o.id));
const uniqueNext = nextBatch.filter((o) => !existingIds.has(o.id));
const merged = [...automat.state.items, ...uniqueNext];
const finalTotal = Math.max(newTotal, merged.length);
automat.setState({
items: merged,
total: finalTotal,
hasMore: nextBatch.length > 0 && merged.length < finalTotal,
loadingMore: false,
});
return merged;
return items;
})
.catch((err) => {
console.error('[orderAutomat] loadMore error:', err);
automat.setState({ loadingMore: false });
return automat.state.items;
console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
if (automat.state.items === null) {
automat.setState({ status: 'failed', error: err });
}
throw err;
})
.finally(() => {
inflightMore = null;
inflightChunks.delete(index);
});
return inflightMore;
inflightChunks.set(index, promise);
return promise;
},
/**
* Silently re-fetches loaded orders without flashing loading state.
*
* @returns {Promise<Order[]>}
*/
async reload() {
try {
const currentCount = Math.max(FETCH_PAGE_SIZE, automat.state.items.length);
const { orders, total } = await fetchOrdersPage(0, currentCount);
automat.setState({
items: orders,
total,
hasMore: orders.length < total,
loadingMore: false,
status: 'ready',
error: null,
});
return orders;
} catch (error) {
automat.setState({ error });
throw error;
}
// MARK: initial load
load() {
return actions.loadChunk(0);
},
/**
* Ensures orders are loaded once the selected company is known.
*
* @returns {Promise<Order[]>}
*/
// MARK: ensure loaded
ensureLoaded() {
if (automat.state.status === 'ready') {
if (automat.state.items !== null) {
return Promise.resolve(automat.state.items);
}
return actions.load();
return actions.loadChunk(0);
},
/**
* Replaces cached orders without network round-trip.
*
* @param {Order[]} items
* @returns {Order[]}
*/
// MARK: reload all previously loaded chunks
async reload() {
const chunksToReload = Array.from(loadedChunks);
loadedChunks.clear();
inflightChunks.clear();
if (chunksToReload.length === 0) {
return actions.loadChunk(0);
}
await actions.loadChunk(0);
for (const c of chunksToReload) {
if (c !== 0 && c * CHUNK_SIZE < automat.state.total) {
void actions.loadChunk(c);
}
}
return automat.state.items;
},
// MARK: replace items directly
replace(items) {
loadedChunks.clear();
inflightChunks.clear();
const count = Array.isArray(items) ? items.length : 0;
automat.setState({
items,
total: items.length,
hasMore: false,
loadingMore: false,
total: count,
status: 'ready',
error: null,
});
return items;
},
/**
* Patches an order locally in the automat items cache.
*
* @param {number|string} id Order ID.
* @param {Partial<Order>} patch Partial properties to update.
* @returns {Order[]}
*/
// MARK: patches an order locally in the automat items cache
patchOrder(id, patch) {
const items = automat.state.items.map((o) =>
o.id === id ? { ...o, ...patch } : o,
);
automat.setState({ items });
if (!Array.isArray(automat.state.items)) return automat.state.items;
let changed = false;
const items = automat.state.items.map((o) => {
if (o && o.id === id) {
changed = true;
return { ...o, ...patch };
}
return o;
});
if (changed) {
automat.setState({ items });
}
return items;
},
/**
* Resets to idle state (called on logout).
*/
// MARK: resets state
reset() {
inflightLoad = null;
inflightMore = null;
loadedChunks.clear();
inflightChunks.clear();
automat.setState(initialState);
},
};
@@ -397,7 +330,20 @@ const actions = {
export const orderAutomat = new Automat(initialState, actions, { name: 'order' });
automat = orderAutomat;
// Reset on logout or company change (lazy-loaded on demand by views via ensureLoaded).
// MARK: exported methods for direct invocation
export function getItems() {
return actions.getItems();
}
export function requireChunk(chunkIndex) {
return actions.requireChunk(chunkIndex);
}
orderAutomat.getItems = getItems;
orderAutomat.requireChunk = requireChunk;
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
// Reset on logout or company change
let lastCompanyId = null;
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId ?? null;
@@ -424,59 +370,32 @@ export function selectOrderTotal(state = automat.state) {
return state.total;
}
/** Selects whether more orders can be fetched from backend */
export function selectHasMoreOrders(state = automat.state) {
return state.hasMore;
}
/** Selects whether an incremental fetch is currently in flight */
export function selectIsLoadingMoreOrders(state = automat.state) {
return state.loadingMore;
}
/** Selects unpaid filter */
export function selectOnlyUnpaid(state = automat.state) {
return state.onlyUnpaid;
return false;
}
/** Selects undelivered filter */
export function selectOnlyUndelivered(state = automat.state) {
return state.onlyUndelivered;
return false;
}
/** Selects negative seek offset in months */
export function selectNegativeSeekMonths(state = automat.state) {
return state.negativeSeekMonths;
}
/** Selects seek month count */
export function selectSeekMonths(state = automat.state) {
return state.seekMonths ?? 0;
}
/** Selects seek year count */
export function selectSeekYears(state = automat.state) {
return state.seekYears ?? 0;
return 0;
}
/**
* 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,
offset: 0,
limit: CHUNK_SIZE,
currentPage: 1,
pageCount: Math.max(1, Math.ceil(total / CHUNK_SIZE)),
};
}