import supplierAutomat from '../../automats/supplierAutomat'; /** 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 name = String(line.name ?? '').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 pu = parseNum(line.pu); const blank = !sku && !name && amount == null && net == null && tax == null && rebate == null; if (blank) continue; if ((!sku && !name) || amount == null || net == null || tax == null) { throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Taxrate.'); } const row = { amount, net, taxrate: tax }; if (sku) row.sku = sku; if (name) row.name = name; if (rebate != null) row.rebate = rebate; if (pu != null) row.pu = pu; outLines.push(row); } if (requireAtLeastOne && outLines.length === 0) { throw new Error('Mindestens eine Artikelzeile nötig.'); } return outLines; } /** * Convert File objects to base64 for API upload * @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments * @param {{ onlyNew?: boolean }} [options] * @returns {Promise>} */ export async function buildFilesForUpload(attachments = [], { onlyNew = false } = {}) { const files = []; for (const attachment of attachments) { if (!attachment) continue; // Skip already persisted attachments in update mode unless modified if (onlyNew && attachment.isPersisted && !attachment.isDirty) { continue; } if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) { continue; } let fileToUpload = attachment.file; if (!fileToUpload || fileToUpload.size === 0) { if (attachment.text) { fileToUpload = new File([attachment.text], attachment.name || 'file.txt', { type: 'text/plain;charset=utf-8', }); } else { continue; } } try { const base64 = await fileToBase64(fileToUpload); const filePayload = { mimetype: attachment.type || fileToUpload.type, name: attachment.name || fileToUpload.name, content: base64, }; if (attachment.thumbnail) { const thumbBase64 = attachment.thumbnail.includes(',') ? attachment.thumbnail.split(',')[1] : attachment.thumbnail; filePayload.thumbavif = thumbBase64; } files.push(filePayload); } catch (err) { console.error('Fehler beim Konvertieren von Datei zu Base64:', err); } } return files; } /** * Convert File to base64 string * @param {File} file * @returns {Promise} */ function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result; if (typeof result === 'string') { // Remove data URL prefix (e.g., "data:application/pdf;base64,") const base64 = result.split(',')[1] || result; resolve(base64); } else { reject(new Error('Invalid file reader result')); } }; reader.onerror = () => reject(new Error('File reader error')); reader.readAsDataURL(file); }); } /** * Convert base64 string to Blob * @param {string} base64 * @param {string} mimeType * @returns {Blob} */ function base64ToBlob(base64, mimeType) { const clean = base64.includes(',') ? base64.split(',')[1] : base64; const byteCharacters = atob(clean); const byteArrays = []; const sliceSize = 512; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } return new Blob(byteArrays, { type: mimeType }); } /** * Restore attachments from API response * @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles * @param {Array} [existingAttachments] * @returns {Promise>} */ export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachments = []) { const existingMap = new Map(); for (const existing of existingAttachments) { if (!existing) continue; if (existing.id != null) existingMap.set(String(existing.id), existing); if (existing.fileId != null) existingMap.set(String(existing.fileId), existing); if (existing.name) existingMap.set(existing.name, existing); } const attachments = []; for (const apiFile of apiFiles) { try { let file = null; let thumbnail = null; // 1. Try to restore thumbnail from apiFile.thumbavif if (apiFile.thumbavif) { try { if (typeof apiFile.thumbavif === 'string') { thumbnail = apiFile.thumbavif.startsWith('data:') ? apiFile.thumbavif : `data:image/avif;base64,${apiFile.thumbavif}`; } else if (typeof apiFile.thumbavif === 'object') { const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif; if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) { const thumbBlob = new Blob([new Uint8Array(rawBytes)], { type: 'image/avif' }); thumbnail = URL.createObjectURL(thumbBlob); } } } catch (thumbErr) { console.error('Fehler beim Dekodieren von thumbavif:', thumbErr); } } // 2. Fallback to existing matching attachment thumbnail const matchingExisting = existingMap.get(String(apiFile.id)) || existingMap.get(String(apiFile.fileId)) || existingMap.get(apiFile.name); if (!thumbnail && matchingExisting?.thumbnail) { thumbnail = matchingExisting.thumbnail; } const placeholderName = apiFile.name || 'file'; const placeholderType = apiFile.mimetype || 'application/octet-stream'; file = matchingExisting?.file instanceof File && matchingExisting.file.size > 0 ? matchingExisting.file : new File([''], placeholderName, { type: placeholderType }); const text = matchingExisting?.text || null; const parentPdfId = matchingExisting?.parentPdfId || null; attachments.push({ id: apiFile.id, fileId: apiFile.fileId ?? apiFile.id, manifestId: apiFile.manifestId, isPersisted: true, file, name: apiFile.name, type: apiFile.mimetype, thumbnail, size: apiFile.size, text, parentPdfId, uploadedAt: apiFile.created, }); } catch (err) { console.error('Fehler beim Wiederherstellen von Datei:', err); } } return attachments; } /** * Collect new or modified attachments for multipart upload * @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments * @param {{ onlyNew?: boolean }} [options] * @returns {Array<{file: File, name: string, mimetype: string, meta?: object}>} */ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}) { const files = []; for (const attachment of attachments) { if (!attachment) continue; // Skip already persisted attachments in update mode unless modified if (onlyNew && attachment.isPersisted && !attachment.isDirty) { continue; } if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) { continue; } let fileToUpload = attachment.file; if (!fileToUpload || fileToUpload.size === 0) { if (attachment.text) { fileToUpload = new File([attachment.text], attachment.name || 'file.txt', { type: 'text/plain;charset=utf-8', }); } else { continue; } } let thumbavif = null; if (attachment.thumbnail) { thumbavif = attachment.thumbnail.includes(',') ? attachment.thumbnail.split(',')[1] : attachment.thumbnail; } files.push({ file: fileToUpload, name: attachment.name || fileToUpload.name, mimetype: attachment.type || fileToUpload.type, thumbavif, }); } return files; } /** * @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[], attachments?: object[] }} form * @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts */ export async 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; // Add files if attachments exist if (form.attachments && form.attachments.length > 0) { const isUpdate = !requireAtLeastOne; const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate }); if (filesToUpload.length > 0) { const formData = new FormData(); body.filesMeta = filesToUpload.map((item) => ({ name: item.name, thumbavif: item.thumbavif ?? null, })); formData.append('data', JSON.stringify(body)); for (const item of filesToUpload) { formData.append('files', item.file, item.name); } return formData; } } 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') { if (supplierAutomat.state.status === 'ready') { return sortSuppliers(supplierAutomat.state.suppliers); } const list = await supplierAutomat.actions.load(); 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}`); }