/** * Branded partner report (Partner-Report) PDF builder — pdfmake, server-side. * Internal one-pager for meetings: master data, addresses, contacts and the * partner's fulfillment pricing, in the LogYou quote/contract look. * Reuses BRAND + header/footer from the offer generator. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos } from '../offers/quotePdf' export const REPORT_TEXTS: any = { de: { docTitle: 'Partner-Report', dateWord: 'Stand', date: 'Datum', partnerNo: 'Partner-Nr.', status: 'Status', active: 'Aktiv', inactive: 'Inaktiv', type: 'Typ', customer: 'Kunde', vendor: 'Lieferant', fulfillmentCustomer: 'Fulfillment-Kunde', prospect: 'Interessent', masterData: 'Stammdaten', taxId: 'USt-IdNr.', hrgNumber: 'Handelsregister-Nr.', url: 'Webseite', language: 'Sprache', email: 'E-Mail', emailInvoice: 'Rechnungs-E-Mail', emailReturn: 'Retouren-E-Mail', partnerGroup: 'Partnergruppe', paymentTerm: 'Zahlungsbedingung', priceList: 'Preisliste', salesRep: 'Vertriebsmitarbeiter', contractSigned: 'Vertrag unterschrieben', description: 'Beschreibung', addresses: 'Adressen', addressName: 'Bezeichnung', address: 'Anschrift', phone: 'Telefon', addressType: 'Typ', billTo: 'Rechnung', shipTo: 'Lieferung', contacts: 'Ansprechpartner', contactName: 'Name', position: 'Position', mobile: 'Telefon 2', noRecords: 'Keine Einträge', pricingTitle: 'Fulfillment & Retoure — Preise', service: 'Leistung', dimension: 'Dimension', price: 'Preis', piece: '1 St.', hour: '1 Std.', perMonth: 'pro Monat', perM3: 'pro m³', orderBaseprice: 'Basispreis pro Auftrag', orderBasepriceTier: 'Basispreis pro Auftrag (Volumenstufe {n})', pickPrice: 'Preis pro Pick', pickPriceFrom: 'Preis pro Pick (ab dem {n}. Artikel)', returnBase: 'Retoure Basispreis', returnPerItem: 'Retoure pro Artikel', hourlyRate: 'Stundensatz (Arbeiten nach Aufwand)', storageTitle: 'Lagerung', storageType: 'Lagertyp', monthlyCost: 'Monatspreis', rentedQty: 'Gemietet', shelfSmall: 'Fachbodenregal M', shelfLarge: 'Fachbodenregal L', palletSpace: 'Palettenstellplatz', volumeStorage: 'Volumenlagerung', feesTitle: 'Monatliche Gebühren & Sonstiges', serviceFee: 'Servicepauschale', kpPriceLimit: 'Kleinpaket-Preislimit', kpExtraWeight: 'Kleinpaket-Zusatzgewicht', packagingTitle: 'Verpackung — Kartonpreise', cartonSize: 'Kartongröße', cartonPrice: 'Preis pro Karton', notSet: '—', page: 'Seite', of: 'von' }, en: { docTitle: 'Partner Report', dateWord: 'As of', date: 'Date', partnerNo: 'Partner no.', status: 'Status', active: 'Active', inactive: 'Inactive', type: 'Type', customer: 'Customer', vendor: 'Vendor', fulfillmentCustomer: 'Fulfillment customer', prospect: 'Prospect', masterData: 'Master data', taxId: 'VAT ID', hrgNumber: 'Trade register no.', url: 'Website', language: 'Language', email: 'Email', emailInvoice: 'Invoice email', emailReturn: 'Return email', partnerGroup: 'Partner group', paymentTerm: 'Payment term', priceList: 'Price list', salesRep: 'Sales representative', contractSigned: 'Contract signed', description: 'Description', addresses: 'Addresses', addressName: 'Name', address: 'Address', phone: 'Phone', addressType: 'Type', billTo: 'Invoice', shipTo: 'Delivery', contacts: 'Contacts', contactName: 'Name', position: 'Position', mobile: 'Phone 2', noRecords: 'No records', pricingTitle: 'Fulfillment & Returns — Prices', service: 'Service', dimension: 'Dimension', price: 'Price', piece: '1 pc.', hour: '1 hr.', perMonth: 'per month', perM3: 'per m³', orderBaseprice: 'Base price per order', orderBasepriceTier: 'Base price per order (volume tier {n})', pickPrice: 'Price per pick', pickPriceFrom: 'Price per pick (from item no. {n})', returnBase: 'Return base price', returnPerItem: 'Return per item', hourlyRate: 'Hourly rate (work by effort)', storageTitle: 'Storage', storageType: 'Storage type', monthlyCost: 'Monthly price', rentedQty: 'Rented', shelfSmall: 'Shelf rack M', shelfLarge: 'Shelf rack L', palletSpace: 'Pallet space', volumeStorage: 'Volume storage', feesTitle: 'Monthly fees & other', serviceFee: 'Service fee', kpPriceLimit: 'Small parcel price limit', kpExtraWeight: 'Small parcel extra weight', packagingTitle: 'Packaging — carton prices', cartonSize: 'Carton size', cartonPrice: 'Price per carton', notSet: '—', page: 'Page', of: 'of' } } const fmt = (template: string, vars: Record): string => template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')) const formatDateLocal = (iso: string, language: string): string => { if (!iso) return '—' const d = new Date(iso) if (isNaN(d.getTime())) return iso const dd = String(d.getDate()).padStart(2, '0') const mm = String(d.getMonth() + 1).padStart(2, '0') return language === 'en' ? `${d.getFullYear()}-${mm}-${dd}` : `${dd}.${mm}.${d.getFullYear()}` } // Authoritative filename — never trust the client's. export const sanitizeReportFilename = (name: string, date?: string): string => { const safe = String(name || 'Partner') .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') .replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue') .replace(/ß/g, 'ss') .replace(/[^A-Za-z0-9-_ ]/g, '') .trim() .replace(/\s+/g, '-') || 'Partner' const d = date || new Date().toISOString().slice(0, 10) return `Partner-Report_${safe}_${d}.pdf` } export const buildPartnerReportDocDefinition = (data: any, logos: { light: string | null; dark: string | null }) => { const language = data?.language === 'en' ? 'en' : 'de' const T = REPORT_TEXTS[language] const p = data.partner || {} const pr = data.pricing || {} const locations: any[] = Array.isArray(data.locations) ? data.locations : [] const contacts: any[] = Array.isArray(data.contacts) ? data.contacts : [] const dateIso = new Date().toISOString().slice(0, 10) const euro = (amount: any) => new Intl.NumberFormat(language === 'en' ? 'en-IE' : 'de-DE', { style: 'currency', currency: 'EUR' }).format(Number(amount) || 0) const sectionTitle = (text: string) => ({ stack: [ { canvas: [{ type: 'rect', x: 0, y: 0, w: 26, h: 3, color: BRAND.orange }] }, { text: String(text).toUpperCase(), fontSize: 11, bold: true, color: BRAND.navy, characterSpacing: 0.4, margin: [0, 5, 0, 0] } ], margin: [0, 16, 0, 8] }) const zebraLayout = { hLineWidth: (i: number) => (i === 1 ? 0.75 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, fillColor: (rowIndex: number) => (rowIndex === 0 ? BRAND.navy : rowIndex % 2 === 0 ? BRAND.zebra : null), paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 5, paddingBottom: () => 5 } const th = (text: string, alignment: string = 'left') => ({ text, style: 'tableHeader', alignment }) const td = (text: any, opts: any = {}) => ({ text: String(text ?? ''), fontSize: 9, ...opts }) const tdPrice = (value: any) => Number(value) > 0 ? td(euro(value), { alignment: 'right', bold: true }) : td(T.notSet, { alignment: 'right', color: BRAND.grayText }) const kv = (label: string, value: any) => [ td(label, { color: BRAND.grayText }), td(value || '—', { bold: true }) ] // ---- Type / status chips const types: string[] = [] if (p.isCustomer) types.push(T.customer) if (p.isVendor) types.push(T.vendor) if (p.isFulfillmentCustomer) types.push(T.fulfillmentCustomer) if (p.isProspect && !p.isCustomer) types.push(T.prospect) // ---- Master data grid (skip empty pairs where both sides are blank) const masterRows: any[][] = [ [...kv(T.taxId, p.taxId), ...kv(T.hrgNumber, p.hrgNumber)], [...kv(T.email, p.email), ...kv(T.emailInvoice, p.emailInvoice)], [...kv(T.emailReturn, p.returnEmail), ...kv(T.url, p.url)], [...kv(T.partnerGroup, p.partnerGroup), ...kv(T.paymentTerm, p.paymentTerm)], [...kv(T.priceList, p.priceList), ...kv(T.salesRep, p.salesRep)], [...kv(T.language, p.language), ...kv(T.contractSigned, p.contractSigned ? formatDateLocal(p.contractSigned, language) : '')] ] // ---- Address rows const addressRows: any[][] = locations.map((loc: any) => { const lines = [loc.address1, loc.address2, [loc.postal, loc.city].filter(Boolean).join(' '), loc.country] .filter((l: any) => String(l || '').trim() !== '') const typeParts: string[] = [] if (loc.isBillTo) typeParts.push(T.billTo) if (loc.isShipTo) typeParts.push(T.shipTo) return [ td(loc.name || '—'), { stack: lines.length ? lines.map((l: string, i: number) => td(l, { margin: [0, i === 0 ? 0 : 1, 0, 0] })) : [td('—')] }, td(loc.phone || '—'), td(typeParts.join(' + ') || '—', { alignment: 'center' }) ] }) // ---- Contact rows const contactRows: any[][] = contacts.map((u: any) => [ td(u.name || '—', { bold: true }), td(u.title || '—'), td(u.phone || '—'), td(u.phone2 || '—'), td(u.email || '—') ]) const emptyRow = (cols: number) => [[ { text: T.noRecords, fontSize: 9, italics: true, color: BRAND.grayText, colSpan: cols, alignment: 'center' }, ...Array(cols - 1).fill({}) ]] const content: any[] = [ // Partner name + meta card { columns: [ { width: '*', stack: [ { text: p.name || '', bold: true, fontSize: 15, color: BRAND.navy }, ...(p.name2 ? [{ text: p.name2, fontSize: 10, color: BRAND.grayText, margin: [0, 2, 0, 0] }] : []), ...(types.length ? [{ text: types.join(' · '), fontSize: 9, bold: true, color: BRAND.orange, margin: [0, 6, 0, 0] }] : []), ...(p.description ? [{ text: p.description, fontSize: 8.5, color: BRAND.grayText, margin: [0, 6, 0, 0] }] : []) ] }, { width: 210, table: { widths: [86, '*'], body: [ kv(T.date, formatDateLocal(dateIso, language)), kv(T.partnerNo, p.value), kv(T.status, p.isActive ? T.active : T.inactive) ] }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, fillColor: () => BRAND.zebra, paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ], columnGap: 24, margin: [0, 4, 0, 0] }, // STAMMDATEN { unbreakable: true, stack: [ sectionTitle(T.masterData), { table: { widths: [110, '*', 110, '*'], body: masterRows }, layout: { hLineWidth: (i: number) => (i > 0 && i < masterRows.length ? 0.5 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, paddingLeft: () => 4, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ] }, // ADRESSEN { unbreakable: addressRows.length <= 6, stack: [ sectionTitle(T.addresses), { table: { headerRows: 1, widths: [110, '*', 100, 90], body: [ [th(T.addressName), th(T.address), th(T.phone), th(T.addressType, 'center')], ...(addressRows.length ? addressRows : emptyRow(4)) ] }, layout: zebraLayout } ] }, // ANSPRECHPARTNER { unbreakable: contactRows.length <= 8, stack: [ sectionTitle(T.contacts), { table: { headerRows: 1, widths: [130, 80, 85, 85, '*'], body: [ [th(T.contactName), th(T.position), th(T.phone), th(T.mobile), th(T.email)], ...(contactRows.length ? contactRows : emptyRow(5)) ] }, layout: zebraLayout } ] } ] // ---- Fulfillment pricing (only when it carries any signal) if (pr.show) { const pickfree = Number(pr.qtyPickfree) || 0 const pickLabel = pickfree > 0 ? fmt(T.pickPriceFrom, { n: pickfree + 1 }) : T.pickPrice const fulfillmentRows: any[][] = [ [td(T.orderBaseprice), td(T.piece, { alignment: 'center' }), tdPrice(pr.orderBaseprice)] ] if (Number(pr.orderBaseprice2) > 0) fulfillmentRows.push([td(fmt(T.orderBasepriceTier, { n: 2 })), td(T.piece, { alignment: 'center' }), tdPrice(pr.orderBaseprice2)]) if (Number(pr.orderBaseprice3) > 0) fulfillmentRows.push([td(fmt(T.orderBasepriceTier, { n: 3 })), td(T.piece, { alignment: 'center' }), tdPrice(pr.orderBaseprice3)]) fulfillmentRows.push( [td(pickLabel), td(T.piece, { alignment: 'center' }), tdPrice(pr.orderPickprice)], [td(T.returnBase), td(T.piece, { alignment: 'center' }), tdPrice(pr.returnBaseprice)], [td(T.returnPerItem), td(T.piece, { alignment: 'center' }), tdPrice(pr.returnPickprice)], [td(T.hourlyRate), td(T.hour, { alignment: 'center' }), tdPrice(pr.hourlyRate)] ) const qtyCell = (qty: any) => Number(qty) > 0 ? td(String(Number(qty)), { alignment: 'center', bold: true }) : td(T.notSet, { alignment: 'center', color: BRAND.grayText }) const storageRows: any[][] = [ [td(T.shelfSmall), qtyCell(pr.shelfSmallQty), tdPrice(pr.shelfSmallPrice)], [td(T.shelfLarge), qtyCell(pr.shelfLargeQty), tdPrice(pr.shelfLargePrice)], [td(T.palletSpace), qtyCell(pr.palletQty), tdPrice(pr.palletPrice)] ] if (Number(pr.volumeM3Price) > 0) { storageRows.push([td(T.volumeStorage), td(T.perM3, { alignment: 'center' }), tdPrice(pr.volumeM3Price)]) } const feeRows: any[][] = [ [td(T.serviceFee), td(T.perMonth, { alignment: 'center' }), tdPrice(pr.monthlyFee)] ] if (Number(pr.kleinPaketPriceLimit) > 0) feeRows.push([td(T.kpPriceLimit), td('', { alignment: 'center' }), tdPrice(pr.kleinPaketPriceLimit)]) if (Number(pr.kpExtraWeight) > 0) feeRows.push([td(T.kpExtraWeight), td('', { alignment: 'center' }), td(`${pr.kpExtraWeight} kg`, { alignment: 'right', bold: true })]) const packagingRows: any[][] = [ [td('KP'), tdPrice(pr.cartons?.kp)], [td('S'), tdPrice(pr.cartons?.s)], [td('M'), tdPrice(pr.cartons?.m)], [td('L'), tdPrice(pr.cartons?.l)], [td('XL'), tdPrice(pr.cartons?.xl)] ] content.push( { unbreakable: true, stack: [ sectionTitle(T.pricingTitle), { table: { headerRows: 1, widths: ['*', 70, 110], body: [[th(T.service), th(T.dimension, 'center'), th(T.price, 'right')], ...fulfillmentRows] }, layout: zebraLayout } ] }, { unbreakable: true, stack: [ sectionTitle(T.storageTitle), { table: { headerRows: 1, widths: ['*', 80, 110], body: [[th(T.storageType), th(T.rentedQty, 'center'), th(T.monthlyCost, 'right')], ...storageRows] }, layout: zebraLayout } ] }, { unbreakable: true, stack: [ sectionTitle(T.feesTitle), { table: { headerRows: 1, widths: ['*', 80, 110], body: [[th(T.service), th('', 'center'), th(T.price, 'right')], ...feeRows] }, layout: zebraLayout } ] }, { unbreakable: true, stack: [ sectionTitle(T.packagingTitle), { table: { headerRows: 1, widths: ['*', 110], body: [[th(T.cartonSize), th(T.cartonPrice, 'right')], ...packagingRows] }, layout: zebraLayout } ] } ) } return { pageSize: 'A4', pageMargins: [40, 106, 40, 86], info: { title: `${T.docTitle} – ${p.name || ''}`, author: 'LogYou GmbH', subject: T.docTitle }, header: buildBrandHeader(T.docTitle, `${p.name || ''} · ${T.dateWord} ${formatDateLocal(dateIso, language)}`, logos.dark), footer: buildBrandFooter(T.page, T.of), content, styles: { tableHeader: { fontSize: 8.5, bold: true, color: BRAND.white } }, defaultStyle: { font: 'Roboto', fontSize: 9.5, color: BRAND.text, lineHeight: 1.25 } } } /** Build the final PDF buffer + authoritative filename. */ export const generatePartnerReportPdf = async (data: any): Promise<{ buffer: Buffer; filename: string }> => { // Import pdfmake with virtual file system for server-side use // (defensive vfs extraction — the export shape varies by pdfmake // version/bundler, do not simplify; same block as quotePdf/contractPdf). const pdfMake = await import('pdfmake/build/pdfmake.js') const pdfFonts = await import('pdfmake/build/vfs_fonts.js') // @ts-ignore - Set up virtual file system const pdfMakeInstance = pdfMake.default || pdfMake let vfs = null if ((pdfFonts as any).pdfMake?.vfs) { vfs = (pdfFonts as any).pdfMake.vfs } else if ((pdfFonts as any).default?.pdfMake?.vfs) { vfs = (pdfFonts as any).default.pdfMake.vfs } else if ((pdfFonts as any).default?.vfs) { vfs = (pdfFonts as any).default.vfs } else if ((pdfFonts as any).vfs) { vfs = (pdfFonts as any).vfs } else if ((pdfFonts as any).default) { const defaultKeys = Object.keys((pdfFonts as any).default) if (defaultKeys.some((key: string) => key.endsWith('.ttf'))) { vfs = (pdfFonts as any).default } } if (!vfs) { throw new Error('Could not find vfs fonts in pdfmake fonts module') } pdfMakeInstance.vfs = vfs const logos = await getQuoteLogos() const docDefinition = buildPartnerReportDocDefinition(data, logos) const filename = sanitizeReportFilename(data?.partner?.name) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } }