- 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
318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
/** 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}`);
|
||
}
|