607 lines
18 KiB
JavaScript
607 lines
18 KiB
JavaScript
/**
|
|
* @file manifestDraftAutomat.js
|
|
* @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat).
|
|
* Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files.
|
|
*/
|
|
|
|
import { Automat } from 'react-automat';
|
|
import { selectCompanyTaxrate } from './companyAutomat';
|
|
import artikelAutomat, { getArticle } from './artikelAutomat';
|
|
import {
|
|
emptyLine,
|
|
lineFromApi,
|
|
dateInputValue,
|
|
todayInputValue,
|
|
parseNum,
|
|
} from '../components/manifest/manifestConfig';
|
|
|
|
/**
|
|
* Creates the initial empty draft state for a manifest kind.
|
|
*
|
|
* @param {'quote'|'order'} kind
|
|
* @returns {object} Initial state
|
|
*/
|
|
export function createInitialManifestDraftState(kind) {
|
|
return {
|
|
kind,
|
|
mode: 'create',
|
|
id: null,
|
|
date: kind === 'quote' ? todayInputValue() : '',
|
|
partyId: null,
|
|
number: '',
|
|
externalNumber: '',
|
|
lines: [emptyLine()],
|
|
attachments: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates an Automat instance managing draft state for a manifest kind.
|
|
*
|
|
* @param {{ kind: 'quote'|'order', name: string }} options
|
|
* @returns {Automat}
|
|
*/
|
|
export function createManifestDraftAutomat({ kind, name }) {
|
|
let automat;
|
|
|
|
const actions = {
|
|
/** @param {string} date */
|
|
setDate(date) {
|
|
automat.setState({ date: dateInputValue(date) });
|
|
},
|
|
|
|
/** @param {number|string|null} partyId */
|
|
setPartyId(partyId) {
|
|
automat.setState({ partyId: partyId != null ? partyId : null });
|
|
},
|
|
|
|
/** @param {string} externalNumber */
|
|
setExternalNumber(externalNumber) {
|
|
automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' });
|
|
},
|
|
|
|
/** @param {string} number */
|
|
setNumber(number) {
|
|
automat.setState({ number: number != null ? String(number) : '' });
|
|
},
|
|
|
|
/** @param {Array<object>} lines */
|
|
setLines(lines) {
|
|
const list = Array.isArray(lines) && lines.length > 0 ? lines : [emptyLine()];
|
|
automat.setState({ lines: list });
|
|
},
|
|
|
|
/**
|
|
* Updates an existing line by key.
|
|
* @param {string} key
|
|
* @param {string|object} patchOrField
|
|
* @param {*} [value]
|
|
*/
|
|
updateLine(key, patchOrField, value) {
|
|
const current = automat.state.lines || [];
|
|
const next = current.map((line) => {
|
|
if (line.key !== key) return line;
|
|
if (typeof patchOrField === 'object' && patchOrField != null) {
|
|
return { ...line, ...patchOrField };
|
|
}
|
|
return { ...line, [patchOrField]: value };
|
|
});
|
|
automat.setState({ lines: next });
|
|
},
|
|
|
|
/** @param {object} [line] */
|
|
addLine(line = null) {
|
|
const current = automat.state.lines || [];
|
|
const newLine = line ? { ...emptyLine(), ...line } : emptyLine();
|
|
automat.setState({ lines: [...current, newLine] });
|
|
},
|
|
|
|
/** @param {string} key */
|
|
removeLine(key) {
|
|
const current = automat.state.lines || [];
|
|
const next = current.filter((l) => l.key !== key);
|
|
automat.setState({ lines: next.length > 0 ? next : [emptyLine()] });
|
|
},
|
|
|
|
removeAllLines() {
|
|
automat.setState({ lines: [emptyLine()] });
|
|
},
|
|
|
|
/** @param {Array<object>} attachments */
|
|
setAttachments(attachments) {
|
|
automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] });
|
|
},
|
|
|
|
/** Resets draft state back to clean initial state. */
|
|
reset() {
|
|
automat.setState(createInitialManifestDraftState(kind));
|
|
},
|
|
|
|
/**
|
|
* Applies full manifest update (from external agent, VFS, or SSE).
|
|
* @param {object} content
|
|
*/
|
|
setManifest(content) {
|
|
if (!content) return;
|
|
const manifest = content.manifest || content;
|
|
const updates = {};
|
|
|
|
if (manifest.date !== undefined) {
|
|
updates.date = dateInputValue(manifest.date);
|
|
}
|
|
const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId;
|
|
if (party !== undefined) {
|
|
updates.partyId = party != null ? party : null;
|
|
}
|
|
const ext = manifest.externalNumber ?? manifest.external_number;
|
|
if (ext !== undefined) {
|
|
updates.externalNumber = ext != null ? String(ext) : '';
|
|
}
|
|
|
|
const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems;
|
|
if (Array.isArray(rawLines)) {
|
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
|
const lines = rawLines.map((row) => {
|
|
const mapped = lineFromApi(row);
|
|
if (!mapped.amount) mapped.amount = '1';
|
|
if (!mapped.tax) {
|
|
mapped.tax = normTax;
|
|
mapped.taxrate = normTax;
|
|
}
|
|
if (!mapped.rebate) mapped.rebate = '0';
|
|
if (!mapped.name && mapped.sku) {
|
|
const art = getArticle(mapped.sku);
|
|
if (art?.name) mapped.name = art.name;
|
|
}
|
|
return mapped;
|
|
});
|
|
updates.lines = lines.length > 0 ? lines : [emptyLine()];
|
|
|
|
for (const l of updates.lines) {
|
|
if (l.sku) {
|
|
void artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
automat.setState(updates);
|
|
},
|
|
|
|
/**
|
|
* Serializes current draft state into VFS draft manifest format.
|
|
* @returns {object} Manifest payload
|
|
*/
|
|
getManifest() {
|
|
const state = automat.state;
|
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
|
|
|
const formattedLines = (state.lines || [])
|
|
.filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross)))
|
|
.map((l) => ({
|
|
sku: l.sku || null,
|
|
name: l.name || null,
|
|
amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1),
|
|
net: parseNum(l.net) ?? parseNum(l.netprice) ?? null,
|
|
gross: parseNum(l.gross) ?? null,
|
|
taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax),
|
|
rebate: parseNum(l.rebate) ?? 0,
|
|
pu: parseNum(l.pu) ?? 1,
|
|
unit: l.unit || null,
|
|
}));
|
|
|
|
return {
|
|
manifest: {
|
|
kind,
|
|
mode: 'create',
|
|
id: null,
|
|
date: state.date || null,
|
|
partyId: state.partyId ?? null,
|
|
externalNumber: state.externalNumber || null,
|
|
lines: formattedLines,
|
|
items: formattedLines,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
automat = new Automat(createInitialManifestDraftState(kind), actions, {
|
|
name,
|
|
persist: false,
|
|
});
|
|
|
|
return automat;
|
|
}
|
|
|
|
/** Shared singleton automat for quote draft. */
|
|
export const quoteDraftAutomat = createManifestDraftAutomat({
|
|
kind: 'quote',
|
|
name: 'quote-draft',
|
|
});
|
|
|
|
/** Shared singleton automat for order draft. */
|
|
export const orderDraftAutomat = createManifestDraftAutomat({
|
|
kind: 'order',
|
|
name: 'order-draft',
|
|
});
|
|
|
|
/**
|
|
* Returns the draft automat for a given manifest kind.
|
|
* @param {'quote'|'order'} kind
|
|
* @returns {Automat|null}
|
|
*/
|
|
export function getManifestDraftAutomat(kind) {
|
|
if (kind === 'quote') return quoteDraftAutomat;
|
|
if (kind === 'order') return orderDraftAutomat;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Computes detailed diff between current manifest draft/edit state and baseline snapshot.
|
|
*
|
|
* @param {object} current
|
|
* @param {object|null} baseline
|
|
* @returns {{ isDirty: boolean, fields: { date: boolean, partyId: boolean, externalNumber: boolean, number: boolean, lines: boolean, attachments: boolean }, dirtyLineKeys: string[], summary: string[] }}
|
|
*/
|
|
export function getManifestDirtyDetails(current, baseline) {
|
|
if (!baseline) {
|
|
return {
|
|
isDirty: false,
|
|
fields: {
|
|
date: false,
|
|
partyId: false,
|
|
externalNumber: false,
|
|
number: false,
|
|
lines: false,
|
|
attachments: false,
|
|
},
|
|
dirtyLineKeys: [],
|
|
summary: [],
|
|
};
|
|
}
|
|
|
|
const fields = {
|
|
date: (current.date || '') !== (baseline.date || ''),
|
|
partyId:
|
|
(current.partyId != null && current.partyId !== '' ? Number(current.partyId) : null) !==
|
|
(baseline.partyId != null && baseline.partyId !== '' ? Number(baseline.partyId) : null),
|
|
externalNumber:
|
|
String(current.externalNumber || '').trim() !== String(baseline.externalNumber || '').trim(),
|
|
number: String(current.number || '').trim() !== String(baseline.number || '').trim(),
|
|
lines: false,
|
|
attachments: false,
|
|
};
|
|
|
|
const dirtyLineKeys = [];
|
|
const dirtyLineFields = {};
|
|
const curLines = current.lines || [];
|
|
const baseLines = baseline.lines || [];
|
|
|
|
const baseLinesByKey = new Map();
|
|
for (let i = 0; i < baseLines.length; i++) {
|
|
const bl = baseLines[i];
|
|
if (bl.key) baseLinesByKey.set(bl.key, bl);
|
|
if (bl.id != null) baseLinesByKey.set(`id-${bl.id}`, bl);
|
|
}
|
|
|
|
if (curLines.length !== baseLines.length) {
|
|
fields.lines = true;
|
|
} else {
|
|
for (let i = 0; i < curLines.length; i++) {
|
|
const cl = curLines[i];
|
|
const bl = baseLines[i];
|
|
const clIdentifier = cl?.key ?? (cl?.id != null ? `id-${cl.id}` : null);
|
|
const blIdentifier = bl?.key ?? (bl?.id != null ? `id-${bl.id}` : null);
|
|
if (clIdentifier && blIdentifier && clIdentifier !== blIdentifier) {
|
|
fields.lines = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < curLines.length; i++) {
|
|
const cl = curLines[i];
|
|
const key = cl.key ?? `idx-${i}`;
|
|
const bl =
|
|
(cl.key && baseLinesByKey.get(cl.key)) ||
|
|
(cl.id != null && baseLinesByKey.get(`id-${cl.id}`)) ||
|
|
(baseLinesByKey.size === 0 ? baseLines[i] : null);
|
|
|
|
if (!bl) {
|
|
dirtyLineKeys.push(key);
|
|
dirtyLineFields[key] = { __all__: true };
|
|
fields.lines = true;
|
|
} else {
|
|
const lineFieldDiffs = {};
|
|
if (String(cl.sku ?? '').trim() !== String(bl.sku ?? '').trim()) {
|
|
lineFieldDiffs.sku = true;
|
|
}
|
|
if (String(cl.name ?? '').trim() !== String(bl.name ?? '').trim()) {
|
|
lineFieldDiffs.name = true;
|
|
}
|
|
if (String(cl.amount ?? cl.count ?? '').trim() !== String(bl.amount ?? bl.count ?? '').trim()) {
|
|
lineFieldDiffs.amount = true;
|
|
lineFieldDiffs.count = true;
|
|
}
|
|
if (String(cl.net ?? cl.netprice ?? '').trim() !== String(bl.net ?? bl.netprice ?? '').trim()) {
|
|
lineFieldDiffs.net = true;
|
|
lineFieldDiffs.netprice = true;
|
|
}
|
|
if (String(cl.gross ?? '').trim() !== String(bl.gross ?? '').trim()) {
|
|
lineFieldDiffs.gross = true;
|
|
}
|
|
if (String(cl.rebate ?? '').trim() !== String(bl.rebate ?? '').trim()) {
|
|
lineFieldDiffs.rebate = true;
|
|
}
|
|
if (String(cl.tax ?? cl.taxrate ?? '').trim() !== String(bl.tax ?? bl.taxrate ?? '').trim()) {
|
|
lineFieldDiffs.tax = true;
|
|
lineFieldDiffs.taxrate = true;
|
|
}
|
|
if (String(cl.pu ?? cl.vpe ?? '').trim() !== String(bl.pu ?? bl.vpe ?? '').trim()) {
|
|
lineFieldDiffs.pu = true;
|
|
lineFieldDiffs.vpe = true;
|
|
}
|
|
if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) {
|
|
lineFieldDiffs.unit = true;
|
|
}
|
|
|
|
if (Object.keys(lineFieldDiffs).length > 0) {
|
|
dirtyLineKeys.push(key);
|
|
dirtyLineFields[key] = lineFieldDiffs;
|
|
fields.lines = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check attachments
|
|
const curAtts = current.attachments || [];
|
|
const baseAtts = baseline.attachments || [];
|
|
if (curAtts.length !== baseAtts.length) {
|
|
fields.attachments = true;
|
|
} else {
|
|
for (const a of curAtts) {
|
|
if (!a.isPersisted || a.isDirty) {
|
|
fields.attachments = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const summary = [];
|
|
if (fields.date) summary.push('Datum');
|
|
if (fields.partyId) summary.push(current.kind === 'delivery' ? 'Lieferant' : 'Kunde');
|
|
if (fields.externalNumber) summary.push('Referenz');
|
|
if (fields.number) summary.push('Nummer');
|
|
if (fields.lines) {
|
|
const count = dirtyLineKeys.length;
|
|
summary.push(count > 0 ? `${count} Artikelzeile${count === 1 ? '' : 'n'}` : 'Artikelzeilen');
|
|
}
|
|
if (fields.attachments && current.kind !== 'quote') summary.push('Dateien');
|
|
|
|
const isDirty = summary.length > 0;
|
|
|
|
return {
|
|
isDirty,
|
|
fields,
|
|
dirtyLineKeys,
|
|
dirtyLineFields,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compares current manifest draft/edit state against a baseline snapshot.
|
|
*
|
|
* @param {object} current
|
|
* @param {object|null} baseline
|
|
* @returns {boolean} True if any field or line has changed from baseline
|
|
*/
|
|
export function checkManifestDirty(current, baseline) {
|
|
return getManifestDirtyDetails(current, baseline).isDirty;
|
|
}
|
|
|
|
/**
|
|
* Creates an Automat managing the single last-edited manifest for that kind.
|
|
*
|
|
* @param {{ kind: 'quote'|'order', name: string }} options
|
|
* @returns {Automat}
|
|
*/
|
|
export function createManifestEditAutomat({ kind, name }) {
|
|
let automat;
|
|
|
|
const emptyFields = {
|
|
date: false,
|
|
partyId: false,
|
|
externalNumber: false,
|
|
number: false,
|
|
lines: false,
|
|
attachments: false,
|
|
};
|
|
|
|
const actions = {
|
|
loadDoc(id, doc) {
|
|
const date = dateInputValue(doc.date);
|
|
const partyId = doc.partyId ?? null;
|
|
const number = doc.number ?? '';
|
|
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
|
|
const lines = Array.isArray(doc.lines) && doc.lines.length > 0 ? doc.lines : [emptyLine()];
|
|
const attachments = Array.isArray(doc.attachments) ? doc.attachments : [];
|
|
|
|
const baseline = {
|
|
date,
|
|
partyId,
|
|
number,
|
|
externalNumber,
|
|
lines: lines.map((l) => ({ ...l })),
|
|
attachments: [...attachments],
|
|
};
|
|
|
|
automat.setState({
|
|
kind,
|
|
mode: 'edit',
|
|
id,
|
|
date,
|
|
partyId,
|
|
number,
|
|
externalNumber,
|
|
lines,
|
|
attachments,
|
|
shipments: doc.shipments || [],
|
|
payments: doc.payments || [],
|
|
shippings: doc.shippings || [],
|
|
orderTotal: doc.orderTotal ?? null,
|
|
paidTotal: doc.paidTotal ?? null,
|
|
paid: Boolean(doc.paid),
|
|
shipped: Boolean(doc.shipped),
|
|
delivered: Boolean(doc.delivered),
|
|
baseline,
|
|
isDirty: false,
|
|
dirtyFields: { ...emptyFields },
|
|
dirtyLineKeys: [],
|
|
dirtyLineFields: {},
|
|
dirtySummary: [],
|
|
});
|
|
},
|
|
|
|
update(updates) {
|
|
const next = { ...automat.state, ...updates };
|
|
const dirtyInfo = getManifestDirtyDetails(next, automat.state.baseline);
|
|
automat.setState({
|
|
...updates,
|
|
isDirty: dirtyInfo.isDirty,
|
|
dirtyFields: dirtyInfo.fields,
|
|
dirtyLineKeys: dirtyInfo.dirtyLineKeys,
|
|
dirtyLineFields: dirtyInfo.dirtyLineFields,
|
|
dirtySummary: dirtyInfo.summary,
|
|
});
|
|
},
|
|
|
|
setAttachments(attachments) {
|
|
const list = Array.isArray(attachments) ? attachments : [];
|
|
const base = automat.state.baseline;
|
|
const nextBaseline = base ? { ...base, attachments: [...list] } : null;
|
|
automat.setState({
|
|
attachments: list,
|
|
baseline: nextBaseline,
|
|
});
|
|
},
|
|
|
|
restoreBaseline() {
|
|
if (!automat.state.baseline) return;
|
|
const base = automat.state.baseline;
|
|
automat.setState({
|
|
date: base.date,
|
|
partyId: base.partyId,
|
|
number: base.number,
|
|
externalNumber: base.externalNumber,
|
|
lines: base.lines.map((l) => ({ ...l })),
|
|
attachments: [...base.attachments],
|
|
isDirty: false,
|
|
dirtyFields: { ...emptyFields },
|
|
dirtyLineKeys: [],
|
|
dirtyLineFields: {},
|
|
dirtySummary: [],
|
|
});
|
|
},
|
|
|
|
commitSaved(savedDoc) {
|
|
const date = dateInputValue(savedDoc.date);
|
|
const partyId = savedDoc.partyId ?? null;
|
|
const number = savedDoc.number ?? automat.state.number;
|
|
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
|
|
const lines = Array.isArray(savedDoc.lines) && savedDoc.lines.length > 0 ? savedDoc.lines : [emptyLine()];
|
|
const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments;
|
|
|
|
const baseline = {
|
|
date,
|
|
partyId,
|
|
number,
|
|
externalNumber,
|
|
lines: lines.map((l) => ({ ...l })),
|
|
attachments: [...attachments],
|
|
};
|
|
|
|
automat.setState({
|
|
date,
|
|
partyId,
|
|
number,
|
|
externalNumber,
|
|
lines,
|
|
attachments,
|
|
baseline,
|
|
isDirty: false,
|
|
dirtyFields: { ...emptyFields },
|
|
dirtyLineKeys: [],
|
|
dirtyLineFields: {},
|
|
dirtySummary: [],
|
|
});
|
|
},
|
|
|
|
reset() {
|
|
automat.setState({
|
|
kind,
|
|
mode: 'edit',
|
|
id: null,
|
|
date: '',
|
|
partyId: null,
|
|
number: '',
|
|
externalNumber: '',
|
|
lines: [emptyLine()],
|
|
attachments: [],
|
|
baseline: null,
|
|
isDirty: false,
|
|
dirtyFields: { ...emptyFields },
|
|
dirtyLineKeys: [],
|
|
dirtyLineFields: {},
|
|
dirtySummary: [],
|
|
});
|
|
},
|
|
};
|
|
|
|
automat = new Automat(
|
|
{
|
|
kind,
|
|
mode: 'edit',
|
|
id: null,
|
|
date: '',
|
|
partyId: null,
|
|
number: '',
|
|
externalNumber: '',
|
|
lines: [emptyLine()],
|
|
attachments: [],
|
|
baseline: null,
|
|
isDirty: false,
|
|
dirtyFields: { ...emptyFields },
|
|
dirtyLineKeys: [],
|
|
dirtyLineFields: {},
|
|
dirtySummary: [],
|
|
},
|
|
actions,
|
|
{ name, persist: false },
|
|
);
|
|
|
|
return automat;
|
|
}
|
|
|
|
/** Shared singleton automat for the single last edited quote. */
|
|
export const quoteEditAutomat = createManifestEditAutomat({
|
|
kind: 'quote',
|
|
name: 'quote-edit',
|
|
});
|
|
|
|
/** Shared singleton automat for the single last edited order. */
|
|
export const orderEditAutomat = createManifestEditAutomat({
|
|
kind: 'order',
|
|
name: 'order-edit',
|
|
});
|
|
|
|
/**
|
|
* Returns the edit automat for a given manifest kind.
|
|
* @param {'quote'|'order'} kind
|
|
* @returns {Automat|null}
|
|
*/
|
|
export function getManifestEditAutomat(kind) {
|
|
if (kind === 'quote') return quoteEditAutomat;
|
|
if (kind === 'order') return orderEditAutomat;
|
|
return null;
|
|
}
|