/** 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}`); }