update
This commit is contained in:
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user