// PDF export of the Shipping Performance BI page (/bi/shipping-performance). // Client-only jsPDF (A4 landscape). Charts arrive as PNG snapshots of the LIVE // amCharts roots (exporting plugin, taken by the page); everything else (rule // text, KPI cards, source table, orders table) is drawn natively from the same // payload the page renders. Helvetica (WinAnsi) covers umlauts and Ø — no arrows. export interface ShippingPerfPdfChart { key: string title: string subtitle?: string dataUrl: string w: number // source pixel size, only the aspect ratio is used h: number } export interface ShippingPerfPdfSummary { orders: number; measured: number; shipped: number onTime: number; late: number; overdue: number; pending: number noStock: number; stockBlocked: number; generatedNotShipped: number onTimeRate: number | null; slaRate: number | null avgLeadHours: number | null; medianLeadHours: number | null; p90LeadHours: number | null avgDaysLate: number | null; beforeCutoffShare: number | null } export interface ShippingPerfPdfSource { key: string; label: string orders: number; onTime: number; late: number; overdue: number; pending: number; noStock?: number onTimeRate: number | null; avgLeadHours: number | null } export interface ShippingPerfPdfRow { status: string stockBlocked?: boolean stockNote?: string | null documentNo?: string externalOrderId?: string | null orderSource?: string | null bpartnerName?: string | null createdAt?: number | null createdWeekday?: string | null beforeCutoff?: boolean deadlineDate?: string | null shippedAt?: number | null shipmentDocumentNo?: string | null inOutId?: number | null shipper?: string | null leadHours?: number | null daysLate?: number | null } export interface ShippingPerfPdfPayload { orgName: string from: string // ISO date to: string cutoff: string // HH:MM cutoffSource: 'partner' | 'override' | 'default' | string timezone: string nonWorkingDays: number noStockMode: 'exclude' | 'include' | string stockCheck: 'ok' | 'unavailable' | string truncated: boolean generatedAt: Date summary: ShippingPerfPdfSummary sources: ShippingPerfPdfSource[] charts: ShippingPerfPdfChart[] // display order; missing charts simply absent rows: ShippingPerfPdfRow[] // already in grid order (filter + sort applied) rowsTotal: number statusFilterLabel: string gridFiltered: boolean } const PAGE_W = 297 const PAGE_H = 210 const MARGIN = 12 const CONTENT_W = PAGE_W - 2 * MARGIN type RGB = readonly [number, number, number] const C = { ink: [11, 11, 11] as RGB, text: [52, 51, 48] as RGB, muted: [120, 119, 114] as RGB, grid: [226, 225, 221] as RGB, band: [245, 244, 241] as RGB, accent: [30, 41, 59] as RGB, blue: [37, 99, 235] as RGB, white: [255, 255, 255] as RGB, good: [21, 128, 61] as RGB, goodSoft: [220, 252, 231] as RGB, warn: [180, 83, 9] as RGB, warnSoft: [254, 243, 199] as RGB, bad: [185, 28, 28] as RGB, badSoft: [254, 226, 226] as RGB, overdue: [194, 65, 12] as RGB, overdueSoft: [255, 237, 213] as RGB, grey: [75, 85, 99] as RGB, greySoft: [243, 244, 246] as RGB, slate: [51, 65, 85] as RGB, slateSoft: [226, 232, 240] as RGB } // Same colours as the STATUS map on the page. const STATUS: Record = { ontime: { label: 'On time', fg: C.good, bg: C.goodSoft }, late: { label: 'Late', fg: C.bad, bg: C.badSoft }, overdue: { label: 'Overdue', fg: C.overdue, bg: C.overdueSoft }, pending: { label: 'Pending', fg: C.grey, bg: C.greySoft }, nostock: { label: 'No stock', fg: C.slate, bg: C.slateSoft } } const fill = (doc: any, c: RGB) => doc.setFillColor(c[0], c[1], c[2]) const stroke = (doc: any, c: RGB) => doc.setDrawColor(c[0], c[1], c[2]) const ink = (doc: any, c: RGB) => doc.setTextColor(c[0], c[1], c[2]) const font = (doc: any, size: number, style: 'normal' | 'bold' | 'italic' | 'bolditalic' = 'normal') => { doc.setFont('helvetica', style) doc.setFontSize(size) } /** Truncate with an ellipsis so the text fits `maxW` mm at the current font. */ const fitText = (doc: any, text: string, maxW: number): string => { const s = String(text ?? '') if (doc.getTextWidth(s) <= maxW) return s let lo = 0 let hi = s.length while (lo < hi) { const mid = Math.ceil((lo + hi) / 2) if (doc.getTextWidth(s.slice(0, mid) + '…') <= maxW) lo = mid else hi = mid - 1 } return s.slice(0, Math.max(0, lo)).trimEnd() + '…' } const loadImage = (url: string): Promise<{ dataUrl: string; w: number; h: number } | null> => new Promise(resolve => { if (!import.meta.client || typeof Image === 'undefined') return resolve(null) const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { try { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight canvas.getContext('2d')?.drawImage(img, 0, 0) resolve({ dataUrl: canvas.toDataURL('image/png'), w: img.naturalWidth, h: img.naturalHeight }) } catch { resolve(null) } } img.onerror = () => resolve(null) img.src = url }) // --------------------------------------------------------------------------- // Formatting (mirrors the page helpers) const fmtDate = (iso?: string | null) => { const s = String(iso || '').slice(0, 10) if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return s return `${s.slice(8, 10)}.${s.slice(5, 7)}.${s.slice(0, 4)}` } const fmtDateTime = (ms?: number | null) => { if (!ms) return '' const parts = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).formatToParts(new Date(ms)) const g = (t: string) => parts.find(p => p.type === t)?.value || '' return `${g('day')}.${g('month')}.${g('year')} ${g('hour')}:${g('minute')}` } const fmtHours = (h?: number | null) => { if (h == null) return '–' if (h < 48) return `${h.toLocaleString('de-DE', { maximumFractionDigits: 1 })} h` return `${(h / 24).toLocaleString('de-DE', { maximumFractionDigits: 1 })} d` } const fmtPct = (v?: number | null) => (v == null ? '–' : `${v.toLocaleString('de-DE')} %`) const fmtNum = (v?: number | null) => Number(v || 0).toLocaleString('de-DE') const rateTint = (rate?: number | null): { fg: RGB; soft: RGB } => { if (rate == null) return { fg: C.accent, soft: C.band } if (rate >= 95) return { fg: C.good, soft: C.goodSoft } if (rate >= 90) return { fg: C.warn, soft: C.warnSoft } return { fg: C.bad, soft: C.badSoft } } const cutoffSourceLabel = (src: string) => src === 'partner' ? 'from partner setting' : src === 'override' ? 'manual override' : 'default (no partner cut-off set)' export const buildShippingPerformancePdf = async (p: ShippingPerfPdfPayload) => { if (!import.meta.client) throw new Error('PDF generation is client-only') const { jsPDF } = await import('jspdf') const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true }) const logo = await loadImage('/assets/logyou-logo-color.png') const scope = `${fmtDate(p.from)} – ${fmtDate(p.to)}` const generated = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' }).format(p.generatedAt) // --------------------------------------------------------------------------- // Chrome: header band + footer on every page (page numbers filled at the end) const HEADER_H = 22 const FOOTER_Y = PAGE_H - 9 const BOTTOM = FOOTER_Y - 6 // last usable y let y = 0 const header = (subtitle: string) => { fill(doc, C.accent) doc.rect(0, 0, PAGE_W, HEADER_H, 'F') fill(doc, C.blue) doc.rect(0, HEADER_H, PAGE_W, 1.2, 'F') ink(doc, C.white) font(doc, 15, 'bold') doc.text('Shipping Performance', MARGIN, 10) font(doc, 9, 'normal') doc.setTextColor(200, 208, 222) doc.text(fitText(doc, subtitle, 150), MARGIN, 16.5) let rightEdge = PAGE_W - MARGIN if (logo) { const h = 9 const w = h * (logo.w / logo.h) fill(doc, C.white) doc.roundedRect(rightEdge - w - 3, 4.5, w + 6, h + 4, 1.5, 1.5, 'F') try { doc.addImage(logo.dataUrl, 'PNG', rightEdge - w, 6.5, w, h, undefined, 'FAST') } catch {} rightEdge -= w + 10 } font(doc, 9, 'bold') ink(doc, C.white) doc.text(fitText(doc, p.orgName || '', 90), rightEdge, 10, { align: 'right' }) font(doc, 8, 'normal') doc.setTextColor(200, 208, 222) doc.text(scope, rightEdge, 16.5, { align: 'right' }) y = HEADER_H + 9 } const newPage = (subtitle: string) => { doc.addPage() header(subtitle) } /** Start a new page when `needed` mm no longer fit above the footer. */ const ensure = (needed: number, subtitle: string) => { if (y + needed > BOTTOM) newPage(subtitle) } const footers = () => { const total = doc.getNumberOfPages() const stockNote = p.stockCheck === 'ok' ? (p.noStockMode === 'exclude' ? 'No-stock orders excluded' : 'No-stock orders included') : 'Stock check unavailable' const left = `Generated ${generated} · Cut-off ${p.cutoff} (${cutoffSourceLabel(p.cutoffSource)}) · ${stockNote} · Times in ${p.timezone}` for (let i = 1; i <= total; i++) { doc.setPage(i) stroke(doc, C.grid) doc.setLineWidth(0.25) doc.line(MARGIN, FOOTER_Y - 3.5, PAGE_W - MARGIN, FOOTER_Y - 3.5) font(doc, 7, 'normal') ink(doc, C.muted) doc.text(fitText(doc, left, 230), MARGIN, FOOTER_Y) doc.text(`Page ${i} / ${total}`, PAGE_W - MARGIN, FOOTER_Y, { align: 'right' }) } } // --------------------------------------------------------------------------- // Chart placement (PNG snapshots) const titleBlockH = (chart: ShippingPerfPdfChart) => 5 + (chart.subtitle ? 3.6 : 0) /** Draw one chart at x/width starting at the current y; returns the height used. */ const drawChartAt = (chart: ShippingPerfPdfChart, x: number, w: number, top: number, maxImgH: number) => { const th = titleBlockH(chart) font(doc, 9, 'bold') ink(doc, C.ink) doc.text(fitText(doc, chart.title, w), x, top + 3.4) if (chart.subtitle) { font(doc, 7.5, 'normal') ink(doc, C.muted) doc.text(fitText(doc, chart.subtitle, w), x, top + 3.4 + 3.6) } let imgW = w let imgH = w * (chart.h / chart.w) let imgX = x if (imgH > maxImgH) { // too tall for the page: shrink proportionally, keep centred imgH = maxImgH imgW = imgH * (chart.w / chart.h) imgX = x + (w - imgW) / 2 } try { doc.addImage(chart.dataUrl, 'PNG', imgX, top + th, imgW, imgH, undefined, 'FAST') } catch {} stroke(doc, C.grid) doc.setLineWidth(0.2) doc.rect(x, top + th, w, imgH) return th + imgH } /** Place a row of charts side by side (one chart = a row of one). */ const placeChartRow = (slots: { chart: ShippingPerfPdfChart; x: number; w: number }[], subtitle: string) => { if (!slots.length) return const needed = Math.max(...slots.map(s => titleBlockH(s.chart) + s.w * (s.chart.h / s.chart.w))) if (y + needed > BOTTOM) newPage(subtitle) const maxImgH = BOTTOM - y - Math.max(...slots.map(s => titleBlockH(s.chart))) const used = Math.max(...slots.map(s => drawChartAt(s.chart, s.x, s.w, y, maxImgH))) y += used + 7 } const chartByKey = (key: string) => p.charts.find(c => c.key === key) // --------------------------------------------------------------------------- // Page 1 — overview: SLA rule, KPI cards, monthly chart header('Overview') { const s = p.summary const nwd = p.nonWorkingDays const ruleText = `Orders imported before ${p.cutoff} on a working day must ship the same day, all others by the next working day (Mon–Fri, excluding ${nwd} non-working day${nwd === 1 ? '' : 's'}). Times in ${p.timezone}.` let stockText = '' let stockWarn = false if (p.stockCheck === 'ok' && p.noStockMode === 'exclude') { stockText = 'Orders whose articles were out of stock in the warehouse at the deadline are marked "No stock" and do not count toward the rates.' } else if (p.stockCheck === 'ok') { stockText = `Stock check on: ${s.stockBlocked} order${s.stockBlocked === 1 ? '' : 's'} without stock at the deadline are included in the rates.` } else { stockText = 'Stock check unavailable – direct database access is not configured, orders without stock are not identified.' stockWarn = true } const meta = `${p.orgName} · ${scope} · ${fmtNum(s.orders)} orders` const innerW = CONTENT_W - 10 font(doc, 8, 'normal') const ruleLines: string[] = doc.splitTextToSize(ruleText, innerW) const stockLines: string[] = doc.splitTextToSize(stockText, innerW) const lineH = 3.8 const boxH = 4 + 5 + ruleLines.length * lineH + stockLines.length * lineH + 1.5 + 4.5 + 3 fill(doc, C.band) doc.roundedRect(MARGIN, y, CONTENT_W, boxH, 2, 2, 'F') fill(doc, C.blue) doc.rect(MARGIN, y + 3, 1.4, boxH - 6, 'F') let ty = y + 4 + 3.6 font(doc, 10, 'bold') ink(doc, C.ink) doc.text(`Cut-off ${p.cutoff}`, MARGIN + 6, ty) const titleW = doc.getTextWidth(`Cut-off ${p.cutoff}`) font(doc, 8, 'normal') ink(doc, C.muted) doc.text(cutoffSourceLabel(p.cutoffSource), MARGIN + 6 + titleW + 3, ty) ty += 5.2 ink(doc, C.text) doc.text(ruleLines, MARGIN + 6, ty) ty += ruleLines.length * lineH ink(doc, stockWarn ? C.warn : C.text) doc.text(stockLines, MARGIN + 6, ty) ty += stockLines.length * lineH + 1.5 font(doc, 8, 'bold') ink(doc, C.accent) doc.text(meta, MARGIN + 6, ty) if (p.truncated) { const mw = doc.getTextWidth(meta) ink(doc, C.bad) doc.text('· capped – narrow the range', MARGIN + 6 + mw + 2, ty) } y += boxH + 7 // KPI cards — same six as the page const onTint = rateTint(s.onTimeRate) const slaTint = rateTint(s.slaRate) const cards: { label: string; value: string; valueColor: RGB; soft: RGB; bar: RGB; sub: string; sub2?: string; parts?: { text: string; color: RGB }[] }[] = [ { label: 'On-time rate', value: fmtPct(s.onTimeRate), valueColor: onTint.fg, soft: onTint.soft, bar: onTint.fg, sub: `of ${fmtNum(s.shipped)} shipped orders` }, { label: 'SLA rate incl. overdue', value: fmtPct(s.slaRate), valueColor: slaTint.fg, soft: slaTint.soft, bar: slaTint.fg, sub: 'unshipped past deadline = missed' }, { label: 'On time', value: fmtNum(s.onTime), valueColor: C.good, soft: C.band, bar: C.good, sub: 'orders' }, { label: 'Late', value: fmtNum(s.late), valueColor: C.bad, soft: C.band, bar: C.bad, sub: `Ø ${s.avgDaysLate ?? 0} working days late` }, { label: 'Overdue · Pending', value: '', valueColor: C.ink, soft: C.band, bar: C.overdue, parts: [{ text: fmtNum(s.overdue), color: C.overdue }, { text: ' · ', color: C.muted }, { text: fmtNum(s.pending), color: C.grey }], sub: `not shipped yet (${fmtNum(s.generatedNotShipped)} generated)`, sub2: s.noStock ? `${fmtNum(s.noStock)} without stock – excluded` : undefined }, { label: 'Lead time import to ship', value: fmtHours(s.medianLeadHours), valueColor: C.ink, soft: C.band, bar: C.blue, sub: `median · Ø ${fmtHours(s.avgLeadHours)} · p90 ${fmtHours(s.p90LeadHours)}` } ] const gap = 4 const cardW = (CONTENT_W - gap * 5) / 6 const cardH = 27 cards.forEach((card, i) => { const x = MARGIN + i * (cardW + gap) fill(doc, card.soft) doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F') fill(doc, card.bar) doc.rect(x, y + 3, 1.4, cardH - 6, 'F') font(doc, 7.5, 'normal') ink(doc, C.muted) doc.text(fitText(doc, card.label, cardW - 8), x + 5, y + 6.5) font(doc, 14, 'bold') if (card.parts) { let px = x + 5 card.parts.forEach(part => { ink(doc, part.color) doc.text(part.text, px, y + 15) px += doc.getTextWidth(part.text) }) } else { ink(doc, card.valueColor) doc.text(fitText(doc, card.value, cardW - 8), x + 5, y + 15) } font(doc, 6.5, 'normal') ink(doc, C.muted) doc.text(fitText(doc, card.sub, cardW - 8), x + 5, y + 20.5) if (card.sub2) { ink(doc, C.slate) doc.text(fitText(doc, card.sub2, cardW - 8), x + 5, y + 24) } }) y += cardH + 8 } const months = chartByKey('months') if (months) placeChartRow([{ chart: months, x: MARGIN, w: CONTENT_W }], 'Overview') // --------------------------------------------------------------------------- // Page 2 — remaining charts (day · hour / weekday / lateness) const days = chartByKey('days') const hours = chartByKey('hours') const weekdays = chartByKey('weekdays') const lateness = chartByKey('lateness') if (days || hours || weekdays || lateness) newPage('Charts') if (days) placeChartRow([{ chart: days, x: MARGIN, w: CONTENT_W }], 'Charts') { const rowGap = 4 const halfW = (CONTENT_W - 2 * rowGap) / 2 const quarterW = (CONTENT_W - 2 * rowGap) / 4 const slots: { chart: ShippingPerfPdfChart; x: number; w: number }[] = [] let x = MARGIN if (hours) { slots.push({ chart: hours, x, w: halfW }); x += halfW + rowGap } if (weekdays) { slots.push({ chart: weekdays, x, w: quarterW }); x += quarterW + rowGap } if (lateness) slots.push({ chart: lateness, x, w: quarterW }) placeChartRow(slots, 'Charts') } // --------------------------------------------------------------------------- // By order source (only when more than one source, like the page) if (p.sources.length > 1) { const showNoStock = (p.summary.noStock || 0) > 0 const numCols = ['Orders', 'On time', 'Late', 'Overdue', 'Pending', ...(showNoStock ? ['No stock'] : []), 'On-time rate', 'Ø lead'] const numW = 22 const labelW = CONTENT_W - numCols.length * numW const rowH = 5.2 const headH = 6 const needed = 8 + headH + p.sources.length * rowH + 4 ensure(needed, 'By order source') font(doc, 10, 'bold') ink(doc, C.ink) doc.text('By order source', MARGIN, y + 3.4) y += 7 const colRight = (i: number) => MARGIN + labelW + (i + 1) * numW - 1.5 fill(doc, C.band) doc.rect(MARGIN, y, CONTENT_W, headH, 'F') font(doc, 7.5, 'bold') ink(doc, C.text) doc.text('Source', MARGIN + 1.5, y + headH - 1.8) numCols.forEach((c, i) => doc.text(c, colRight(i), y + headH - 1.8, { align: 'right' })) y += headH p.sources.forEach((src, idx) => { if (y + rowH > BOTTOM) { newPage('By order source') fill(doc, C.band) doc.rect(MARGIN, y, CONTENT_W, headH, 'F') font(doc, 7.5, 'bold') ink(doc, C.text) doc.text('Source', MARGIN + 1.5, y + headH - 1.8) numCols.forEach((c, i) => doc.text(c, colRight(i), y + headH - 1.8, { align: 'right' })) y += headH } if (idx % 2 === 1) { fill(doc, [250, 250, 248] as RGB) doc.rect(MARGIN, y, CONTENT_W, rowH, 'F') } stroke(doc, C.grid) doc.setLineWidth(0.15) doc.line(MARGIN, y + rowH, PAGE_W - MARGIN, y + rowH) const base = y + rowH - 1.6 font(doc, 7.5, 'normal') ink(doc, C.text) doc.text(fitText(doc, src.label || src.key, labelW - 3), MARGIN + 1.5, base) const cells: { text: string; color: RGB; bold?: boolean }[] = [ { text: fmtNum(src.orders), color: C.text }, { text: fmtNum(src.onTime), color: C.good }, { text: fmtNum(src.late), color: C.bad }, { text: fmtNum(src.overdue), color: C.overdue }, { text: fmtNum(src.pending), color: C.grey }, ...(showNoStock ? [{ text: fmtNum(src.noStock || 0), color: C.slate }] : []), { text: fmtPct(src.onTimeRate), color: rateTint(src.onTimeRate).fg, bold: true }, { text: fmtHours(src.avgLeadHours), color: C.text } ] cells.forEach((cell, i) => { font(doc, 7.5, cell.bold ? 'bold' : 'normal') ink(doc, cell.color) doc.text(cell.text, colRight(i), base, { align: 'right' }) }) y += rowH }) y += 6 } // --------------------------------------------------------------------------- // Orders table — the rows the grid currently shows, as many pages as needed const rows = p.rows || [] const ordersSubtitle = `Orders · ${p.statusFilterLabel} · ${fmtNum(rows.length)} of ${fmtNum(p.rowsTotal)}${p.gridFiltered ? ' · grid filter active' : ''}` newPage(ordersSubtitle) const hasStockNote = rows.some(r => r.stockNote) type Col = { key: string; title: string; w: number; align?: 'left' | 'right' } const cols: Col[] = [ { key: 'status', title: 'Status', w: 16 }, { key: 'order', title: 'Order', w: 20 }, { key: 'ext', title: 'Ext. order ID', w: hasStockNote ? 20 : 30 }, { key: 'source', title: 'Source', w: 20 }, { key: 'customer', title: 'Customer', w: hasStockNote ? 32 : 50 }, { key: 'imported', title: 'Imported (Berlin)', w: 22 }, { key: 'wd', title: 'Wd', w: 9 }, { key: 'cutoff', title: 'Cut-off', w: 12 }, { key: 'shipby', title: 'Ship by', w: 16 }, { key: 'shipped', title: 'Shipped at', w: 22 }, { key: 'shipment', title: 'Shipment', w: 18 }, { key: 'shipper', title: 'Shipper', w: 16 }, { key: 'lead', title: 'Lead', w: 13, align: 'right' }, { key: 'late', title: 'Late', w: 9, align: 'right' }, ...(hasStockNote ? [{ key: 'stock', title: 'Missing stock', w: 28 } as Col] : []) ] const colX: number[] = [] cols.reduce((x, c) => { colX.push(x); return x + c.w }, MARGIN) const rowH = 4.6 const headH = 5.6 const tableHead = () => { fill(doc, C.band) doc.rect(MARGIN, y, CONTENT_W, headH, 'F') font(doc, 6.8, 'bold') ink(doc, C.text) cols.forEach((c, i) => { if (c.align === 'right') doc.text(c.title, colX[i] + c.w - 1.2, y + headH - 1.7, { align: 'right' }) else doc.text(fitText(doc, c.title, c.w - 2), colX[i] + 1.2, y + headH - 1.7) }) y += headH } tableHead() let anyMarker = false if (!rows.length) { font(doc, 8, 'italic') ink(doc, C.muted) doc.text('No orders match the current filter.', MARGIN + 1.5, y + 5) y += 8 } rows.forEach((r, idx) => { if (y + rowH > BOTTOM) { newPage(ordersSubtitle) tableHead() } if (idx % 2 === 1) { fill(doc, [250, 250, 248] as RGB) doc.rect(MARGIN, y, CONTENT_W, rowH, 'F') } stroke(doc, C.grid) doc.setLineWidth(0.12) doc.line(MARGIN, y + rowH, PAGE_W - MARGIN, y + rowH) const base = y + rowH - 1.4 const marker = !!(r.stockBlocked && r.status !== 'nostock') if (marker) anyMarker = true cols.forEach((c, i) => { const x = colX[i] const maxW = c.w - 2.4 font(doc, 6.8, 'normal') ink(doc, C.text) switch (c.key) { case 'status': { const s = STATUS[r.status] || STATUS.pending font(doc, 6, 'bold') const label = s.label + (marker ? ' *' : '') const pillW = Math.min(c.w - 2, doc.getTextWidth(label) + 3) fill(doc, s.bg) doc.roundedRect(x + 1, y + 0.7, pillW, rowH - 1.4, 1.2, 1.2, 'F') ink(doc, s.fg) doc.text(fitText(doc, label, pillW - 1.5), x + 2.5, base) break } case 'order': doc.text(fitText(doc, r.documentNo || '', maxW), x + 1.2, base); break case 'ext': doc.text(fitText(doc, r.externalOrderId || '', maxW), x + 1.2, base); break case 'source': doc.text(fitText(doc, r.orderSource || '', maxW), x + 1.2, base); break case 'customer': doc.text(fitText(doc, r.bpartnerName || '', maxW), x + 1.2, base); break case 'imported': doc.text(fitText(doc, fmtDateTime(r.createdAt), maxW), x + 1.2, base); break case 'wd': doc.text(fitText(doc, String(r.createdWeekday || '').slice(0, 3), maxW), x + 1.2, base); break case 'cutoff': ink(doc, r.beforeCutoff ? C.good : C.grey) doc.text(r.beforeCutoff ? 'yes' : 'no', x + 1.2, base) break case 'shipby': doc.text(fitText(doc, fmtDate(r.deadlineDate), maxW), x + 1.2, base); break case 'shipped': doc.text(fitText(doc, fmtDateTime(r.shippedAt), maxW), x + 1.2, base); break case 'shipment': doc.text(fitText(doc, r.shipmentDocumentNo || (r.inOutId ? '#' + r.inOutId : ''), maxW), x + 1.2, base); break case 'shipper': doc.text(fitText(doc, r.shipper || '', maxW), x + 1.2, base); break case 'lead': doc.text(fmtHours(r.leadHours), x + c.w - 1.2, base, { align: 'right' }); break case 'late': { const late = Number(r.daysLate || 0) if (late > 0) { font(doc, 6.8, 'bold'); ink(doc, C.bad) } doc.text(late > 0 ? String(late) : (r.daysLate == null ? '' : '0'), x + c.w - 1.2, base, { align: 'right' }) break } case 'stock': ink(doc, C.slate) doc.text(fitText(doc, r.stockNote || '', maxW), x + 1.2, base) break } }) y += rowH }) if (anyMarker) { ensure(8, ordersSubtitle) font(doc, 6.8, 'italic') ink(doc, C.muted) doc.text('* Order had no stock in the warehouse at the deadline (included in the rates).', MARGIN + 1.5, y + 5) y += 8 } footers() return doc }