diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 204df12..81158c7 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -7,7 +7,7 @@ description: >- # Dash API Routes Overview (All) -Overview of all 233 API endpoints across all modules. +Overview of all 235 API endpoints across all modules. ### Notation @@ -347,6 +347,12 @@ _none_ #### Create company — `POST /company` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Returns: `201 { company: T23, user: T45 }` +#### Export company catalog as zip archive (SSE) — `GET /company/export` +- Params: `query: stream?: str` +- Returns: `200 SSE` +#### Export company catalog as zip archive (SSE) — `POST /company/export` +_none_ +- Returns: `200 SSE` #### Get company history — `GET /company/history` - Params: `query: limit?: int` - Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }` diff --git a/src/automats/manifestDraftAutomat.js b/src/automats/manifestDraftAutomat.js index a674978..e877ef5 100644 --- a/src/automats/manifestDraftAutomat.js +++ b/src/automats/manifestDraftAutomat.js @@ -233,3 +233,307 @@ export function getManifestDraftAutomat(kind) { 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 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; + } + + 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}`)) || + baseLines[i]; + + if (!bl) { + if (cl.sku || cl.name || (cl.amount && cl.amount !== '1') || cl.net || cl.gross) { + dirtyLineKeys.push(key); + fields.lines = true; + } + } else { + const lineChanged = + String(cl.sku ?? '').trim() !== String(bl.sku ?? '').trim() || + String(cl.name ?? '').trim() !== String(bl.name ?? '').trim() || + String(cl.amount ?? '').trim() !== String(bl.amount ?? '').trim() || + String(cl.net ?? cl.netprice ?? '').trim() !== String(bl.net ?? bl.netprice ?? '').trim() || + String(cl.gross ?? '').trim() !== String(bl.gross ?? '').trim() || + String(cl.rebate ?? '').trim() !== String(bl.rebate ?? '').trim() || + String(cl.tax ?? cl.taxrate ?? '').trim() !== String(bl.tax ?? bl.taxrate ?? '').trim() || + String(cl.pu ?? '').trim() !== String(bl.pu ?? '').trim() || + String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim(); + + if (lineChanged) { + dirtyLineKeys.push(key); + 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) summary.push('Dateien'); + + const isDirty = summary.length > 0; + + return { + isDirty, + fields, + dirtyLineKeys, + 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, + baseline, + isDirty: false, + dirtyFields: { ...emptyFields }, + dirtyLineKeys: [], + 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, + dirtySummary: dirtyInfo.summary, + }); + }, + + 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: [], + 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: [], + dirtySummary: [], + }); + }, + + reset() { + automat.setState({ + kind, + mode: 'edit', + id: null, + date: '', + partyId: null, + number: '', + externalNumber: '', + lines: [emptyLine()], + attachments: [], + baseline: null, + isDirty: false, + dirtyFields: { ...emptyFields }, + dirtyLineKeys: [], + dirtySummary: [], + }); + }, + }; + + automat = new Automat( + { + kind, + mode: 'edit', + id: null, + date: '', + partyId: null, + number: '', + externalNumber: '', + lines: [emptyLine()], + attachments: [], + baseline: null, + isDirty: false, + dirtyFields: { ...emptyFields }, + dirtyLineKeys: [], + 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', +}); + +/** + * 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; + return null; +} diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 858fcdb..1abd908 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -30,7 +30,10 @@ import { parseNum, } from './manifestConfig'; import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber'; -import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat'; +import { + getManifestDraftAutomat, + getManifestEditAutomat, +} from '../../automats/manifestDraftAutomat'; class ManifestForm extends PureComponent { constructor(props) { @@ -44,19 +47,29 @@ class ManifestForm extends PureComponent { : entityAutomat.state.status === 'ready'; this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null; + this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null; + const initialDraft = this.draftAutomat?.state; + const initialEdit = (this.editAutomat && String(this.editAutomat.state?.id) === String(props.docId)) + ? this.editAutomat.state + : null; + const initialDoc = initialDraft || initialEdit; this.state = { - status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'), + status: props.mode === 'edit' && !initialEdit ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'), error: null, parties: initialParties, partiesLoading: !partiesReady, - date: initialDraft?.date || '', - partyId: initialDraft?.partyId ?? null, - number: initialDraft?.number || '', - externalNumber: initialDraft?.externalNumber || '', - lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()], - attachments: initialDraft?.attachments || [], + date: initialDoc?.date || '', + partyId: initialDoc?.partyId ?? null, + number: initialDoc?.number || '', + externalNumber: initialDoc?.externalNumber || '', + lines: initialDoc?.lines?.length > 0 ? initialDoc.lines : [emptyLine()], + attachments: initialDoc?.attachments || [], + isDirty: Boolean(initialEdit?.isDirty), + dirtyFields: initialEdit?.dirtyFields || null, + dirtyLineKeys: initialEdit?.dirtyLineKeys || [], + dirtySummary: initialEdit?.dirtySummary || [], saving: false, pdfUploading: false, pdfDialogOpen: false, @@ -67,6 +80,7 @@ class ManifestForm extends PureComponent { }; this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); + this.handleRestore = this.handleRestore.bind(this); this.handlePartyChange = this.handlePartyChange.bind(this); this.handleDateChange = this.handleDateChange.bind(this); this.handleNumberChange = this.handleNumberChange.bind(this); @@ -145,6 +159,25 @@ class ManifestForm extends PureComponent { attachments: state.attachments || [], })); } + + if (this.editAutomat) { + this.unsubEdit = this.editAutomat.subscribe(this, (state) => { + if (String(state.id) === String(this.props.docId)) { + return { + date: state.date || '', + partyId: state.partyId, + externalNumber: state.externalNumber || '', + lines: state.lines?.length > 0 ? state.lines : [emptyLine()], + attachments: state.attachments || [], + isDirty: Boolean(state.isDirty), + dirtyFields: state.dirtyFields, + dirtyLineKeys: state.dirtyLineKeys, + dirtySummary: state.dirtySummary, + }; + } + return null; + }); + } } componentDidUpdate(prevProps, prevState) { @@ -172,11 +205,42 @@ class ManifestForm extends PureComponent { }); } } + + if ( + this.props.mode === 'edit' && + this.editAutomat && + String(this.editAutomat.state.id) === String(this.props.docId) + ) { + if ( + prevState.date !== this.state.date || + prevState.partyId !== this.state.partyId || + prevState.externalNumber !== this.state.externalNumber || + prevState.lines !== this.state.lines || + prevState.attachments !== this.state.attachments + ) { + this.editAutomat.actions.update({ + date: this.state.date, + partyId: this.state.partyId, + externalNumber: this.state.externalNumber, + lines: this.state.lines, + attachments: this.state.attachments, + }); + const { isDirty, dirtyFields, dirtyLineKeys, dirtySummary } = this.editAutomat.state; + if ( + this.state.isDirty !== isDirty || + this.state.dirtyFields !== dirtyFields || + this.state.dirtySummary !== dirtySummary + ) { + this.setState({ isDirty, dirtyFields, dirtyLineKeys, dirtySummary }); + } + } + } } componentWillUnmount() { this._cancelled = true; this.unsubDraft?.(); + this.unsubEdit?.(); this.unsubParties?.(); this.unsubCompany?.(); if (this._parseAbortController) { @@ -221,6 +285,29 @@ class ManifestForm extends PureComponent { return; } + // If editAutomat already holds this exact document in memory, preserve in-progress edits + if ( + this.editAutomat && + String(this.editAutomat.state.id) === String(docId) && + this.editAutomat.state.baseline != null + ) { + void this.loadPartiesOnly(); + this.setState({ + status: 'ready', + isDirty: Boolean(this.editAutomat.state.isDirty), + dirtyFields: this.editAutomat.state.dirtyFields, + dirtyLineKeys: this.editAutomat.state.dirtyLineKeys, + dirtySummary: this.editAutomat.state.dirtySummary, + date: this.editAutomat.state.date, + partyId: this.editAutomat.state.partyId, + number: this.editAutomat.state.number, + externalNumber: this.editAutomat.state.externalNumber, + lines: this.editAutomat.state.lines, + attachments: this.editAutomat.state.attachments, + }); + return; + } + this.setState({ status: 'loading', error: null, partiesLoading: true }); try { const [docRes, parties] = await Promise.all([ @@ -240,10 +327,7 @@ class ManifestForm extends PureComponent { const apiFiles = doc.files ?? []; const attachments = await restoreAttachmentsFromApi(apiFiles); - this.setState({ - status: 'ready', - parties, - partiesLoading: false, + const loadedDoc = { date: dateInputValue(doc.date), partyId: doc[cfg.partyField], number: doc.number ?? '', @@ -251,6 +335,21 @@ class ManifestForm extends PureComponent { doc.externalNumber != null ? String(doc.externalNumber) : '', lines, attachments, + }; + + if (this.editAutomat) { + this.editAutomat.actions.loadDoc(docId, loadedDoc); + } + + this.setState({ + status: 'ready', + parties, + partiesLoading: false, + ...loadedDoc, + isDirty: false, + dirtyFields: null, + dirtyLineKeys: [], + dirtySummary: [], }); } catch (err) { if (this._cancelled) return; @@ -267,6 +366,25 @@ class ManifestForm extends PureComponent { else void this.loadPartiesOnly(); } + handleRestore() { + if (!this.editAutomat) return; + this.editAutomat.actions.restoreBaseline(); + const state = this.editAutomat.state; + this.setState({ + date: state.date || '', + partyId: state.partyId, + number: state.number || '', + externalNumber: state.externalNumber || '', + lines: state.lines?.length > 0 ? state.lines : [emptyLine()], + attachments: state.attachments || [], + isDirty: false, + dirtyFields: state.dirtyFields, + dirtyLineKeys: state.dirtyLineKeys, + dirtySummary: state.dirtySummary, + }); + enqueueSnackbar('Änderungen verworfen und ursprünglicher Stand wiederhergestellt.', { variant: 'info' }); + } + handlePartyChange(partyId) { const { mode } = this.props; const { requirePartyBeforeLines } = this.cfg; @@ -736,7 +854,7 @@ class ManifestForm extends PureComponent { if (this._cancelled) return; const doc = data[cfg.docField]; const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); - this.setState({ + const savedDoc = { date: dateInputValue(doc.date), partyId: doc[cfg.partyField], number: doc.number ?? '', @@ -744,6 +862,19 @@ class ManifestForm extends PureComponent { doc.externalNumber != null ? String(doc.externalNumber) : '', lines: apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()], + attachments: this.state.attachments, + }; + + if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) { + this.editAutomat.actions.commitSaved(savedDoc); + } + + this.setState({ + ...savedDoc, + isDirty: false, + dirtyFields: null, + dirtyLineKeys: [], + dirtySummary: [], saving: false, }); enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); @@ -756,7 +887,7 @@ class ManifestForm extends PureComponent { renderArticles() { const cfg = this.cfg; - const { lines, saving } = this.state; + const { lines, saving, dirtyFields, dirtyLineKeys } = this.state; return ( {/* Row 1: Editable Line Inputs */} - td': { borderBottom: 'none', p: 0 } }}> + td': { borderBottom: 'none', p: 0 }, + ...(isDirty ? { bgcolor: 'rgba(237, 108, 2, 0.08)' } : {}), + }} + > {columns.map((col, colIdx) => ( {this.renderField(col, colIdx, columns.length)} @@ -868,13 +879,19 @@ class ManifestFormLine extends PureComponent { {/* Row 2: Matched Supplier Article Details */} {secondLine && ( - td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}> + td': { pt: 0.25, pb: 0.75, borderBottom: 'none' }, + ...(isDirty ? { bgcolor: 'rgba(237, 108, 2, 0.04)' } : {}), + }} + > {secondLine} diff --git a/src/components/manifest/ManifestHeader.jsx b/src/components/manifest/ManifestHeader.jsx index a47db39..a0171dd 100644 --- a/src/components/manifest/ManifestHeader.jsx +++ b/src/components/manifest/ManifestHeader.jsx @@ -6,12 +6,14 @@ import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import Chip from '@mui/material/Chip'; +import Typography from '@mui/material/Typography'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile } from 'dashgui/panels'; import { enqueueSnackbar } from 'notistack'; +import RestoreIcon from '@mui/icons-material/Restore'; import FileDropzone from '../FileDropzone'; import AttachmentList from './AttachmentList'; import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig'; @@ -232,10 +234,14 @@ class ManifestHeader extends PureComponent { onNumberChange, onExternalNumberChange, onSave, + onRestore, actionsColor, docId, ledgerSummary, onOpenBookDialog, + isDirty, + dirtyFields, + dirtySummary, } = this.props; const hasArticles = this.hasArticles(); @@ -250,7 +256,7 @@ class ManifestHeader extends PureComponent { (this.state.createdParty?.id === partyId ? this.state.createdParty : null); const isDateEmpty = !date || String(date).trim() === ''; - const isPartyEmpty = partyId == null || partyId === ''; + const isPartyEmpty = kind !== 'quote' && (partyId == null || partyId === ''); return ( <> @@ -267,7 +273,7 @@ class ManifestHeader extends PureComponent { ( )} /> @@ -355,13 +375,19 @@ class ManifestHeader extends PureComponent { {useExternalNumber ? ( ) : null} @@ -424,7 +450,7 @@ class ManifestHeader extends PureComponent { + {isDirty && onRestore && ( + + )} + {isDirty && dirtySummary && dirtySummary.length > 0 && ( + + + Geänderte Felder: + + + {dirtySummary.map((item) => ( + + ))} + + + )} {docId != null && kind === 'delivery' && onOpenBookDialog && (