/** * Branded LogYou offer (Angebot) PDF builder — pdfmake, server-side. * Brand: orange #F15924 / navy #1C324B (from the official logo SVG). * * Exports the brand constants + header/footer builders separately so the * later contract (Vertrag) generator (server/api/offers/contract/*) can * reuse the same look without duplicating it. */ export const BRAND = { orange: '#F15924', navy: '#1C324B', navySoft: '#2A4560', headerSub: '#AFC0D0', grayLine: '#E3E7EC', grayText: '#5A6B7C', zebra: '#F6F8FA', text: '#33414E', white: '#FFFFFF' } export const LEGAL_FOOTER_LINES = [ 'LogYou GmbH · Mühlenweg 4 · 35510 Butzbach | Geschäftsführer: Dennis Montag | Amtsgericht Friedberg, HRB 10595', 'Telefon: +49 60 33 / 91 60 57-0 | Fax: +49 60 33 / 91 60 57-9 | EMail: info@logyou.de | USt-IdNr.: DE 369380129', 'IBAN: DE 0251 3900 0000 9558 8301 | Volksbank Mittelhessen' ] // Every customer-facing string of the quote PDF, per language. export const QUOTE_TEXTS: any = { de: { docTitle: 'Fulfillment Angebot', dateWord: 'Angebot vom', date: 'Datum', validUntil: 'Gültig bis', plannedStart: 'Geplanter Start', version: 'Version', intro: 'Vielen Dank für Ihr Interesse. Gerne unterbreiten wir Ihnen folgendes Angebot für Ihr Fulfillment.', assumptions: 'Annahmen', businessModel: 'Geschäftsmodell', shopSystem: 'Shopsystem / Warenwirtschaft', avgPicks: 'Ø Picks pro Bestellung', ordersPerMonth: 'Ø Bestellungen pro Monat', skuCount: 'Ø Gelagerte Artikel (SKUs)', storageNeed: 'Lagerbedarf', onboarding: 'Onboarding & Setupkosten (IT-Support)', free: 'kostenfrei', inbound: 'Wareneingang', inboundIncluded: 'Warenannahme, Mengenkontrolle & Einlagerung (angemeldet, sortenrein, etikettiert)', included: 'im Fulfillment-Preis enthalten', inboundExtra: 'Nicht sortenrein / unangemeldet, Etikettierung, Inventur (zzgl. Material)', byEffort: 'nach Aufwand*', fulfillmentReturns: 'Fulfillment & Retoure', service: 'Leistung', dimension: 'Dimension', price: 'Preis', piece: '1 St.', time: 'Zeit', perMonth: 'pro Monat', orderBaseprice: 'Basispreis pro Auftrag', orderBasepriceTier: 'Basispreis pro Auftrag (Volumenstufe {n})', pickPrice: 'Preis pro Pick', pickPriceFrom: 'Preis pro Pick (ab dem {n}. Artikel)', customs: 'Zoll-Aufträge (Ausland außerhalb EU) zusätzlich', extraWork: 'Zusatzarbeiten (Umpalettieren usw.)', palletMove: 'Palette Ein-/Auslagern', returnBase: 'Retoure Basispreis', returnPerItem: 'Retoure pro Artikel', hourlyNote: '*Preis pro Stunde für Arbeiten nach individuellem Aufwand: {rate} pro Stunde, Abrechnung im 5-Minuten-Takt.', storageTitle: 'Lagerung (sortenrein)', storageType: 'Lagertyp', storageSize: 'Größe der Lagerfläche (B×T×H)', monthlyCost: 'Monatspreis', shelfSmall: 'Fachbodenregal M', shelfLarge: 'Fachbodenregal L', palletSpace: 'Palettenstellplatz', volumeStorage: 'Volumenlagerung', perM3: 'pro m³', monthlyCosts: 'Monatliche Fulfillment Kosten', serviceFee: 'Servicepauschale', serviceFeeIncl: 'inkl. Online-Dashboard, Software-Anbindung, Abrechnung & Support', packaging: 'Verpackung', cartonSize: 'Kartongröße', volume: 'Volumen', cartonPrice: 'Preis pro Karton', paperPrice: 'Packpapier pro Karton', individually: 'individuell kalkuliert', onRequest: 'auf Anfrage', einwegpalette: 'Einwegpalette', packagingNote: 'Label und weiteres Verpackungsmaterial nach Verbrauch.', shippingTitle: 'Versandkosten National (Deutsche Post / DHL)***', shippingMethod: 'Versandart', tierKg1: 'Kleinpaket bis 1 kg**', tierKg2: 'Paket bis 2 kg*', tierKg5: 'Paket bis 5 kg*', tierKg10: 'Paket bis 10 kg*', tierKg20: 'Paket bis 20 kg*', tierKg30: 'Paket bis 31,5 kg*', spedition: 'Speditionsversand', speditionDefault: 'Tagespreis auf Anfrage', forecastTitle: 'Beispielrechnung — Ihre voraussichtlichen monatlichen Kosten', fcFulfillment: 'Fulfillment ({orders} Aufträge)', fcReturns: 'Retouren (ca. {rate} %)', fcStorage: 'Lagerung', fcServiceFee: 'Servicepauschale', fcPackaging: 'Verpackung (Schätzung Größe {carton})', fcShipping: 'Versand (DHL, Annahme bis 2 kg)', fcTotal: 'Gesamt pro Monat', fcPerOrder: 'Effektiv pro Auftrag', fcDisclaimer: 'Unverbindliche Schätzung auf Basis der genannten Annahmen. Abgerechnet wird nach tatsächlichem Verbrauch.', notesTitle: 'Hinweise', noteVat: 'Alle Preise verstehen sich netto zzgl. 19 % MwSt.', noteValidity: 'Das Angebot ist {days} Tage gültig.', noteInsurance: '* Versichert bis 500 EUR inkl. Tracking ** Versichert bis 20 EUR inkl. Tracking *** Versandpreise sind abhängig vom Frachtdienstleister und können variieren.', closing: 'Wir freuen uns auf die Zusammenarbeit!', closingTeam: 'Ihr LogYou Team', page: 'Seite', of: 'von' }, en: { docTitle: 'Fulfillment Quote', dateWord: 'Quote of', date: 'Date', validUntil: 'Valid until', plannedStart: 'Planned start', version: 'Version', intro: 'Thank you for your interest. We are pleased to present the following quote for your fulfillment.', assumptions: 'Assumptions', businessModel: 'Business model', shopSystem: 'Shop system / ERP', avgPicks: 'Avg. picks per order', ordersPerMonth: 'Avg. orders per month', skuCount: 'Avg. stored items (SKUs)', storageNeed: 'Storage requirement', onboarding: 'Onboarding & setup costs (IT support)', free: 'free of charge', inbound: 'Inbound / Goods receipt', inboundIncluded: 'Goods receipt, quantity check & put-away (announced, sorted, labelled)', included: 'included in the fulfillment price', inboundExtra: 'Unsorted / unannounced goods, labelling, stocktaking (plus material)', byEffort: 'by effort*', fulfillmentReturns: 'Fulfillment & Returns', service: 'Service', dimension: 'Dimension', price: 'Price', piece: '1 pc.', time: 'time', perMonth: 'per month', 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})', customs: 'Customs orders (outside the EU), additional', extraWork: 'Additional work (re-palletizing etc.)', palletMove: 'Pallet in-/outbound movement', returnBase: 'Return base price', returnPerItem: 'Return per item', hourlyNote: '*Hourly rate for work billed by individual effort: {rate} per hour, billed in 5-minute increments.', storageTitle: 'Storage (sorted by SKU)', storageType: 'Storage type', storageSize: 'Storage area size (W×D×H)', monthlyCost: 'Monthly price', shelfSmall: 'Shelf rack M', shelfLarge: 'Shelf rack L', palletSpace: 'Pallet space', volumeStorage: 'Volume storage', perM3: 'per m³', monthlyCosts: 'Monthly fulfillment costs', serviceFee: 'Service fee', serviceFeeIncl: 'incl. online dashboard, software integration, billing & support', packaging: 'Packaging', cartonSize: 'Carton size', volume: 'Volume', cartonPrice: 'Price per carton', paperPrice: 'Packing paper per carton', individually: 'calculated individually', onRequest: 'on request', einwegpalette: 'Disposable pallet', packagingNote: 'Labels and further packaging material by consumption.', shippingTitle: 'Domestic shipping costs (Deutsche Post / DHL)***', shippingMethod: 'Shipping method', tierKg1: 'Small parcel up to 1 kg**', tierKg2: 'Parcel up to 2 kg*', tierKg5: 'Parcel up to 5 kg*', tierKg10: 'Parcel up to 10 kg*', tierKg20: 'Parcel up to 20 kg*', tierKg30: 'Parcel up to 31.5 kg*', spedition: 'Freight forwarding', speditionDefault: 'daily rate on request', forecastTitle: 'Example calculation — your expected monthly costs', fcFulfillment: 'Fulfillment ({orders} orders)', fcReturns: 'Returns (approx. {rate} %)', fcStorage: 'Storage', fcServiceFee: 'Service fee', fcPackaging: 'Packaging (estimate size {carton})', fcShipping: 'Shipping (DHL, assumed up to 2 kg)', fcTotal: 'Total per month', fcPerOrder: 'Effective per order', fcDisclaimer: 'Non-binding estimate based on the stated assumptions. Billing is based on actual consumption.', notesTitle: 'Notes', noteVat: 'All prices are net plus 19 % German VAT.', noteValidity: 'This quote is valid for {days} days.', noteInsurance: '* Insured up to EUR 500 incl. tracking ** Insured up to EUR 20 incl. tracking *** Shipping prices depend on the carrier and may vary.', closing: 'We look forward to working with you!', closingTeam: 'Your LogYou team', page: 'Page', of: 'of' } } const A4_WIDTH = 595.28 // Authoritative filename — never trust the client's. export const sanitizeQuoteFilename = (company: string, date?: string): string => { const safe = String(company || 'Kunde') .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, '-') || 'Kunde' const d = date || new Date().toISOString().slice(0, 10) return `Angebot_LogYou_${safe}_${d}.pdf` } // Logos bundled via Nitro server assets (server/assets/offers/*). let logoCache: { light: string | null; dark: string | null } | null = null export const getQuoteLogos = async (): Promise<{ light: string | null; dark: string | null }> => { if (logoCache) return logoCache const storage = useStorage('assets:server') const load = async (key: string): Promise => { try { const raw: any = await storage.getItemRaw(key) if (!raw) return null const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw) return 'data:image/png;base64,' + buf.toString('base64') } catch { return null } } logoCache = { light: await load('offers/logyou-logo.png'), dark: await load('offers/logyou-logo-dark.png') } return logoCache } 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()}` } /** Shared branded page header (navy band + logo + orange rule). Reused by the future contract PDF. */ export const buildBrandHeader = (title: string, subtitle: string, logoDarkDataUrl: string | null) => { return () => ({ stack: [ { table: { widths: ['*'], body: [[{ border: [false, false, false, false], fillColor: BRAND.navy, margin: [40, 20, 40, 16], columns: [ logoDarkDataUrl ? { image: logoDarkDataUrl, fit: [128, 34], width: 150, margin: [0, 2, 0, 0] } : { text: 'LOGYOU', color: BRAND.white, bold: true, fontSize: 20, width: 150, margin: [0, 6, 0, 0] }, { width: '*', stack: [ { text: title, color: BRAND.white, bold: true, fontSize: 15, alignment: 'right' }, { text: subtitle, color: BRAND.headerSub, fontSize: 8, alignment: 'right', margin: [0, 4, 0, 0] } ] } ] }]] }, layout: { defaultBorder: false, paddingLeft: () => 0, paddingRight: () => 0, paddingTop: () => 0, paddingBottom: () => 0 } }, { canvas: [{ type: 'rect', x: 0, y: 0, w: A4_WIDTH, h: 3, color: BRAND.orange }] } ] }) } /** Shared branded page footer (orange hairline + legal lines + page x of y). */ export const buildBrandFooter = (pageWord: string, ofWord: string) => { return (currentPage: number, pageCount: number) => ({ margin: [40, 6, 40, 0], stack: [ { canvas: [{ type: 'line', x1: 0, y1: 0, x2: A4_WIDTH - 80, y2: 0, lineWidth: 0.75, lineColor: BRAND.orange }] }, ...LEGAL_FOOTER_LINES.map((line, i) => ({ text: line, fontSize: 6.6, color: BRAND.grayText, alignment: 'center', margin: [0, i === 0 ? 5 : 2, 0, 0] })), { text: `${pageWord} ${currentPage} ${ofWord} ${pageCount}`, fontSize: 6.6, color: BRAND.grayText, alignment: 'right', margin: [0, 2, 0, 0] } ] }) } export const buildQuoteDocDefinition = (payload: any, logos: { light: string | null; dark: string | null }) => { const language = payload?.meta?.language === 'en' ? 'en' : 'de' const T = QUOTE_TEXTS[language] const c = payload.customer || {} const a = payload.assumptions || {} const p = payload.pricing || {} const st = a.storage || {} const validDays = Number(payload?.meta?.validDays) || 7 const dateIso = payload?.meta?.date || new Date().toISOString().slice(0, 10) const validUntil = new Date(new Date(dateIso).getTime() + validDays * 86400000).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) => td(typeof value === 'number' ? euro(value) : value, { alignment: 'right', bold: typeof value === 'number' }) const priceTable = (widths: any[], headers: any[], rows: any[][]) => ({ table: { headerRows: 1, widths, body: [headers, ...rows] }, layout: zebraLayout }) // Storage-need summary string for the assumptions grid const storageParts: string[] = [] if (Number(st.shelfSmallQty) > 0) storageParts.push(`${st.shelfSmallQty}× ${T.shelfSmall}`) if (Number(st.shelfLargeQty) > 0) storageParts.push(`${st.shelfLargeQty}× ${T.shelfLarge}`) if (Number(st.palletQty) > 0) storageParts.push(`${st.palletQty}× ${T.palletSpace}`) if (Number(st.volumeM3) > 0) storageParts.push(`${st.volumeM3} m³`) const storageSummary = storageParts.length ? storageParts.join(', ') : '—' const kv = (label: string, value: any) => [ td(label, { color: BRAND.grayText }), td(value || '—', { bold: true }) ] // FULFILLMENT & RETOURE rows (tier rows only when > 0) const pickfree = Number(p.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(Number(p.orderBaseprice))] ] if (Number(p.orderBaseprice2) > 0) fulfillmentRows.push([td(fmt(T.orderBasepriceTier, { n: 2 })), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.orderBaseprice2))]) if (Number(p.orderBaseprice3) > 0) fulfillmentRows.push([td(fmt(T.orderBasepriceTier, { n: 3 })), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.orderBaseprice3))]) fulfillmentRows.push( [td(pickLabel), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.orderPickprice))], [td(T.customs), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.customsSurcharge))], [td(T.extraWork), td(T.time, { alignment: 'center' }), tdPrice(T.byEffort)], [td(T.palletMove), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.palletMovePrice))], [td(T.returnBase), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.returnBaseprice))], [td(T.returnPerItem), td(T.piece, { alignment: 'center' }), tdPrice(Number(p.returnPickprice))] ) // LAGERUNG rows const storageRows: any[][] = [ [td(T.shelfSmall), td('45 × 60 × 40 cm', { alignment: 'center' }), tdPrice(Number(p.shelfSmallPrice))], [td(T.shelfLarge), td('90 × 60 × 40 cm', { alignment: 'center' }), tdPrice(Number(p.shelfLargePrice))], [td(T.palletSpace), td('120 × 80 × 150 cm', { alignment: 'center' }), tdPrice(Number(p.palletPrice))] ] if (Number(p.volumeM3Price) > 0) storageRows.push([td(T.volumeStorage), td(T.perM3, { alignment: 'center' }), tdPrice(Number(p.volumeM3Price))]) // VERPACKUNG rows const cartons = p.cartons || {} const cartonRow = (label: string, vol: string, key: string) => [ td(label), td(vol, { alignment: 'center' }), tdPrice(Number(cartons[key]?.price)), tdPrice(Number(cartons[key]?.paper)) ] const packagingRows: any[][] = [ cartonRow('S', '< 6 l', 's'), cartonRow('M', '< 15 l', 'm'), cartonRow('L', '< 30 l', 'l'), cartonRow('XL', '< 60 l', 'xl'), [td('XXL'), td('> 60 l', { alignment: 'center' }), td(T.individually, { alignment: 'right', italics: true, color: BRAND.grayText }), td(T.individually, { alignment: 'right', italics: true, color: BRAND.grayText })], [td(T.einwegpalette), td('—', { alignment: 'center' }), tdPrice(Number(p.einwegpalettePrice)), td('—', { alignment: 'right' })] ] // VERSAND rows const tiers = p.dhlTiers || {} const shippingRows: any[][] = [ [td(T.tierKg1), tdPrice(Number(tiers.kg1))], [td(T.tierKg2), tdPrice(Number(tiers.kg2))], [td(T.tierKg5), tdPrice(Number(tiers.kg5))], [td(T.tierKg10), tdPrice(Number(tiers.kg10))], [td(T.tierKg20), tdPrice(Number(tiers.kg20))], [td(T.tierKg30), tdPrice(Number(tiers.kg30))], [td(T.spedition), td(p.speditionNote || T.speditionDefault, { alignment: 'right', italics: true })] ] const content: any[] = [ // Address + meta card { columns: [ { width: '*', stack: [ { text: c.company || '', bold: true, fontSize: 12, color: BRAND.navy }, { text: c.contactName || '', margin: [0, 3, 0, 0] }, ...(c.street ? [{ text: c.street, margin: [0, 1, 0, 0] }] : []), ...((c.zip || c.city) ? [{ text: [c.zip, c.city].filter(Boolean).join(' '), margin: [0, 1, 0, 0] }] : []), { text: c.country || 'Deutschland', margin: [0, 1, 0, 0] } ] }, { width: 200, table: { widths: [86, '*'], body: [ kv(T.date, formatDateLocal(dateIso, language)), kv(T.validUntil, formatDateLocal(validUntil, language)), kv(T.plannedStart, formatDateLocal(a.plannedStart, language)) ] }, layout: { hLineWidth: () => 0, vLineWidth: () => 0, fillColor: () => BRAND.zebra, paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ], columnGap: 24, margin: [0, 4, 0, 0] }, { text: T.intro, color: BRAND.grayText, margin: [0, 14, 0, 0] }, // ANNAHMEN { unbreakable: true, stack: [ sectionTitle(T.assumptions), { table: { widths: [150, '*', 150, '*'], body: [ [...kv(T.businessModel, a.businessModel), ...kv(T.plannedStart, formatDateLocal(a.plannedStart, language))], [...kv(T.shopSystem, a.shopSystem), ...kv(T.avgPicks, a.avgPicksPerOrder)], [...kv(T.ordersPerMonth, a.ordersPerMonth), ...kv(T.skuCount, a.skuCount)], [...kv(T.storageNeed, storageSummary), ...kv(T.onboarding, T.free)] ] }, layout: { hLineWidth: (i: number) => (i > 0 && i < 4 ? 0.5 : 0), vLineWidth: () => 0, hLineColor: () => BRAND.grayLine, paddingLeft: () => 4, paddingRight: () => 8, paddingTop: () => 4, paddingBottom: () => 4 } } ] }, // WARENEINGANG { unbreakable: true, stack: [ sectionTitle(T.inbound), priceTable( ['*', 150], [th(T.service), th(T.price, 'right')], [ [td(T.inboundIncluded), td(T.included, { alignment: 'right', italics: true })], [td(T.inboundExtra), td(T.byEffort, { alignment: 'right', italics: true })] ] ) ] }, // FULFILLMENT & RETOURE { unbreakable: true, stack: [ sectionTitle(T.fulfillmentReturns), priceTable(['*', 70, 110], [th(T.service), th(T.dimension, 'center'), th(T.price, 'right')], fulfillmentRows), { text: fmt(T.hourlyNote, { rate: euro(Number(p.hourlyRate)) }), fontSize: 7.5, color: BRAND.grayText, margin: [0, 5, 0, 0] } ] }, // LAGERUNG { unbreakable: true, stack: [ sectionTitle(T.storageTitle), priceTable(['*', 170, 110], [th(T.storageType), th(T.storageSize, 'center'), th(T.monthlyCost, 'right')], storageRows) ] }, // MONATLICHE KOSTEN { unbreakable: true, stack: [ sectionTitle(T.monthlyCosts), priceTable( ['*', 110], [th(T.service), th(T.price, 'right')], [[ { stack: [td(T.serviceFee, { bold: true }), td(T.serviceFeeIncl, { fontSize: 7.5, color: BRAND.grayText, margin: [0, 2, 0, 0] })] }, tdPrice(Number(p.monthlyFee)) ]] ) ] }, // VERPACKUNG { unbreakable: true, stack: [ sectionTitle(T.packaging), priceTable( ['*', 80, 110, 120], [th(T.cartonSize), th(T.volume, 'center'), th(T.cartonPrice, 'right'), th(T.paperPrice, 'right')], packagingRows ), { text: T.packagingNote, fontSize: 7.5, color: BRAND.grayText, margin: [0, 5, 0, 0] } ] }, // VERSAND { unbreakable: true, stack: [ sectionTitle(T.shippingTitle), priceTable(['*', 130], [th(T.shippingMethod), th(T.price, 'right')], shippingRows) ] } ] // Optional forecast section if (payload?.forecast?.includeInPdf && Array.isArray(payload?.forecast?.lines)) { const fc = payload.forecast const fcLabel: Record = { fulfillment: fmt(T.fcFulfillment, { orders: a.ordersPerMonth }), returns: fmt(T.fcReturns, { rate: a.returnRatePct }), storage: T.fcStorage, serviceFee: T.fcServiceFee, packaging: fmt(T.fcPackaging, { carton: String(a.typicalCarton || 'M').toUpperCase() }), shipping: T.fcShipping } const fcRows: any[][] = fc.lines.map((line: any) => [td(fcLabel[line.key] || line.key), tdPrice(Number(line.monthly))]) fcRows.push([ { text: T.fcTotal, bold: true, fontSize: 10, color: BRAND.white, fillColor: BRAND.navy }, { text: euro(Number(fc.totalMonthly)), bold: true, fontSize: 10, color: BRAND.orange, alignment: 'right', fillColor: BRAND.navy } ]) fcRows.push([td(T.fcPerOrder, { bold: true }), tdPrice(Number(fc.perOrder))]) content.push({ unbreakable: true, stack: [ sectionTitle(T.forecastTitle), priceTable(['*', 130], [th(T.service), th(T.price, 'right')], fcRows), { text: T.fcDisclaimer, fontSize: 7.5, italics: true, color: BRAND.grayText, margin: [0, 5, 0, 0] } ] }) } // Notes + closing content.push({ unbreakable: true, stack: [ sectionTitle(T.notesTitle), { ul: [ { text: T.noteVat }, { text: fmt(T.noteValidity, { days: validDays }) }, { text: fmt(T.hourlyNote, { rate: euro(Number(p.hourlyRate)) }).replace(/^\*/, '') }, { text: T.noteInsurance } ], fontSize: 8, color: BRAND.grayText, markerColor: BRAND.orange }, { columns: [ { width: 'auto', canvas: [{ type: 'rect', x: 0, y: 2, w: 3, h: 26, color: BRAND.orange }] }, { width: '*', stack: [ { text: T.closing, bold: true, fontSize: 11, color: BRAND.navy }, { text: T.closingTeam, fontSize: 9.5, color: BRAND.grayText, margin: [0, 3, 0, 0] } ], margin: [10, 0, 0, 0] } ], margin: [0, 18, 0, 0] } ] }) return { pageSize: 'A4', pageMargins: [40, 106, 40, 86], info: { title: `${T.docTitle} – ${c.company || ''}`, author: 'LogYou GmbH', subject: T.docTitle }, header: buildBrandHeader(T.docTitle, `logyou.de · ${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 generateQuotePdf = async (payload: any): Promise<{ buffer: Buffer; filename: string }> => { // Import pdfmake with virtual file system for server-side use // (defensive vfs extraction copied from generate-fee-report.post.ts — the // export shape varies by pdfmake version/bundler, do not simplify). 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 = buildQuoteDocDefinition(payload, logos) const filename = sanitizeQuoteFilename(payload?.customer?.company, payload?.meta?.date) const buffer = await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) return { buffer, filename } }