This commit is contained in:
seb
2026-10-15 20:15:19 +02:00
parent 8e783a571f
commit beed294cb0
26 changed files with 3165 additions and 906 deletions

View File

@@ -0,0 +1,240 @@
/**
* @file shipmentAutomat.js
* @description Manages cached and paginated company shipment manifests (Lieferscheine) (`GET /api/shipment`).
*
* Upstream Triggers:
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
* - `sseAutomat`: Triggers `actions.reload()` on `'shipment'` SSE events.
*
* Actions:
* - `load()`: Fetches shipments with current filters.
* - `reload()`: Silently re-fetches shipments.
* - `setFilter(filter)`: Updates free-text search filter.
* - `setStateFilter(state)`: Updates delivery state filter ('', 'new', 'delivered', 'returned').
* - `updateState(id, state)`: Calls `PATCH /api/shipment/:id` and updates local cache.
* - `deleteShipment(id)`: Calls `DELETE /api/shipment/:id` and removes from local cache.
*/
import { Automat } from 'react-automat';
import { shipment as shipmentApi } from '../api';
import authAutomat from './authAutomat';
import orderAutomat from './orderAutomat';
/**
* @typedef {Object} ShipmentSummary
* @property {number} id
* @property {string} number
* @property {'shipment'} type
* @property {string} date
* @property {string|null} createdAt
* @property {number|null} orderId
* @property {string|null} orderNumber
* @property {number|null} entityId
* @property {string|null} customerName
* @property {number} positions
* @property {number} totalQuantity
* @property {number} packageCount
* @property {string|null} carrier
* @property {string|null} trackingCode
* @property {'new'|'delivered'|'returned'} state
*/
/**
* @typedef {Object} ShipmentAutomatState
* @property {ShipmentSummary[]|null} items
* @property {number} total
* @property {number} unfilteredTotal
* @property {string} filter
* @property {string} stateFilter
* @property {number} seekMonths
* @property {'idle'|'loading'|'ready'|'failed'} status
* @property {Error|string|null} error
*/
const initialState = {
items: null,
total: 0,
unfilteredTotal: 0,
filter: '',
stateFilter: '',
seekMonths: 0,
status: 'idle',
error: null,
};
let automat;
let inflightLoad = null;
let debounceTimer = null;
// #region fetch helper
async function fetchShipments(state) {
const params = {
offset: 0,
limit: 100,
};
if (state.filter && state.filter.trim()) {
params.search = state.filter.trim();
}
if (state.stateFilter) {
params.state = state.stateFilter;
}
if (state.seekMonths) {
params.seekMonths = state.seekMonths;
}
const res = await shipmentApi.list(params);
return {
items: res?.shipments || [],
total: res?.total ?? (res?.shipments?.length || 0),
unfilteredTotal: res?.unfilteredTotal ?? res?.total ?? (res?.shipments?.length || 0),
};
}
// #endregion
const actions = {
// #region load & reload
async load() {
if (inflightLoad) return inflightLoad;
automat.setState((s) => ({
status: s.items === null ? 'loading' : s.status,
error: null,
}));
inflightLoad = (async () => {
try {
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
automat.setState({
items,
total,
unfilteredTotal,
status: 'ready',
error: null,
});
return items;
} catch (err) {
automat.setState({
status: 'failed',
error: err,
});
throw err;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
async reload() {
try {
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
automat.setState({
items,
total,
unfilteredTotal,
status: 'ready',
error: null,
});
return items;
} catch (err) {
console.warn('[shipmentAutomat] silent reload failed:', err);
}
},
// #endregion
// #region filters
setFilter(filterText) {
const next = typeof filterText === 'string' ? filterText : '';
if (automat.state.filter === next) return;
automat.setState({ filter: next });
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
void automat.actions.load().catch(() => {});
}, 200);
},
setStateFilter(stateFilter) {
const next = typeof stateFilter === 'string' ? stateFilter : '';
if (automat.state.stateFilter === next) return;
automat.setState({ stateFilter: next });
void automat.actions.load().catch(() => {});
},
setSeekMonths(months) {
automat.setState({ seekMonths: months });
void automat.actions.load().catch(() => {});
},
// #endregion
// #region item mutations
patchShipment(id, patch) {
automat.setState((s) => {
if (!Array.isArray(s.items)) return null;
const next = s.items.map((item) => (item.id === id ? { ...item, ...patch } : item));
return { items: next };
});
},
async updateState(id, newState) {
// Optimistic update
automat.actions.patchShipment(id, { state: newState });
try {
await shipmentApi.update(id, { state: newState });
void orderAutomat.actions.reload().catch(() => {});
} catch (err) {
// Rollback on error
void automat.actions.reload();
throw err;
}
},
async deleteShipment(id) {
await shipmentApi.delete(id);
automat.setState((s) => {
if (!Array.isArray(s.items)) return null;
const next = s.items.filter((item) => item.id !== id);
return {
items: next,
total: Math.max(0, s.total - 1),
unfilteredTotal: Math.max(0, s.unfilteredTotal - 1),
};
});
void orderAutomat.actions.reload().catch(() => {});
},
reset() {
if (debounceTimer) clearTimeout(debounceTimer);
inflightLoad = null;
automat.setState({ ...initialState });
},
// #endregion
};
automat = new Automat(initialState, actions, { name: 'shipment' });
// #region authAutomat synchronization
let lastCompanyId = null;
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId ?? null;
if (auth.status === 'anon') {
automat.actions.reset();
lastCompanyId = null;
return;
}
if (lastCompanyId !== null && companyId !== lastCompanyId) {
automat.actions.reset();
}
if (auth.status === 'authed' && companyId != null) {
const isNew = lastCompanyId !== companyId;
lastCompanyId = companyId;
if (isNew) {
void automat.actions.load().catch(() => {});
}
}
});
// #endregion
export default automat;