diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index 0e8ff92..87a3a7b 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -148,11 +148,18 @@ async function getOrderById(id: number) { try { const orderResult = await pool.query( `SELECT b.*, - l.name AS lieferant_name, - COALESCE(u.name, u.preferred_username, u.email) AS besteller_name + l.name AS lieferant_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 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`, [id] ); diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index 5ba9ac8..86dadc3 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -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`); } diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index c788b33..db03b25 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -2056,12 +2056,13 @@ export default function Kalender() { 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); + }} > diff --git a/frontend/src/types/bestellung.types.ts b/frontend/src/types/bestellung.types.ts index 5237b4c..e469ffa 100644 --- a/frontend/src/types/bestellung.types.ts +++ b/frontend/src/types/bestellung.types.ts @@ -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; diff --git a/frontend/src/utils/pdfExport.ts b/frontend/src/utils/pdfExport.ts index f70457e..fac023b 100644 --- a/frontend/src/utils/pdfExport.ts +++ b/frontend/src/utils/pdfExport.ts @@ -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); }; }