Files
dashfront/src/components/manifest/manifestConfig.js
seb 0130ee6516 feat(manifest): improve PDF extraction workflow and fix delivery lines rendering
- Fix SSE 'result' event handling in delivery creation and stop circular progress
- Directly populate deliveryEditorSlice with parsed header, line items, and notes
- Simplify ManifestPdfDialog to display LLM protocol log and status only
- Render PDF analysis notes at the bottom of the delivery page
- Keep article lines visible when imported items exist even if partyId is not yet selected
- Bind handleEdit and handleDelete in ManifestList constructor to fix row click TypeError
- Support delivery columns (name, pu, netprice, taxrate) in ManifestForm and lineFromApi
- Remove obsolete Unit column from delivery lines
2026-09-16 10:06:19 +02:00

318 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** Groups that are not sales customers (filter client-side). */
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
export function entityDisplayName(entity) {
if (!entity) return '';
return [entity.firstName, entity.name].filter(Boolean).join(' ');
}
export function sortEntities(entities) {
return [...entities].sort((a, b) => {
const byName = String(a.name).localeCompare(String(b.name), 'de');
if (byName !== 0) return byName;
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
});
}
/**
* Customers = entities whose customergroup.name is not Lieferanten/POS.
*/
export function filterCustomerEntities(entities, groups) {
const excludedIds = new Set(
groups
.filter((g) => EXCLUDED_CUSTOMER_GROUP_NAMES.has(g.name))
.map((g) => g.id),
);
return sortEntities(
entities.filter((e) => !excludedIds.has(e.customergroupId)),
);
}
export function sortSuppliers(suppliers) {
return [...suppliers].sort((a, b) =>
String(a.name).localeCompare(String(b.name), 'de'),
);
}
export function sortDocs(docs) {
return [...docs].sort((a, b) => String(b.date).localeCompare(String(a.date)));
}
export function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString('de-DE');
}
/**
* Delivery documents have a server-allocated, read-only `number` plus an
* optional user-entered `externalNumber`. Returns the display value for a
* field, defaulting to a dash when empty.
*/
export function numberDisplay(value) {
if (value == null || String(value).trim() === '') return '–';
return String(value);
}
export function dateInputValue(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10);
return d.toISOString().slice(0, 10);
}
export function emptyLine() {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
amount: '',
sku: '',
tax: '',
rebate: '',
net: '',
name: '',
pu: '',
unit: '',
netprice: '',
taxrate: '',
};
}
export function lineFromApi(row) {
return {
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
amount: row.amount == null ? '' : String(row.amount),
sku: row.sku ?? '',
tax: row.tax == null ? '' : String(row.tax),
rebate: row.rebate == null ? '' : String(row.rebate),
net: row.net == null ? '' : String(row.net),
name: row.name ?? '',
pu: row.pu == null ? '' : String(row.pu),
unit: row.unit ?? '',
netprice: row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '',
taxrate: row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '',
};
}
function parseNum(value) {
if (value == null || value === '') return null;
const n = Number(String(value).trim().replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
/** Build create/update line array. Throws Error on client validation. */
export function buildLines(lines, { requireAtLeastOne }) {
const outLines = [];
for (const line of lines) {
const sku = String(line.sku ?? '').trim();
const amount = parseNum(line.amount);
const net = parseNum(line.net ?? line.netprice);
const tax = parseNum(line.tax ?? line.taxrate);
const rebate = parseNum(line.rebate);
const blank =
!sku && amount == null && net == null && tax == null && rebate == null;
if (blank) continue;
if (!sku || amount == null || net == null || tax == null) {
throw new Error('Jede Zeile braucht SKU, Amount, Net und Tax.');
}
const row = { sku, amount, net, tax };
if (rebate != null) row.rebate = rebate;
if (line.name) row.name = line.name;
if (line.pu != null) row.pu = line.pu;
if (line.unit) row.unit = line.unit;
outLines.push(row);
}
if (requireAtLeastOne && outLines.length === 0) {
throw new Error('Mindestens eine Artikelzeile nötig.');
}
return outLines;
}
/**
* @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[] }} form
* @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts
*/
export function buildManifestBody(
form,
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber },
) {
const date = form.date;
if (!date) throw new Error('Datum fehlt.');
let body;
if (useExternalNumber) {
// Delivery: number is server-allocated/read-only. The caller-supplied
// reference goes in externalNumber (nullable; empty string → null).
const externalNumber = String(form.externalNumber ?? '').trim();
body = {
externalNumber: externalNumber || null,
date,
lines: buildLines(form.lines, { requireAtLeastOne }),
};
} else {
const number = String(form.number ?? '').trim();
if (!number) throw new Error('Nummer fehlt.');
body = {
number,
date,
lines: buildLines(form.lines, { requireAtLeastOne }),
};
}
if (form.partyId != null) body[partyField] = form.partyId;
else if (!requireAtLeastOne) body[partyField] = null;
return body;
}
export const LINE_COLUMNS = [
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },
{ id: 'sku', label: 'SKU', width: 140, align: 'left', type: 'text' },
{ id: 'tax', label: 'Tax', width: 80, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rebate', width: 88, align: 'right', type: 'number' },
{ id: 'net', label: 'Net', width: 104, align: 'right', type: 'number' },
];
export const DELIVERY_LINE_COLUMNS = [
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 220, align: 'left', type: 'text' },
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },
{ id: 'pu', label: 'PU', width: 80, align: 'right', type: 'number' },
{ id: 'taxrate', label: 'Tax', width: 80, align: 'right', type: 'number' },
{ id: 'netprice', label: 'Net', width: 104, align: 'right', type: 'number' },
];
/** @typedef {'delivery'|'quote'|'order'} ManifestKind */
/**
* Load party options for Autocomplete / list name lookup.
* @param {ManifestKind} kind
* @param {object} ctx StorageManager instance
*/
export async function loadParties(kind, ctx) {
if (kind === 'delivery') {
const list = ctx.suppliers ?? (await ctx.supplierPromise);
return sortSuppliers(list);
}
const [entities, groups] = await Promise.all([
ctx.entities ?? ctx.entityPromise,
ctx.customergroups ?? ctx.customergroupPromise,
]);
return filterCustomerEntities(entities, groups);
}
export function partyDisplayName(kind, party) {
if (!party) return '';
if (kind === 'delivery') return String(party.name ?? '');
return entityDisplayName(party);
}
/**
* @param {ManifestKind} kind
*/
export function manifestConfig(kind) {
if (kind === 'delivery') {
return {
kind: 'delivery',
api: 'delivery',
listField: 'deliveries',
docField: 'delivery',
partyField: 'supplierId',
listPath: '/lieferung',
createPath: '/lieferung/neu',
editPath: (id) => `/lieferung/${id}`,
dateLabel: 'Lieferdatum',
partyLabel: 'Lieferant',
emptyList: 'Noch keine Lieferungen.',
createButton: 'Neue Lieferung',
createdMsg: 'Lieferung angelegt.',
savedMsg: 'Lieferung gespeichert.',
deletedMsg: 'Lieferung gelöscht.',
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Lieferungs-ID.',
partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.',
getPathLabel: 'GET /api/delivery/:id',
postPathLabel: 'POST /api/delivery',
patchPathLabel: 'PATCH /api/delivery/:id',
listColor: '#fed7aa',
headerColor: '#fed7aa',
linesColor: '#bbf7d0',
requirePartyBeforeLines: true,
updateCache: 'updateDelivery',
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
// Delivery uses a server-allocated read-only number plus an optional
// caller-supplied external reference.
externalNumber: true,
columns: DELIVERY_LINE_COLUMNS,
};
}
if (kind === 'quote') {
return {
kind: 'quote',
api: 'quote',
listField: 'quotes',
docField: 'quote',
partyField: 'entityId',
listPath: '/auftrag',
createPath: '/auftrag/neu',
editPath: (id) => `/auftrag/${id}`,
dateLabel: 'Gültig bis',
partyLabel: 'Kunde',
emptyList: 'Noch keine Angebote.',
createButton: 'Neues Angebot',
createdMsg: 'Angebot angelegt.',
savedMsg: 'Angebot gespeichert.',
deletedMsg: 'Angebot gelöscht.',
confirmDelete: (number) => `Angebot „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Angebots-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/quote/:id',
postPathLabel: 'POST /api/quote',
patchPathLabel: 'PATCH /api/quote/:id',
listColor: '#fde68a',
headerColor: '#fde68a',
linesColor: '#bbf7d0',
requirePartyBeforeLines: false,
updateCache: 'updateQuote',
listPromise: (ctx) => ctx.quotes ?? ctx.quotePromise,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
columns: LINE_COLUMNS,
};
}
if (kind === 'order') {
return {
kind: 'order',
api: 'order',
listField: 'orders',
docField: 'order',
partyField: 'entityId',
listPath: '/auftrag/bestellung',
createPath: '/auftrag/bestellung/neu',
editPath: (id) => `/auftrag/bestellung/${id}`,
dateLabel: 'Lieferdatum',
partyLabel: 'Kunde',
emptyList: 'Noch keine Bestellungen.',
createButton: 'Neue Bestellung',
createdMsg: 'Bestellung angelegt.',
savedMsg: 'Bestellung gespeichert.',
deletedMsg: 'Bestellung gelöscht.',
confirmDelete: (number) => `Bestellung „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Bestellungs-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/order/:id',
postPathLabel: 'POST /api/order',
patchPathLabel: 'PATCH /api/order/:id',
listColor: '#c4b5fd',
headerColor: '#c4b5fd',
linesColor: '#bbf7d0',
requirePartyBeforeLines: false,
updateCache: 'updateOrder',
listPromise: (ctx) => ctx.orders ?? ctx.orderPromise,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
columns: LINE_COLUMNS,
};
}
throw new Error(`Unknown manifest kind: ${kind}`);
}