u
This commit is contained in:
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user