QuotePDF.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
  2. import { formatCurrency, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
  3. import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
  4. import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
  5. import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
  6. function fillTemplate(template: string, values: Record<string, string>): string {
  7. return Object.entries(values).reduce(
  8. (str, [key, val]) => str.replace(`{${key}}`, val),
  9. template
  10. )
  11. }
  12. interface QuoteData {
  13. quoteNumber: string | null
  14. title: string
  15. description: string | null
  16. validUntil: Date | null
  17. createdAt: Date
  18. subtotal: number
  19. taxRate: number
  20. taxAmount: number
  21. discountType: string | null
  22. discountValue: number
  23. discountAmount: number
  24. totalAmount: number
  25. notes: string | null
  26. partItems: {
  27. partNumber: string | null
  28. name: string
  29. quantity: number
  30. unitPrice: number
  31. total: number
  32. excluded?: boolean
  33. }[]
  34. laborItems: { description: string; hours: number; rate: number; total: number; excluded?: boolean }[]
  35. customer: {
  36. name: string
  37. email: string | null
  38. phone: string | null
  39. address: string | null
  40. company: string | null
  41. } | null
  42. vehicle: {
  43. make: string
  44. model: string
  45. year: number
  46. vin: string | null
  47. licensePlate: string | null
  48. } | null
  49. }
  50. interface WorkshopInfo {
  51. name: string
  52. address: string
  53. phone: string
  54. email: string
  55. }
  56. interface ImageAttachmentPDF {
  57. fileName: string
  58. dataUri: string
  59. description?: string
  60. }
  61. interface OtherAttachmentPDF {
  62. fileName: string
  63. fileType: string
  64. }
  65. export function QuotePDF({
  66. data,
  67. workshop,
  68. currencyCode = 'USD',
  69. logoDataUri,
  70. torqvoiceLogoDataUri,
  71. dateFormat,
  72. timezone,
  73. template,
  74. portalUrl,
  75. imageAttachments = [],
  76. otherAttachments = [],
  77. pdfAttachmentNames = [],
  78. labels = {},
  79. }: {
  80. data: QuoteData
  81. workshop?: WorkshopInfo
  82. currencyCode?: string
  83. logoDataUri?: string
  84. torqvoiceLogoDataUri?: string
  85. dateFormat?: string
  86. timezone?: string
  87. template?: TemplateConfig
  88. portalUrl?: string
  89. imageAttachments?: ImageAttachmentPDF[]
  90. otherAttachments?: OtherAttachmentPDF[]
  91. pdfAttachmentNames?: string[]
  92. labels?: Record<string, string>
  93. }) {
  94. const primaryColor = template?.primaryColor || '#d97706'
  95. const fontFamily = template?.fontFamily || 'Helvetica'
  96. const headerStyle = template?.headerStyle || 'standard'
  97. const showLogo = template?.showLogo !== false
  98. const showCompanyName = template?.showCompanyName !== false
  99. const styles = createStyles(primaryColor, fontFamily)
  100. const fontBold = getFontBold(fontFamily)
  101. const quoteNum = data.quoteNumber || 'QUOTE'
  102. const df = dateFormat || DEFAULT_DATE_FORMAT
  103. const tz = timezone || undefined
  104. const createdDate = formatDateForPdf(data.createdAt, df, tz)
  105. const validDate = data.validUntil ? formatDateForPdf(data.validUntil, df, tz) : null
  106. const shopName = workshop?.name || 'Torqvoice'
  107. const renderCompactHeader = () => (
  108. <View style={{ marginBottom: 20 }}>
  109. <View
  110. style={{
  111. flexDirection: 'row',
  112. alignItems: 'center',
  113. justifyContent: 'space-between',
  114. paddingBottom: 10,
  115. borderBottomWidth: 1,
  116. borderBottomColor: '#e5e7eb',
  117. }}
  118. >
  119. <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
  120. {showLogo && logoDataUri && (
  121. <Image
  122. src={logoDataUri}
  123. style={{ maxWidth: 40, maxHeight: 40, borderRadius: 4, objectFit: 'contain' }}
  124. />
  125. )}
  126. {showCompanyName && (
  127. <View>
  128. <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
  129. {shopName}
  130. </Text>
  131. {workshop?.address && (
  132. <Text style={{ fontSize: 8, color: gray }}>{workshop.address}</Text>
  133. )}
  134. </View>
  135. )}
  136. </View>
  137. <View style={{ alignItems: 'flex-end' }}>
  138. <Text style={{ fontSize: 14, fontFamily: fontBold, color: primaryColor }}>{labels.title || 'QUOTE'}</Text>
  139. <Text style={{ fontSize: 9, color: gray, marginTop: 2 }}>{quoteNum}</Text>
  140. <Text style={{ fontSize: 9, color: gray }}>{createdDate}</Text>
  141. {validDate && <Text style={{ fontSize: 9, color: gray }}>{labels.validUntil ? fillTemplate(labels.validUntil, { date: validDate }) : `Valid until: ${validDate}`}</Text>}
  142. </View>
  143. </View>
  144. <View
  145. style={{
  146. flexDirection: 'row',
  147. justifyContent: 'space-between',
  148. marginTop: 6,
  149. paddingHorizontal: 2,
  150. }}
  151. >
  152. <View style={{ flexDirection: 'row', gap: 12 }}>
  153. {workshop?.phone && (
  154. <Text style={{ fontSize: 8, color: gray }}>{labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}</Text>
  155. )}
  156. {workshop?.email && (
  157. <Text style={{ fontSize: 8, color: gray }}>{workshop.email}</Text>
  158. )}
  159. </View>
  160. {torqvoiceLogoDataUri && (
  161. <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
  162. <Image src={torqvoiceLogoDataUri} style={{ width: 12, height: 12 }} />
  163. <Text style={{ fontSize: 7, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
  164. </View>
  165. )}
  166. </View>
  167. </View>
  168. )
  169. const renderModernHeader = () => (
  170. <View style={{ marginBottom: 24 }}>
  171. <View
  172. style={{
  173. backgroundColor: primaryColor,
  174. padding: 20,
  175. borderRadius: 4,
  176. marginHorizontal: -10,
  177. }}
  178. >
  179. <View
  180. style={{
  181. flexDirection: 'row',
  182. alignItems: 'center',
  183. justifyContent: 'center',
  184. gap: 12,
  185. }}
  186. >
  187. {showLogo && logoDataUri && (
  188. <Image
  189. src={logoDataUri}
  190. style={{ maxWidth: 50, maxHeight: 50, borderRadius: 4, objectFit: 'contain' }}
  191. />
  192. )}
  193. <View style={{ alignItems: 'center' }}>
  194. {showCompanyName && (
  195. <Text style={{ fontSize: 22, fontFamily: fontBold, color: 'white' }}>
  196. {shopName}
  197. </Text>
  198. )}
  199. {workshop?.address && (
  200. <Text style={{ fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 }}>
  201. {workshop.address}
  202. </Text>
  203. )}
  204. <View style={{ flexDirection: 'row', gap: 12, marginTop: 4 }}>
  205. {workshop?.phone && (
  206. <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>
  207. {labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}
  208. </Text>
  209. )}
  210. {workshop?.email && (
  211. <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>
  212. {workshop.email}
  213. </Text>
  214. )}
  215. </View>
  216. </View>
  217. </View>
  218. {torqvoiceLogoDataUri && (
  219. <View
  220. style={{
  221. flexDirection: 'row',
  222. alignItems: 'center',
  223. justifyContent: 'center',
  224. gap: 3,
  225. marginTop: 8,
  226. }}
  227. >
  228. <Image src={torqvoiceLogoDataUri} style={{ width: 12, height: 12 }} />
  229. <Text style={{ fontSize: 7, fontFamily: fontBold, color: 'rgba(255,255,255,0.7)' }}>
  230. Torqvoice
  231. </Text>
  232. </View>
  233. )}
  234. </View>
  235. <View
  236. style={{
  237. flexDirection: 'row',
  238. justifyContent: 'space-between',
  239. alignItems: 'center',
  240. marginTop: 12,
  241. paddingBottom: 8,
  242. }}
  243. >
  244. <Text style={{ fontSize: 18, fontFamily: fontBold, color: primaryColor }}>{labels.title || 'QUOTE'}</Text>
  245. <View style={{ flexDirection: 'row', gap: 16 }}>
  246. <Text style={{ fontSize: 9, color: gray }}>{quoteNum}</Text>
  247. <Text style={{ fontSize: 9, color: gray }}>{createdDate}</Text>
  248. {validDate && <Text style={{ fontSize: 9, color: gray }}>{labels.validUntil ? fillTemplate(labels.validUntil, { date: validDate }) : `Valid until: ${validDate}`}</Text>}
  249. </View>
  250. </View>
  251. </View>
  252. )
  253. const renderStandardHeader = () => (
  254. <View style={styles.header}>
  255. <View>
  256. {showLogo && logoDataUri && (
  257. <Image
  258. src={logoDataUri}
  259. style={{ width: 60, height: 60, marginBottom: 6, borderRadius: 4 }}
  260. />
  261. )}
  262. {showCompanyName && <Text style={styles.brandName}>{shopName}</Text>}
  263. {workshop?.address && <Text style={styles.brandSub}>{workshop.address}</Text>}
  264. {workshop?.phone && <Text style={styles.brandContact}>{labels.tel ? fillTemplate(labels.tel, { phone: workshop.phone }) : `Tel: ${workshop.phone}`}</Text>}
  265. {workshop?.email && <Text style={styles.brandContact}>{workshop.email}</Text>}
  266. </View>
  267. <View>
  268. {torqvoiceLogoDataUri && (
  269. <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 3, marginBottom: 6 }}>
  270. <Image src={torqvoiceLogoDataUri} style={{ width: 16, height: 16 }} />
  271. <Text style={{ fontSize: 9, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
  272. </View>
  273. )}
  274. <Text style={{ ...styles.invoiceTitle, color: primaryColor }}>{labels.title || 'QUOTE'}</Text>
  275. <Text style={styles.invoiceNumber}>{quoteNum}</Text>
  276. <Text style={styles.invoiceNumber}>{createdDate}</Text>
  277. {validDate && <Text style={styles.invoiceNumber}>{labels.validUntil ? fillTemplate(labels.validUntil, { date: validDate }) : `Valid until: ${validDate}`}</Text>}
  278. </View>
  279. </View>
  280. )
  281. return (
  282. <Document>
  283. <Page size="A4" style={styles.page}>
  284. {headerStyle === 'compact'
  285. ? renderCompactHeader()
  286. : headerStyle === 'modern'
  287. ? renderModernHeader()
  288. : renderStandardHeader()}
  289. <View style={styles.infoRow}>
  290. {data.customer && (
  291. <View style={styles.infoBox}>
  292. <Text style={styles.infoLabel}>{labels.to || 'To'}</Text>
  293. <Text style={styles.infoTextBold}>{data.customer.name}</Text>
  294. {data.customer.company && (
  295. <Text style={styles.infoText}>{data.customer.company}</Text>
  296. )}
  297. {data.customer.address && (
  298. <Text style={styles.infoTextSmall}>{data.customer.address}</Text>
  299. )}
  300. {data.customer.email && (
  301. <Text style={styles.infoTextSmall}>{data.customer.email}</Text>
  302. )}
  303. {data.customer.phone && (
  304. <Text style={styles.infoTextSmall}>{data.customer.phone}</Text>
  305. )}
  306. </View>
  307. )}
  308. {data.vehicle && (
  309. <View style={styles.infoBox}>
  310. <Text style={styles.infoLabel}>{labels.vehicle || 'Vehicle'}</Text>
  311. <Text style={styles.infoTextBold}>
  312. {data.vehicle.year} {data.vehicle.make} {data.vehicle.model}
  313. </Text>
  314. {data.vehicle.vin && (
  315. <Text style={styles.infoTextSmall}>{labels.vin ? fillTemplate(labels.vin, { vin: data.vehicle.vin }) : `VIN: ${data.vehicle.vin}`}</Text>
  316. )}
  317. {data.vehicle.licensePlate && (
  318. <Text style={styles.infoTextSmall}>{labels.plate ? fillTemplate(labels.plate, { plate: data.vehicle.licensePlate }) : `Plate: ${data.vehicle.licensePlate}`}</Text>
  319. )}
  320. </View>
  321. )}
  322. <View style={styles.infoBox}>
  323. <Text style={styles.infoLabel}>{labels.quoteDetails || 'Quote Details'}</Text>
  324. <Text style={styles.infoTextBold}>{data.title}</Text>
  325. </View>
  326. </View>
  327. {data.partItems.length > 0 && (
  328. <View>
  329. <Text style={styles.sectionTitle}>{labels.parts || 'Parts'}</Text>
  330. <View style={styles.table}>
  331. <View style={styles.tableHeader}>
  332. <Text style={{ ...styles.tableHeaderCell, width: '15%' }}>{labels.partNumber || 'Part #'}</Text>
  333. <Text style={{ ...styles.tableHeaderCell, width: '35%' }}>{labels.description || 'Description'}</Text>
  334. <Text style={{ ...styles.tableHeaderCell, width: '12%', textAlign: 'right' }}>
  335. {labels.qty || 'Qty'}
  336. </Text>
  337. <Text style={{ ...styles.tableHeaderCell, width: '18%', textAlign: 'right' }}>
  338. {labels.unitPrice || 'Unit Price'}
  339. </Text>
  340. <Text style={{ ...styles.tableHeaderCell, width: '20%', textAlign: 'right' }}>
  341. {labels.total || 'Total'}
  342. </Text>
  343. </View>
  344. {data.partItems.map((p, i) => (
  345. <View key={i} style={{ ...styles.tableRow, ...(p.excluded ? { opacity: 0.5 } : {}) }}>
  346. <Text style={{ ...styles.tableCell, width: '15%', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>{p.partNumber || '-'}</Text>
  347. <Text style={{ ...styles.tableCell, width: '35%', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>{p.name}</Text>
  348. <Text style={{ ...styles.tableCell, width: '12%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
  349. {p.quantity}
  350. </Text>
  351. <Text style={{ ...styles.tableCell, width: '18%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
  352. {formatCurrency(p.unitPrice, currencyCode)}
  353. </Text>
  354. <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right', ...(p.excluded ? { textDecoration: 'line-through' } : {}) }}>
  355. {formatCurrency(p.total, currencyCode)}
  356. </Text>
  357. </View>
  358. ))}
  359. </View>
  360. </View>
  361. )}
  362. {data.laborItems.length > 0 && (
  363. <View>
  364. <Text style={styles.sectionTitle}>{labels.labor || 'Labor'}</Text>
  365. <View style={styles.table}>
  366. <View style={styles.tableHeader}>
  367. <Text style={{ ...styles.tableHeaderCell, width: '45%' }}>{labels.description || 'Description'}</Text>
  368. <Text style={{ ...styles.tableHeaderCell, width: '15%', textAlign: 'right' }}>
  369. {labels.hours || 'Hours'}
  370. </Text>
  371. <Text style={{ ...styles.tableHeaderCell, width: '20%', textAlign: 'right' }}>
  372. {labels.rate || 'Rate'}
  373. </Text>
  374. <Text style={{ ...styles.tableHeaderCell, width: '20%', textAlign: 'right' }}>
  375. {labels.total || 'Total'}
  376. </Text>
  377. </View>
  378. {data.laborItems.map((l, i) => (
  379. <View key={i} style={{ ...styles.tableRow, ...(l.excluded ? { opacity: 0.5 } : {}) }}>
  380. <Text style={{ ...styles.tableCell, width: '45%', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>{l.description}</Text>
  381. <Text style={{ ...styles.tableCell, width: '15%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
  382. {l.hours}
  383. </Text>
  384. <Text style={{ ...styles.tableCell, width: '20%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
  385. {labels.ratePerHour ? fillTemplate(labels.ratePerHour, { rate: formatCurrency(l.rate, currencyCode) }) : `${formatCurrency(l.rate, currencyCode)}/hr`}
  386. </Text>
  387. <Text style={{ ...styles.tableCellBold, width: '20%', textAlign: 'right', ...(l.excluded ? { textDecoration: 'line-through' } : {}) }}>
  388. {formatCurrency(l.total, currencyCode)}
  389. </Text>
  390. </View>
  391. ))}
  392. </View>
  393. </View>
  394. )}
  395. {(() => {
  396. const laborTotal = data.laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0)
  397. const partsTotal = data.partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0)
  398. const sub = laborTotal + partsTotal
  399. const disc = data.discountType === 'percentage'
  400. ? sub * (data.discountValue / 100)
  401. : data.discountType === 'fixed'
  402. ? Math.min(data.discountValue, sub)
  403. : 0
  404. const tax = (sub - disc) * (data.taxRate / 100)
  405. const total = sub - disc + tax
  406. return (
  407. <View style={styles.totalsBox}>
  408. {data.laborItems.length > 0 && (
  409. <View style={styles.totalRow}>
  410. <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
  411. <Text style={styles.totalValue}>
  412. {formatCurrency(laborTotal, currencyCode)}
  413. </Text>
  414. </View>
  415. )}
  416. {data.partItems.length > 0 && (
  417. <View style={styles.totalRow}>
  418. <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
  419. <Text style={styles.totalValue}>
  420. {formatCurrency(partsTotal, currencyCode)}
  421. </Text>
  422. </View>
  423. )}
  424. {sub > 0 && (
  425. <View style={styles.totalRow}>
  426. <Text style={styles.totalLabel}>{labels.subtotal || 'Subtotal'}</Text>
  427. <Text style={styles.totalValue}>{formatCurrency(sub, currencyCode)}</Text>
  428. </View>
  429. )}
  430. {disc > 0 && (
  431. <View style={styles.totalRow}>
  432. <Text style={styles.totalLabel}>
  433. {data.discountType === 'percentage'
  434. ? (labels.discountPercent ? fillTemplate(labels.discountPercent, { percent: String(data.discountValue) }) : `Discount (${data.discountValue}%)`)
  435. : (labels.discount || 'Discount')}
  436. </Text>
  437. <Text style={{ ...styles.totalValue, color: '#dc2626' }}>
  438. {formatCurrency(-disc, currencyCode)}
  439. </Text>
  440. </View>
  441. )}
  442. {data.taxRate > 0 && (
  443. <View style={styles.totalRow}>
  444. <Text style={styles.totalLabel}>{labels.tax ? fillTemplate(labels.tax, { rate: String(data.taxRate) }) : `Tax (${data.taxRate}%)`}</Text>
  445. <Text style={styles.totalValue}>{formatCurrency(tax, currencyCode)}</Text>
  446. </View>
  447. )}
  448. <View style={styles.totalDivider} />
  449. <View style={styles.grandTotalRow}>
  450. <Text style={styles.grandTotalLabel}>{labels.total || 'Total'}</Text>
  451. <Text style={styles.grandTotalValue}>
  452. {formatCurrency(total, currencyCode)}
  453. </Text>
  454. </View>
  455. </View>
  456. )
  457. })()}
  458. {torqvoiceLogoDataUri && (
  459. <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 3, marginTop: 6 }}>
  460. <Text style={{ fontSize: 7, color: gray }}>{labels.poweredBy || 'Powered by'}</Text>
  461. <Image src={torqvoiceLogoDataUri} style={{ width: 12, height: 12 }} />
  462. <Text style={{ fontSize: 7, color: gray, fontFamily: fontBold }}>Torqvoice</Text>
  463. </View>
  464. )}
  465. {data.description && (
  466. <View style={styles.notesSection}>
  467. <Text style={styles.notesLabel}>Description</Text>
  468. <HtmlToPdf html={data.description} baseStyle={styles.notesText} fontBold={fontBold} />
  469. </View>
  470. )}
  471. {portalUrl && (
  472. <View style={{ marginTop: 8 }}>
  473. <Text style={{ fontSize: 8, color: gray, textAlign: 'center' }}>
  474. {labels.viewPortal ? fillTemplate(labels.viewPortal, { url: portalUrl }) : `View your portal: ${portalUrl}`}
  475. </Text>
  476. </View>
  477. )}
  478. {/* Document attachment names */}
  479. {(otherAttachments.length > 0 || pdfAttachmentNames.length > 0) && (
  480. <View style={{ marginTop: 10 }}>
  481. <Text style={styles.sectionTitle}>{labels.attachedDocuments || 'Attached Documents'}</Text>
  482. {otherAttachments.map((att, i) => (
  483. <Text key={`other-${i}`} style={{ fontSize: 9, color: gray, marginBottom: 2 }}>
  484. {att.fileName}
  485. </Text>
  486. ))}
  487. {pdfAttachmentNames.map((name, i) => (
  488. <Text key={`pdf-${i}`} style={{ fontSize: 9, color: gray, marginBottom: 2 }}>
  489. {labels.attached ? fillTemplate(labels.attached, { name }) : `${name} (attached)`}
  490. </Text>
  491. ))}
  492. </View>
  493. )}
  494. {torqvoiceLogoDataUri ? (
  495. <View style={{
  496. ...styles.footer,
  497. flexDirection: 'row',
  498. alignItems: 'center',
  499. justifyContent: 'center',
  500. gap: 4,
  501. }}>
  502. <Text style={{ fontSize: 8, color: gray }}>
  503. {validDate
  504. ? (labels.validityFooterUntil ? fillTemplate(labels.validityFooterUntil, { date: validDate }) : `This quote is valid until ${validDate}`)
  505. : (labels.validityFooter30 || 'This quote is valid for 30 days')} ·{' '}
  506. </Text>
  507. <Text style={{ fontSize: 7, color: gray }}>{labels.poweredBy || 'Powered by'}</Text>
  508. <Image src={torqvoiceLogoDataUri} style={{ width: 14, height: 14 }} />
  509. <Text style={{ fontSize: 7, color: gray, fontFamily: fontBold }}>Torqvoice</Text>
  510. </View>
  511. ) : (
  512. <Text style={styles.footer}>
  513. {validDate
  514. ? (labels.validityFooterUntil ? fillTemplate(labels.validityFooterUntil, { date: validDate }) : `This quote is valid until ${validDate}`)
  515. : (labels.validityFooter30 || 'This quote is valid for 30 days')} · {shopName}
  516. </Text>
  517. )}
  518. </Page>
  519. {imageAttachments.length > 0 && (
  520. <Page size="A4" style={styles.page}>
  521. <Text style={styles.sectionTitle}>{labels.quoteImages || 'Quote Images'}</Text>
  522. <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
  523. {imageAttachments.map((img, i) => (
  524. <View key={i} style={{ width: '48%', marginBottom: 8 }}>
  525. <Image
  526. src={img.dataUri}
  527. style={{
  528. maxHeight: 250,
  529. borderRadius: 4,
  530. objectFit: 'contain',
  531. objectPosition: 'left',
  532. }}
  533. />
  534. {img.description ? (
  535. <Text style={{ fontSize: 8, color: gray, marginTop: 2 }}>{img.description}</Text>
  536. ) : (
  537. <Text style={{ fontSize: 8, color: gray, marginTop: 2 }}>{img.fileName}</Text>
  538. )}
  539. </View>
  540. ))}
  541. </View>
  542. <Text style={styles.footer}>
  543. {quoteNum} · {shopName}
  544. </Text>
  545. </Page>
  546. )}
  547. </Document>
  548. )
  549. }