/** * @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: todayInputValue(), partyId: null, number: '', externalNumber: '', lines: kind === 'order' ? [] : [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} lines */ setLines(lines) { const list = Array.isArray(lines) ? lines : []; 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 }); }, removeAllLines() { automat.setState({ lines: [] }); }, /** @param {Array} 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', }); /** Shared singleton automat for retoure draft. */ export const retoureDraftAutomat = createManifestDraftAutomat({ kind: 'retoure', name: 'retoure-draft', }); /** * Returns the draft automat for a given manifest kind. * @param {'quote'|'order'|'retoure'} kind * @returns {Automat|null} */ export function getManifestDraftAutomat(kind) { if (kind === 'quote') return quoteDraftAutomat; if (kind === 'order') return orderDraftAutomat; if (kind === 'retoure') return retoureDraftAutomat; 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 (Boolean(cl.shippable ?? true) !== Boolean(bl.shippable ?? true)) { lineFieldDiffs.shippable = 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 createdAt = doc.createdAt ?? doc.created_at ?? null; 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 : []; const attachments = Array.isArray(doc.attachments) ? doc.attachments : []; const baseline = { date, createdAt, partyId, number, externalNumber, lines: lines.map((l) => ({ ...l })), attachments: [...attachments], }; automat.setState({ kind, mode: 'edit', id, date, createdAt, partyId, number, externalNumber, lines, attachments, shipments: doc.shipments || [], payments: doc.payments || [], shippings: doc.shippings || [], orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null, paidTotal: doc.paidTotal ?? doc.paid_total ?? null, paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid), shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_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 createdAt = savedDoc.createdAt ?? savedDoc.created_at ?? automat.state.createdAt ?? null; 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 : []; const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments; const baseline = { date, createdAt, partyId, number, externalNumber, lines: lines.map((l) => ({ ...l })), attachments: [...attachments], }; automat.setState({ date, createdAt, partyId, number, externalNumber, lines, attachments, baseline, isDirty: false, dirtyFields: { ...emptyFields }, dirtyLineKeys: [], dirtyLineFields: {}, dirtySummary: [], }); }, reset() { automat.setState({ kind, mode: 'edit', id: null, date: '', createdAt: null, 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: '', createdAt: null, 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', }); /** Shared singleton automat for the single last edited retoure. */ export const retoureEditAutomat = createManifestEditAutomat({ kind: 'retoure', name: 'retoure-edit', }); /** * Returns the edit automat for a given manifest kind. * @param {'quote'|'order'|'retoure'} kind * @returns {Automat|null} */ export function getManifestEditAutomat(kind) { if (kind === 'quote') return quoteEditAutomat; if (kind === 'order') return orderEditAutomat; if (kind === 'retoure') return retoureEditAutomat; return null; }