Files
dashfront/src/components/manifest/manifestConfig.js
2026-09-22 07:20:31 +02:00

571 lines
19 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.

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<Array<{mimetype: string, name: string, content: string, thumbavif?: string, meta?: object}>>}
*/
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<string>}
*/
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<object>} [existingAttachments]
* @returns {Promise<Array<{id: number, fileId: number, manifestId: number, file: File, name: string, type: string, thumbnail: string|null, size: number, text: string|null, parentPdfId: string|null, isPersisted: boolean}>>}
*/
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}`);
}