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

@@ -148,11 +148,18 @@ async function getOrderById(id: number) {
try { try {
const orderResult = await pool.query( const orderResult = await pool.query(
`SELECT b.*, `SELECT b.*,
l.name AS lieferant_name, l.name AS lieferant_name,
COALESCE(u.name, u.preferred_username, u.email) AS besteller_name l.kontakt_name AS lieferant_kontakt_name,
l.email AS lieferant_email,
l.telefon AS lieferant_telefon,
l.adresse AS lieferant_adresse,
COALESCE(u.name, u.preferred_username, u.email) AS besteller_name,
u.email AS besteller_email,
mp.dienstgrad AS besteller_dienstgrad
FROM bestellungen b FROM bestellungen b
LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN lieferanten l ON l.id = b.lieferant_id
LEFT JOIN users u ON u.id = b.erstellt_von LEFT JOIN users u ON u.id = COALESCE(b.besteller_id, b.erstellt_von)
LEFT JOIN mitglieder_profile mp ON mp.user_id = COALESCE(b.besteller_id, b.erstellt_von)
WHERE b.id = $1`, WHERE b.id = $1`,
[id] [id]
); );

View File

@@ -363,48 +363,65 @@ export default function BestellungDetail() {
let curY = await addPdfHeader(doc, settings, 210); let curY = await addPdfHeader(doc, settings, 210);
// Document title below header // ── Document title ──
doc.setFontSize(14); doc.setFontSize(14);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text(title, 10, curY); doc.text(title, 10, curY);
curY += 10; curY += 10;
// Metadata block // ── Helper: two-column label/value row ──
doc.setFontSize(10); const row = (label: string, value: string, col2X = 70) => {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
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) {
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text(`${label}:`, 10, curY); doc.text(label + ':', 10, curY);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
doc.text(value, 45, curY); doc.text(value, col2X, curY);
curY += 5; 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 steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100;
const rows = positionen.map((p) => { const hasPrices = positionen.some((p) => p.einzelpreis != null);
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) : '',
];
});
// Total
const totalNetto = positionen.reduce((sum, p) => { const totalNetto = positionen.reduce((sum, p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : 0; const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : 0;
const m = parseFloat(String(p.menge)) || 0; const m = parseFloat(String(p.menge)) || 0;
@@ -412,28 +429,95 @@ export default function BestellungDetail() {
}, 0); }, 0);
const totalBrutto = totalNetto * (1 + steuersatz); const totalBrutto = totalNetto * (1 + steuersatz);
autoTable(doc, { if (hasPrices) {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']], const rows = positionen.map((p) => {
body: rows, const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined;
startY: curY, const menge = parseFloat(String(p.menge)) || 0;
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' }, const gesamt = ep != null ? ep * menge : undefined;
alternateRowStyles: { fillColor: [245, 245, 245] }, return [
margin: { left: 10, right: 10 }, p.bezeichnung,
styles: { fontSize: 9, cellPadding: 2 }, p.artikelnummer || '',
columnStyles: { `${menge} ${p.einheit}`,
0: { cellWidth: 60 }, ep != null ? formatCurrency(ep) : '',
1: { cellWidth: 30 }, gesamt != null ? formatCurrency(gesamt) : '',
2: { cellWidth: 25, halign: 'right' }, ];
3: { cellWidth: 30, halign: 'right' }, });
4: { cellWidth: 30, halign: 'right' },
}, autoTable(doc, {
foot: [ head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
['', '', '', 'Netto:', formatCurrency(totalNetto)], body: rows,
['', '', '', 'Brutto:', formatCurrency(totalBrutto)], startY: curY,
], theme: 'grid',
footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: 'bold' }, headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
didDrawPage: addPdfFooter(doc, settings), 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`); doc.save(`bestellung_${kennung.replace('/', '-')}.pdf`);
} }

View File

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

View File

@@ -53,8 +53,14 @@ export interface Bestellung {
bezeichnung: string; bezeichnung: string;
lieferant_id?: number; lieferant_id?: number;
lieferant_name?: string; lieferant_name?: string;
lieferant_kontakt_name?: string;
lieferant_email?: string;
lieferant_telefon?: string;
lieferant_adresse?: string;
besteller_id?: string; besteller_id?: string;
besteller_name?: string; besteller_name?: string;
besteller_email?: string;
besteller_dienstgrad?: string;
status: BestellungStatus; status: BestellungStatus;
budget?: number; budget?: number;
steuersatz?: 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( export function addPdfFooter(
doc: jsPDF, doc: jsPDF,
settings: PdfSettings, settings: PdfSettings,
): ((data: any) => void) | undefined { ): (data: any) => void {
if (!settings.pdf_footer) return undefined; return (data: any) => {
return () => { const pageHeight = doc.internal.pageSize.height;
renderMarkdownText(doc, settings.pdf_footer, 10, doc.internal.pageSize.height - 12, { const pageWidth = doc.internal.pageSize.width;
fontSize: 8,
}); 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);
}; };
} }