supplierAutomat

This commit is contained in:
seb
2026-09-21 10:55:17 +02:00
parent 98080f0514
commit 2046f96768
9 changed files with 222 additions and 34 deletions

View File

@@ -382,7 +382,6 @@ export class StorageManager extends ApiClient {
const companyCache = createCache('GET /company', () => this.#loadCompanies()); const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree()); const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals()); const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () => const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(), this.#loadCustomergroups(),
@@ -424,11 +423,6 @@ export class StorageManager extends ApiClient {
posterminalPromise: { get: () => posterminalCache.promise }, posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update }, updatePosterminal: { value: posterminalCache.update },
supplierPromise: { get: () => supplierCache.promise },
updateSupplier: { value: supplierCache.update },
/** Settled GET /supplier list, or undefined. */
suppliers: { get: () => supplierCache.value },
deliveryPromise: { get: () => deliveryCache.promise }, deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update }, updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */ /** Settled GET /delivery list, or undefined. */
@@ -580,12 +574,6 @@ export class StorageManager extends ApiClient {
return requireArray(data.posterminals, 'posterminals'); return requireArray(data.posterminals, 'posterminals');
} }
async #loadSuppliers() {
const data = await this.supplier.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
async #loadDeliveries() { async #loadDeliveries() {
const data = await this.delivery.list(); const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery'); requireObject(data, 'GET /api/delivery');

View File

@@ -3,3 +3,4 @@ export { default as authAutomat } from './authAutomat';
export * from './authAutomat'; export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat'; export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as supplierAutomat } from './supplierAutomat';

View File

@@ -0,0 +1,201 @@
import { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
/**
* @typedef {Object} Supplier
* @property {string|number} id Supplier identifier.
* @property {string} name Supplier name.
*/
/**
* @typedef {Object} SupplierState
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|null} error Last load error.
*/
/** @type {SupplierState} */
const initialState = {
suppliers: [],
status: 'idle',
error: null,
};
let supplierAutomat;
/**
* Fetches the company-scoped supplier list.
*
* @returns {Promise<Array<Supplier>>} Supplier list.
*/
async function fetchSuppliers() {
const data = await supplierApi.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
/** In-flight `load()` promise so concurrent consumers share one request. */
let inflightLoad = null;
/**
* Loads the supplier list once, deduplicating concurrent callers.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
function loadSuppliers() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
supplierAutomat.setState({ status: 'loading', error: null });
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
return suppliers;
} catch (error) {
supplierAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/**
* Loads the supplier list, exposing a loading/failed lifecycle.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
load() {
return loadSuppliers();
},
/**
* Silently re-fetches the supplier list while keeping the current state.
*
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
*/
async reload() {
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
return suppliers;
} catch (error) {
supplierAutomat.setState({ error });
throw error;
}
},
/**
* Creates a supplier and refreshes the cached list.
*
* @param {{name: string}} body Creation payload.
* @returns {Promise<Supplier>} Created supplier.
*/
async create(body) {
const data = await supplierApi.create(body);
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Renames a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @param {{name: string}} body Update payload.
* @returns {Promise<Supplier>} Updated supplier.
*/
async update(id, body) {
const data = await supplierApi.update(id, body);
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Deletes a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @returns {Promise<void>}
*/
async remove(id) {
await supplierApi.delete(id);
await supplierAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Supplier>} suppliers Suppliers to cache.
* @returns {void}
*/
setSuppliers(suppliers) {
supplierAutomat.setState({
suppliers: Array.isArray(suppliers) ? suppliers : [],
status: 'ready',
error: null,
});
},
/**
* Forgets the cached list and returns to the initial state.
*
* @returns {void}
*/
clear() {
supplierAutomat.setState({ ...initialState });
},
};
/**
* Company-scoped supplier cache. Loads automatically once an authenticated
* session with a selected company exists and is cleared when auth is gone.
*
* @type {Automat<SupplierState>}
*/
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
/**
* 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 supplier 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) {
supplierAutomat.actions.clear();
} else {
void supplierAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
export default supplierAutomat;

View File

@@ -23,6 +23,7 @@ import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { formatPrice } from '../../../utils/price'; import { formatPrice } from '../../../utils/price';
import supplierAutomat from '../../../automats/supplierAutomat';
import ArticleSupplierDialog from '../ArticleSupplierDialog'; import ArticleSupplierDialog from '../ArticleSupplierDialog';
export default class ArticleSupplierTab extends PureComponent { export default class ArticleSupplierTab extends PureComponent {
@@ -80,7 +81,7 @@ export default class ArticleSupplierTab extends PureComponent {
// 2. Fetch available company suppliers and company metadata // 2. Fetch available company suppliers and company metadata
try { try {
const [companySuppliers, company] = await Promise.all([ const [companySuppliers, company] = await Promise.all([
this.context.suppliers ?? this.context.supplierPromise, supplierAutomat.actions.load(),
this.context.company ?? this.context.companyPromise, this.context.company ?? this.context.companyPromise,
]); ]);
this.setState({ this.setState({

View File

@@ -346,13 +346,13 @@ class ManifestFormLine extends PureComponent {
</TableRow> </TableRow>
{/* Row 2: Matched Supplier Article Details */} {/* Row 2: Matched Supplier Article Details */}
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: '1px solid', borderColor: 'divider' } }}> <TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
<TableCell <TableCell
colSpan={columns.length + 1} colSpan={columns.length + 1}
sx={{ sx={{
py: 0.4, py: 0.4,
px: 2, px: 2,
bgcolor: 'rgba(248, 250, 252, 0.75)', bgcolor: 'transparent',
}} }}
> >
{this.renderSecondLine()} {this.renderSecondLine()}

View File

@@ -41,7 +41,7 @@ class ManifestLines extends PureComponent {
<TableContainer> <TableContainer>
<Table size="small" stickyHeader> <Table size="small" stickyHeader>
<TableHead> <TableHead>
<TableRow> <TableRow sx={{ '& th': { borderBottom: 'none', fontWeight: 700 } }}>
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableCell
key={col.id} key={col.id}
@@ -50,12 +50,13 @@ class ManifestLines extends PureComponent {
width: col.width, width: col.width,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
bgcolor: linesColor, bgcolor: linesColor,
fontWeight: 700,
}} }}
> >
{col.label} {col.label}
</TableCell> </TableCell>
))} ))}
<TableCell padding="checkbox" sx={{ bgcolor: linesColor }} /> <TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>

View File

@@ -1,3 +1,5 @@
import supplierAutomat from '../../automats/supplierAutomat';
/** Groups that are not sales customers (filter client-side). */ /** Groups that are not sales customers (filter client-side). */
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']); export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
@@ -424,7 +426,10 @@ export const DELIVERY_LINE_COLUMNS = [
*/ */
export async function loadParties(kind, ctx) { export async function loadParties(kind, ctx) {
if (kind === 'delivery') { if (kind === 'delivery') {
const list = ctx.suppliers ?? (await ctx.supplierPromise); if (supplierAutomat.state.status === 'ready') {
return sortSuppliers(supplierAutomat.state.suppliers);
}
const list = await supplierAutomat.actions.load();
return sortSuppliers(list); return sortSuppliers(list);
} }
const [entities, groups] = await Promise.all([ const [entities, groups] = await Promise.all([

View File

@@ -9,6 +9,7 @@ import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App'; import App from './App';
import { store } from './store'; import { store } from './store';
import { getActiveTheme } from './theme'; import { getActiveTheme } from './theme';
import './automats/supplierAutomat';
import './index.css'; import './index.css';
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically // Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically

View File

@@ -16,11 +16,10 @@ import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import withNavigate from '../../components/withNavigate'; import withNavigate from '../../components/withNavigate';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastDeliveryPath } from '../../automats'; import { setLastDeliveryPath } from '../../automats';
import supplierAutomat from '../../automats/supplierAutomat';
import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat'; import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat';
@@ -51,8 +50,6 @@ function getInitialParams(props) {
} }
class LieferungLieferantenPage extends PureComponent { class LieferungLieferantenPage extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
const { prefill } = getInitialParams(props); const { prefill } = getInitialParams(props);
@@ -109,7 +106,7 @@ class LieferungLieferantenPage extends PureComponent {
async loadSuppliers() { async loadSuppliers() {
this.setState({ status: 'loading', error: null }); this.setState({ status: 'loading', error: null });
try { try {
const list = this.context.suppliers ?? (await this.context.supplierPromise); const list = await supplierAutomat.actions.load();
if (this._cancelled) return; if (this._cancelled) return;
this.applySuppliers(list); this.applySuppliers(list);
} catch (err) { } catch (err) {
@@ -123,7 +120,7 @@ class LieferungLieferantenPage extends PureComponent {
} }
async refreshFromServer() { async refreshFromServer() {
const list = await this.context.updateSupplier(); const list = await supplierAutomat.actions.reload();
if (this._cancelled) return; if (this._cancelled) return;
this.applySuppliers(list); this.applySuppliers(list);
} }
@@ -136,11 +133,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: 'new' }); this.setState({ busyKey: 'new' });
try { try {
const data = await this.context.supplier.create({ name }); const newSupplier = await supplierAutomat.actions.create({ name });
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
const newSupplier = data.supplier;
// Assign the new supplier to the active delivery draft & clear unmatched name // Assign the new supplier to the active delivery draft & clear unmatched name
deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id); deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
@@ -174,10 +167,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
const data = await this.context.supplier.update(supplier.id, { name: draft }); await supplierAutomat.actions.update(supplier.id, { name: draft });
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await this.refreshFromServer(); await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
@@ -200,7 +190,7 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
await this.context.supplier.delete(supplier.id); await supplierAutomat.actions.remove(supplier.id);
await this.refreshFromServer(); await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });