This commit is contained in:
Matthias Hochmeister
2026-03-26 11:25:28 +01:00
parent 3d03345107
commit d4adf9230d
5 changed files with 181 additions and 68 deletions

View File

@@ -363,48 +363,65 @@ export default function BestellungDetail() {
let curY = await addPdfHeader(doc, settings, 210);
// Document title below header
// ── Document title ──
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(title, 10, curY);
curY += 10;
// Metadata block
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const meta: [string, string][] = [
['Bezeichnung', bestellung.bezeichnung],
['Lieferant', bestellung.lieferant_name || ''],
['Besteller', bestellung.besteller_name || ''],
['Status', BESTELLUNG_STATUS_LABELS[bestellung.status]],
['Bestelldatum', bestellung.bestellt_am ? formatDate(bestellung.bestellt_am) : ''],
['Erstellt am', formatDate(bestellung.erstellt_am)],
];
for (const [label, value] of meta) {
// ── Helper: two-column label/value row ──
const row = (label: string, value: string, col2X = 70) => {
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text(`${label}:`, 10, curY);
doc.text(label + ':', 10, curY);
doc.setFont('helvetica', 'normal');
doc.text(value, 45, curY);
doc.text(value, col2X, curY);
curY += 5;
};
// ── Vendor block ──
if (bestellung.lieferant_name) {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Lieferant', 10, curY);
curY += 5;
row('Name', bestellung.lieferant_name);
if (bestellung.lieferant_kontakt_name) row('Ansprechpartner', bestellung.lieferant_kontakt_name);
if (bestellung.lieferant_adresse) row('Adresse', bestellung.lieferant_adresse);
if (bestellung.lieferant_email) row('E-Mail', bestellung.lieferant_email);
if (bestellung.lieferant_telefon) row('Telefon', bestellung.lieferant_telefon);
curY += 3;
}
curY += 4;
// Line items table
// ── Besteller block ──
if (bestellung.besteller_name) {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Besteller', 10, curY);
curY += 5;
const nameWithRank = bestellung.besteller_dienstgrad
? `${bestellung.besteller_dienstgrad} ${bestellung.besteller_name}`
: bestellung.besteller_name;
row('Name', nameWithRank);
if (bestellung.besteller_email) row('E-Mail', bestellung.besteller_email);
curY += 3;
}
// ── Order info block ──
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Bestellinformationen', 10, curY);
curY += 5;
row('Bezeichnung', bestellung.bezeichnung);
row('Status', BESTELLUNG_STATUS_LABELS[bestellung.status]);
row('Erstellt am', formatDate(bestellung.erstellt_am));
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
curY += 5;
// ── Line items table ──
const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100;
const rows = positionen.map((p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined;
const menge = parseFloat(String(p.menge)) || 0;
const gesamt = ep != null ? ep * menge : undefined;
return [
p.bezeichnung,
p.artikelnummer || '',
`${menge} ${p.einheit}`,
ep != null ? formatCurrency(ep) : '',
gesamt != null ? formatCurrency(gesamt) : '',
];
});
const hasPrices = positionen.some((p) => p.einzelpreis != null);
// Total
const totalNetto = positionen.reduce((sum, p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : 0;
const m = parseFloat(String(p.menge)) || 0;
@@ -412,28 +429,95 @@ export default function BestellungDetail() {
}, 0);
const totalBrutto = totalNetto * (1 + steuersatz);
autoTable(doc, {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
body: rows,
startY: curY,
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [245, 245, 245] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 60 },
1: { cellWidth: 30 },
2: { cellWidth: 25, halign: 'right' },
3: { cellWidth: 30, halign: 'right' },
4: { cellWidth: 30, halign: 'right' },
},
foot: [
['', '', '', 'Netto:', formatCurrency(totalNetto)],
['', '', '', 'Brutto:', formatCurrency(totalBrutto)],
],
footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: 'bold' },
didDrawPage: addPdfFooter(doc, settings),
});
if (hasPrices) {
const rows = positionen.map((p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined;
const menge = parseFloat(String(p.menge)) || 0;
const gesamt = ep != null ? ep * menge : undefined;
return [
p.bezeichnung,
p.artikelnummer || '',
`${menge} ${p.einheit}`,
ep != null ? formatCurrency(ep) : '',
gesamt != null ? formatCurrency(gesamt) : '',
];
});
autoTable(doc, {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
body: rows,
startY: curY,
theme: 'grid',
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 60 },
1: { cellWidth: 28 },
2: { cellWidth: 25, halign: 'right' },
3: { cellWidth: 30, halign: 'right' },
4: { cellWidth: 30, halign: 'right' },
},
foot: [
['', '', '', 'Netto:', formatCurrency(totalNetto)],
['', '', '', `Brutto (${bestellung.steuersatz ?? 20}% USt.):`, formatCurrency(totalBrutto)],
],
footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: 'bold' },
didDrawPage: addPdfFooter(doc, settings),
});
} else {
const rows = positionen.map((p) => {
const menge = parseFloat(String(p.menge)) || 0;
return [p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`];
});
autoTable(doc, {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge']],
body: rows,
startY: curY,
theme: 'grid',
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 100 },
1: { cellWidth: 40 },
2: { cellWidth: 33, halign: 'right' },
},
didDrawPage: addPdfFooter(doc, settings),
});
}
// ── Signature section ──
const signY = (doc as any).lastAutoTable?.finalY ?? curY;
const sigStartY = signY + 15;
// Ensure there's enough space; add a page if needed
if (sigStartY + 25 > doc.internal.pageSize.height - 20) {
doc.addPage();
curY = 20;
} else {
curY = sigStartY;
}
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
// Signature line
doc.setDrawColor(0, 0, 0);
doc.setLineWidth(0.4);
doc.line(10, curY, 90, curY);
// Date line (right side)
doc.line(120, curY, 200, curY);
curY += 4;
const sigName = bestellung.besteller_dienstgrad
? `${bestellung.besteller_dienstgrad} ${bestellung.besteller_name || ''}`
: (bestellung.besteller_name || '');
doc.text(sigName, 10, curY);
doc.text('Ort, Datum', 120, curY);
doc.save(`bestellung_${kennung.replace('/', '-')}.pdf`);
}

View File

@@ -2056,12 +2056,13 @@ export default function Kalender() {
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
onClick={() => {
const start = new Date(viewMonth.year, viewMonth.month, 1);
const end = new Date(viewMonth.year, viewMonth.month + 1, 0, 23, 59, 59);
const trainToExport = trainingEvents.filter((t) => { const d = new Date(t.datum_von); return d >= start && d <= end; });
const eventsToExport = veranstaltungen.filter((e) => { const d = new Date(e.datum_von); return d >= start && d <= end; });
generatePdf(viewMonth.year, viewMonth.month, trainToExport, eventsToExport);
}}
>
<PdfIcon fontSize="small" />
</IconButton>

View File

@@ -53,8 +53,14 @@ export interface Bestellung {
bezeichnung: string;
lieferant_id?: number;
lieferant_name?: string;
lieferant_kontakt_name?: string;
lieferant_email?: string;
lieferant_telefon?: string;
lieferant_adresse?: string;
besteller_id?: string;
besteller_name?: string;
besteller_email?: string;
besteller_dienstgrad?: string;
status: BestellungStatus;
budget?: number;
steuersatz?: number;

View File

@@ -129,16 +129,31 @@ export async function addPdfHeader(
}
/**
* Returns a `didDrawPage` callback that renders pdf_footer at the bottom of each page.
* Returns a `didDrawPage` callback that renders pdf_footer (left) and
* "Seite X/Y" page numbers (bottom right) on every page.
*/
export function addPdfFooter(
doc: jsPDF,
settings: PdfSettings,
): ((data: any) => void) | undefined {
if (!settings.pdf_footer) return undefined;
return () => {
renderMarkdownText(doc, settings.pdf_footer, 10, doc.internal.pageSize.height - 12, {
fontSize: 8,
});
): (data: any) => void {
return (data: any) => {
const pageHeight = doc.internal.pageSize.height;
const pageWidth = doc.internal.pageSize.width;
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 100, 100);
if (settings.pdf_footer) {
renderMarkdownText(doc, settings.pdf_footer, 10, pageHeight - 8, { fontSize: 8 });
}
const pageNum = data?.pageNumber ?? 1;
const pageCount = data?.pageCount ?? 1;
const pageText = `Seite ${pageNum}/${pageCount}`;
const textWidth = doc.getTextWidth(pageText);
doc.text(pageText, pageWidth - 10 - textWidth, pageHeight - 8);
doc.setTextColor(0, 0, 0);
};
}