Add shared Manifest UI for delivery, quote, and order.
Wire quote/order API clients and routes, then unify list/create/edit behind ManifestList/Form tiles so Lieferungen and Auftrag share one stack.
This commit is contained in:
264
src/components/manifest/manifestConfig.js
Normal file
264
src/components/manifest/manifestConfig.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/** 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');
|
||||
}
|
||||
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function lineFromApi(row) {
|
||||
return {
|
||||
key: `line-${row.id}`,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const tax = parseNum(line.tax);
|
||||
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;
|
||||
outLines.push(row);
|
||||
}
|
||||
if (requireAtLeastOne && outLines.length === 0) {
|
||||
throw new Error('Mindestens eine Artikelzeile nötig.');
|
||||
}
|
||||
return outLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ number: string, date: string, partyId: number|null, lines: object[] }} form
|
||||
* @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean }} opts
|
||||
*/
|
||||
export function buildManifestBody(form, { partyField, requireAtLeastOne }) {
|
||||
const number = String(form.number ?? '').trim();
|
||||
const date = form.date;
|
||||
if (!number) throw new Error('Nummer fehlt.');
|
||||
if (!date) throw new Error('Datum fehlt.');
|
||||
|
||||
const 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' },
|
||||
];
|
||||
|
||||
/** @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])),
|
||||
};
|
||||
}
|
||||
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])),
|
||||
};
|
||||
}
|
||||
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])),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown manifest kind: ${kind}`);
|
||||
}
|
||||
Reference in New Issue
Block a user