// Shared pdfmake document definition for the "Picklist" report. Every picklist // entry point renders through this one builder so the report looks the same // everywhere: /generate-shipments (normal), the dashboard reprint modal // (usePicklistPdf.ts), /integrations/picklist-desktop, /mobile/picklist and // /mobile/generate-shipments. // // Layout: A4 portrait, rows roughly twice as tall as the old 6.5 pt report. // The extra height is used on purpose — the product name may wrap to two lines // and the codes are stacked (Value / SKU over MPN / UPC) — so the larger type // still fits the portrait width. The locator is drawn as three bordered chips // (X, Y, Z) in fixed-width columns so aisles line up down the page; Qty is bold. // // Column order follows the pick: Org, Locator, Qty, Pick Code, Name, then the // reference columns (Weight, all codes). "Pick Code" is the code the merchant's // pickers match — C_BPartner.PickProductIdentifier of the org-linked partner, // delivered per line as `pickIdentifier` (see ~/utils/pickCode.ts). It stays // EMPTY when the merchant has no identifier configured; the product-level // PickListCode only drives the digital lists, not this report. import { resolvePickCode, getProductCodeValue, PICK_CODE_LABELS } from './pickCode' interface PicklistDocOptions { rows: any[] relatedDocNos?: string[] today: string title?: string } const NAME_MAX_CHARS = 48 const LINE_COLOR = '#9f9f9f' const CHIP_BORDER = '#0f172a' const CHIP_WIDTH = 20 const CHIP_GAP = 4 // Content widths (pt, without cell padding) of the fixed columns; Name takes the rest. const ORG_WIDTH = 60 const LOCATOR_WIDTH = 3 * CHIP_WIDTH + 2 * CHIP_GAP + 18 const QTY_WIDTH = 28 const PICK_CODE_WIDTH = 106 const WEIGHT_WIDTH = 32 const CODES_WIDTH = 92 // Rough Roboto advance widths as a fraction of the font size — good enough to pick a // font size that keeps a string on one line (pdfmake exposes no measuring API here). const CHAR_EM = 0.54 const CHAR_EM_BOLD = 0.6 const fitFontSize = (text: string | number, width: number, max: number, min: number, em = CHAR_EM) => { const len = Math.max(typeof text === 'number' ? text : text.length, 1) return Math.max(min, Math.min(max, Math.floor((width / (len * em)) * 10) / 10)) } // Print-friendly take on the on-screen loc-x / loc-y / loc-z chips (the report is // mostly printed on a b/w laser): X inverted, Y shaded, Z plain — all bordered. const CHIP_STYLES = [ { fillColor: '#0f172a', color: '#ffffff' }, { fillColor: '#e5e7eb', color: '#0f172a' }, { fillColor: '#ffffff', color: '#0f172a' }, ] const truncate = (str: any, max: number) => { const s = (str ?? '').toString() if (!s) return '-' return s.length > max ? `${s.slice(0, max - 1)}…` : s } const formatWeight = (w: any) => { const n = Number(w) return Number.isFinite(n) && n > 0 ? `${Number(n.toFixed(3))} kg` : '-' } // pdfmake table cells have no vertical alignment. The codes cell is always two // lines, so text that is certain to fit on ONE line gets a top margin to sit // centred in the row; anything that may wrap starts at the top instead. const NAME_ONE_LINE_CHARS = 22 const centredIfShort = (text: string, maxChars: number) => (text.length <= maxChars ? [0, 4, 0, 0] : [0, 0, 0, 0]) // Org name on at most two lines, broken ONLY at whitespace: pdfmake would otherwise // split a long word mid-way ("simpeltradi / ng"). The lines are laid out here (most // balanced split), rendered noWrap, and the type shrinks until the longest line fits. const orgCell = (org: string) => { const words = org.trim().split(/\s+/).filter(Boolean) let lines = [words.join(' ') || '-'] if (words.length > 1 && lines[0].length * CHAR_EM * 9 > ORG_WIDTH) { let best = Infinity for (let i = 1; i < words.length; i++) { const a = words.slice(0, i).join(' ') const b = words.slice(i).join(' ') const longest = Math.max(a.length, b.length) if (longest < best) { best = longest; lines = [a, b] } } } const longest = lines.reduce((m, l) => Math.max(m, l.length), 0) const fontSize = fitFontSize(longest, ORG_WIDTH, 9, 5.5) return { stack: lines.map(l => ({ text: l, noWrap: true })), fontSize, margin: lines.length === 1 ? [0, 4, 0, 0] : [0, 0, 0, 0], } } // The merchant's configured identifier, big and bold, with its field name underneath. // Falls back (normal weight) to the next available code when the configured field is // empty on this product; empty cell when the merchant has nothing configured. const pickCodeCell = (item: any) => { if (!item?.pickIdentifier) return { text: '' } const resolved = resolvePickCode(item.M_Product_ID, item.pickIdentifier) const value = resolved ? getProductCodeValue(item.M_Product_ID, resolved.key) : '' if (!resolved || !value) return { text: '' } const configured = resolved.source === 'partner' return { stack: [ { text: value, bold: configured, // digits are narrower than capitals in Roboto — lets an EAN-13 print at full size fontSize: fitFontSize(value, PICK_CODE_WIDTH, configured ? 14 : 10, 7, /^\d+$/.test(value) ? 0.57 : (configured ? 0.64 : CHAR_EM)), }, { text: PICK_CODE_LABELS[resolved.key] || '', fontSize: 6, color: '#555555' }, ], } } const qtyCell = (qty: any) => { const text = String(qty ?? '') return { text, bold: true, noWrap: true, fontSize: fitFontSize(text, QTY_WIDTH, 13, 7, CHAR_EM_BOLD), alignment: 'center', margin: [0, 2, 0, 0], } } // Locator values are normally two characters ("AF", "06"); shrink the type for the // odd long one so it stays inside its chip instead of wrapping. const chipFontSize = (value: string) => { if (value.length <= 3) return 10 if (value.length <= 5) return 7.5 return 6 } const locatorCell = (loc: any) => { const parts = [loc?.X, loc?.Y, loc?.Z].map(p => (p ?? '').toString().trim()) if (parts.every(p => p === '')) return { text: '-', style: 'tableCell', margin: [0, 4, 0, 0] } const row: any[] = [] parts.forEach((part, idx) => { if (idx > 0) row.push({ text: '', border: [false, false, false, false] }) const value = part || '–' row.push({ text: value, bold: true, fontSize: chipFontSize(value), alignment: 'center', noWrap: true, border: [true, true, true, true], ...CHIP_STYLES[idx], }) }) return { margin: [0, 1, 0, 0], table: { widths: [CHIP_WIDTH, CHIP_GAP, CHIP_WIDTH, CHIP_GAP, CHIP_WIDTH], body: [row], }, layout: { hLineWidth: () => 0.8, vLineWidth: () => 0.8, hLineColor: () => CHIP_BORDER, vLineColor: () => CHIP_BORDER, // even columns are chips, odd columns are the gaps between them paddingLeft: (i: number) => (i % 2 === 0 ? 2 : 0), paddingRight: (i: number) => (i % 2 === 0 ? 2 : 0), paddingTop: () => 2, paddingBottom: () => 2, }, } } export const buildPicklistDocDefinition = ({ rows, relatedDocNos = [], today, title = 'Picklist' }: PicklistDocOptions) => { const tableBody: any[] = [ [ { text: 'Org', style: 'tableHeader' }, { text: 'Locator', style: 'tableHeader' }, { text: 'Qty', style: 'tableHeader', alignment: 'center' }, { text: 'Pick Code', style: 'tableHeader' }, { text: 'Name', style: 'tableHeader' }, { text: 'Weight', style: 'tableHeader', alignment: 'right' }, { text: 'Value / SKU\nMPN / UPC', style: 'tableHeader' }, ], ...(rows || []).map((item: any) => { const value = item.M_Product_ID?.Value || '-' const sku = item.M_Product_ID?.SKU || '-' const mpn = item.M_Product_ID?.mpn || '-' const upc = item.M_Product_ID?.UPC || '-' const org = item.AD_Org_ID?.identifier || '-' const name = truncate(item.M_Product_ID?.Name, NAME_MAX_CHARS) const weight = formatWeight(item.M_Product_ID?.Weight) return [ orgCell(org), locatorCell(item.M_Locator_ID), qtyCell(item.QtyEntered), pickCodeCell(item), { text: name, style: 'tableCell', margin: centredIfShort(name, NAME_ONE_LINE_CHARS) }, { text: weight, fontSize: fitFontSize(weight, WEIGHT_WIDTH, 8, 6), alignment: 'right', noWrap: true, margin: [0, 4, 0, 0] }, { text: `${value} / ${sku}\n${mpn} / ${upc}`, style: 'tableCellCodes' }, ] }), ] const content: any[] = [ { text: title, style: 'title', alignment: 'center' }, { text: today, style: 'subtitle', alignment: 'center', margin: [0, 2, 0, 8] }, { table: { headerRows: 1, widths: [ORG_WIDTH, LOCATOR_WIDTH, QTY_WIDTH, PICK_CODE_WIDTH, '*', WEIGHT_WIDTH, CODES_WIDTH], body: tableBody, dontBreakRows: true, }, layout: { hLineWidth: () => 0.5, vLineWidth: () => 0.5, hLineColor: () => LINE_COLOR, vLineColor: () => LINE_COLOR, fillColor: (rowIndex: number) => rowIndex === 0 ? '#f2f2f2' : null, paddingLeft: () => 3, paddingRight: () => 3, paddingTop: () => 3, paddingBottom: () => 3, }, }, ] if (relatedDocNos.length > 0) { content.push({ margin: [0, 8, 0, 0], table: { widths: ['*'], body: [[{ stack: [ { text: `Related Shipments (${relatedDocNos.length})`, fontSize: 8, bold: true, margin: [0, 0, 0, 3] }, { text: relatedDocNos.join(', '), fontSize: 7.5, lineHeight: 1.4 }, ], margin: [4, 4, 4, 4], }]], }, layout: { hLineWidth: () => 0.5, vLineWidth: () => 0.5, hLineColor: () => LINE_COLOR, vLineColor: () => LINE_COLOR, }, }) } return { pageSize: 'A4', pageOrientation: 'portrait', pageMargins: [15, 15, 15, 25], content, footer: (currentPage: number, pageCount: number) => pageCount > 1 ? { text: `${currentPage} / ${pageCount}`, alignment: 'center', fontSize: 8, margin: [0, 8, 0, 0] } : null, styles: { title: { fontSize: 14, bold: true }, subtitle: { fontSize: 8 }, tableHeader: { fontSize: 8, bold: true }, tableCell: { fontSize: 8.5 }, tableCellCodes: { fontSize: 6.5 }, }, defaultStyle: { fontSize: 9 }, } }