This commit is contained in:
seb
2026-09-21 03:19:32 +02:00
parent 5594c59d88
commit 366d2517d6
17 changed files with 467 additions and 269 deletions

View File

@@ -1,6 +1,58 @@
import { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api';
/**
* @typedef {Object} DeliveryLine
* @property {string|number} key Stable editor key.
* @property {string|null} name Article name.
* @property {string|number|null} sku Supplier or internal article SKU.
* @property {number|null} amount Ordered amount.
* @property {number|null} pu Price unit.
* @property {string|null} unit Unit of measure.
* @property {number|null} netprice Net price.
* @property {number|null} taxrate Tax rate.
*/
/**
* @typedef {Object} SupplierArticle
* @property {string|number} [id] Supplier-article identifier.
* @property {string|number|null} [sku] Supplier SKU.
* @property {{sku?: string|number|null, eans?: Array<string|number>, articleean?: Array<string|number>}} [article]
* Linked internal article.
*/
/**
* @typedef {Object} DeliveryEditorState
* @property {Array<Object>} deliveryEntities Available suppliers or delivery parties.
* @property {string|number|null} id Delivery identifier.
* @property {string|number|null} deliveryId Alias of `id`.
* @property {string|null} date Delivery date.
* @property {string|number|null} deliveryEntity Selected delivery party.
* @property {string|number|null} partyId Alias of `deliveryEntity`.
* @property {string|null} externalNumber External delivery number.
* @property {string|null} external_number API-compatible alias of `externalNumber`.
* @property {string|null} number Internal delivery number.
* @property {string|null} type Delivery type.
* @property {Array<DeliveryLine>} lines Editable lines.
* @property {Array<DeliveryLine>} lineitems Alias of `lines`.
* @property {Array<*>} notes Delivery notes.
* @property {Array<Object>} attachments Delivery attachments.
* @property {Array<Object>} files Alias of `attachments`.
* @property {Array<SupplierArticle>} supplierArticles Loaded supplier articles.
* @property {Object<string, SupplierArticle>} supplierArticlesMap Articles indexed by SKU or EAN.
* @property {Object<string, SupplierArticle|null>} supplierArticlesByLineKey Matched article per line.
* @property {boolean} supplierArticlesLoading Whether any supplier article request is pending.
* @property {Object<string, boolean>} loadingLineKeys Pending lookup state per line.
* @property {string|null} supplierArticlesError Last supplier article error.
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
*/
/**
* Normalizes a partial line into an editor line with a stable key.
*
* @param {Partial<DeliveryLine>} [line] Initial line values.
* @returns {DeliveryLine} Normalized line.
*/
export function createEmptyLineitem(line = {}) {
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -14,6 +66,14 @@ export function createEmptyLineitem(line = {}) {
};
}
/**
* Indexes supplier articles by supplier SKU, internal SKU, and EAN. Text keys
* are stored both verbatim and lower-cased for case-insensitive lookup.
*
* @param {Array<SupplierArticle>} [articles] Articles to index.
* @param {Object<string, SupplierArticle>} [existingMap] Existing index to extend.
* @returns {Object<string, SupplierArticle>} Article lookup map.
*/
export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
const map = { ...existingMap };
for (const item of articles) {
@@ -49,6 +109,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
return map;
}
/** @type {Omit<DeliveryEditorState, 'deliveryEntities'>} */
export const initialDelivery = {
id: null,
deliveryId: null,
@@ -74,11 +135,19 @@ export const initialDelivery = {
};
/** @type {DeliveryEditorState} */
export const initialState = {
deliveryEntities: [],
...initialDelivery,
};
/**
* Creates a delivery editor automat or returns the already registered named
* instance. Named instances allow independent state for multiple deliveries.
*
* @param {{name?: string|null}|string} [options] Automat options or name.
* @returns {Automat<DeliveryEditorState>} Delivery editor automat.
*/
export function createDeliveryEditorAutomat(options = {}) {
const name = typeof options === 'string' ? options : options?.name ?? null;
if (name) {
@@ -88,14 +157,21 @@ export function createDeliveryEditorAutomat(options = {}) {
let automat;
const actions = {
/** @param {string|number|null} id @returns {void} */
setId(id) {
automat.setState({ id, deliveryId: id });
},
/** @param {Array<Object>} deliveryEntities @returns {void} */
setDeliveryEntities(deliveryEntities) {
automat.setState({ deliveryEntities: deliveryEntities ?? [] });
},
/**
* Replaces the edited delivery and normalizes API aliases.
* @param {Partial<DeliveryEditorState>} [delivery]
* @returns {void}
*/
setDelivery(delivery = {}) {
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
const lines = Array.isArray(rawLines) ? rawLines : [];
@@ -129,10 +205,16 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {string|null} date @returns {void} */
setDate(date) {
automat.setState({ date });
},
/**
* Selects a delivery party and refreshes its supplier articles.
* @param {string|number|null} entityId
* @returns {void}
*/
setDeliveryEntity(entityId) {
const prevId = automat.state.deliveryEntity ?? automat.state.partyId;
const idChanged = prevId !== entityId;
@@ -155,15 +237,18 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {string|null} name @returns {void} */
setUnmatchedSupplierName(name) {
automat.setState({ unmatchedSupplierName: name ?? null });
},
/** @param {string|number|null} partyId @returns {void} */
setPartyId(partyId) {
automat.actions.setDeliveryEntity(partyId);
},
/** @param {string|null} externalNumber @returns {void} */
setExternalNumber(externalNumber) {
automat.setState({
externalNumber,
@@ -171,24 +256,29 @@ export function createDeliveryEditorAutomat(options = {}) {
});
},
/** @param {string|null} number @returns {void} */
setNumber(number) {
automat.setState({ number });
},
/** @param {string|null} type @returns {void} */
setType(type) {
automat.setState({ type });
},
/** @param {Array<DeliveryLine>} lines @returns {void} */
setLines(lines) {
const cleanLines = lines ?? [];
automat.setState({ lines: cleanLines, lineitems: cleanLines });
},
/** @param {Array<DeliveryLine>} lines @returns {void} */
setLineitems(lines) {
const cleanLines = lines ?? [];
automat.setState({ lines: cleanLines, lineitems: cleanLines });
},
/** @param {Array<*>|*} notes @returns {void} */
setNotes(notes) {
const cleanNotes = Array.isArray(notes)
? notes
@@ -196,16 +286,23 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.setState({ notes: cleanNotes });
},
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
addLineitem(line = {}) {
const item = createEmptyLineitem(line);
const nextLines = [...(automat.state.lines ?? []), item];
automat.setState({ lines: nextLines, lineitems: nextLines });
},
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
addLine(line = {}) {
automat.actions.addLineitem(line);
},
/**
* Updates a line selected by index or key.
* @param {{index?: number, key?: string|number, [key: string]: *}} payload
* @returns {void}
*/
updateLineitem(payload) {
const { index, key, ...fields } = payload || {};
const currentLines = automat.state.lines ?? [];
@@ -223,10 +320,12 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {{index?: number, key?: string|number, [key: string]: *}} payload @returns {void} */
updateLine(payload) {
automat.actions.updateLineitem(payload);
},
/** @param {number|string|{key: string|number}} payload @returns {void} */
removeLineitem(payload) {
const currentLines = automat.state.lines ?? [];
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
@@ -251,10 +350,17 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {number|string|{key: string|number}} payload @returns {void} */
removeLine(payload) {
automat.actions.removeLineitem(payload);
},
/**
* Loads supplier articles for all current SKUs and rebuilds line matches.
* @param {string|number|null} [targetEntityId] Supplier to query.
* @param {Array<DeliveryLine>} [targetLines] Lines whose SKUs should be queried.
* @returns {Promise<void>}
*/
async fetchSupplierArticles(targetEntityId, targetLines) {
const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId;
if (!entityId) {
@@ -311,6 +417,12 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/**
* Loads and matches a supplier article for one line SKU.
* @param {string|number|null} sku SKU to query.
* @param {string|number} [lineKey] Editor line key receiving the match.
* @returns {Promise<void>}
*/
async fetchSupplierArticleForSku(sku, lineKey) {
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
const trimmed = sku != null ? String(sku).trim() : '';
@@ -370,6 +482,7 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {Object} attachment @returns {void} */
addAttachment(attachment) {
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const item = { ...attachment, id };
@@ -377,11 +490,13 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.setState({ attachments: next, files: next });
},
/** @param {Array<Object>} attachments @returns {void} */
setAttachments(attachments) {
const clean = Array.isArray(attachments) ? attachments : [];
automat.setState({ attachments: clean, files: clean });
},
/** @param {{id: string|number, [key: string]: *}} payload @returns {void} */
updateAttachment(payload) {
const { id, ...fields } = payload || {};
const current = automat.state.attachments ?? [];
@@ -393,6 +508,7 @@ export function createDeliveryEditorAutomat(options = {}) {
}
},
/** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */
removeAttachment(idOrPayload) {
const current = automat.state.attachments ?? [];
const targetId =
@@ -411,12 +527,14 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.setState({ attachments: next, files: next });
},
/** Resets delivery fields while retaining the available entities. @returns {void} */
resetDelivery() {
automat.setState({
...initialDelivery,
});
},
/** Resets the complete editor state. @returns {void} */
resetDeliveryEditor() {
automat.setState({
...initialState,
@@ -432,9 +550,17 @@ export function createDeliveryEditorAutomat(options = {}) {
return automat;
}
/** Shared automat used for new, unsaved deliveries. */
export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
/** Backward-compatible alias of {@link deliveryDraftAutomat}. */
export const deliveryEditorAutomat = deliveryDraftAutomat;
/**
* Returns the draft automat or the named automat belonging to a saved delivery.
*
* @param {string|number|null} docId Delivery identifier; `null` selects the draft.
* @returns {Automat<DeliveryEditorState>} Delivery-specific editor automat.
*/
export function getDeliveryEditorAutomat(docId) {
if (docId == null) {
return deliveryDraftAutomat;
@@ -478,22 +604,38 @@ export const {
} = deliveryEditorAutomat.actions;
// Selectors
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
/** @param {DeliveryEditorState} [state] @returns {string|null} */
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
/** @param {DeliveryEditorState} [state] @returns {Array<DeliveryLine>} */
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
/** @param {DeliveryEditorState} [state] @returns {Array<*>} */
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
/** @param {DeliveryEditorState} [state] @returns {Array<SupplierArticle>} */
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle>} */
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle|null>} */
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
/** @param {DeliveryEditorState} [state] @returns {boolean} */
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys;
export default deliveryEditorAutomat;