InvoiceLayoutPreviewRenderer.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. 'use client'
  2. import { useMemo, useState, useEffect } from 'react'
  3. import { useMessages } from 'next-intl'
  4. import { PDFViewer } from '@react-pdf/renderer'
  5. import '@/features/vehicles/Components/invoice-pdf/fonts.client'
  6. import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
  7. import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
  8. import type { InvoiceLayoutPreviewProps } from './InvoiceLayoutPreview'
  9. import { useServiceType } from '@/components/service-type-context'
  10. // ---------------------------------------------------------------------------
  11. // Dummy data matching InvoiceData type exactly
  12. // ---------------------------------------------------------------------------
  13. function buildDummyCustomFields(customFields?: InvoiceLayoutPreviewProps['customFields']) {
  14. if (!customFields) return []
  15. return customFields
  16. .filter((f) => f.isActive && f.entityType === 'service_record')
  17. .map((f) => ({
  18. fieldId: f.id,
  19. label: f.label,
  20. value:
  21. f.fieldType === 'checkbox'
  22. ? 'true'
  23. : f.fieldType === 'number'
  24. ? '42'
  25. : f.fieldType === 'date'
  26. ? '2026-03-10'
  27. : 'Sample value',
  28. fieldType: f.fieldType,
  29. }))
  30. }
  31. const DUMMY_INVOICE_DATA = {
  32. id: 'preview-dummy-id-00000001',
  33. title: 'Brake Service & Oil Change',
  34. description: null,
  35. type: 'Maintenance',
  36. serviceDate: new Date('2026-03-10'),
  37. startDateTime: new Date('2026-03-10'),
  38. shopName: 'Your Workshop',
  39. techName: 'Mike Johnson',
  40. mileage: 45230,
  41. diagnosticNotes: null,
  42. findings: [
  43. {
  44. description: 'Rear brake pads worn to 15%',
  45. severity: 'needs_work',
  46. notes: 'Recommend replacement within 5,000 miles',
  47. },
  48. { description: 'Minor oil leak at valve cover gasket', severity: 'monitor', notes: null },
  49. ],
  50. invoiceNotes:
  51. '<p>Front brake pads replaced. Oil and filter changed with synthetic 5W-30. Next service recommended at 50,000 miles.</p>',
  52. subtotal: 314.5,
  53. taxRate: 8,
  54. taxAmount: 25.16,
  55. totalAmount: 339.66,
  56. cost: 339.66,
  57. invoiceNumber: 'INV-2026-1001',
  58. discountType: null,
  59. discountValue: 0,
  60. discountAmount: 0,
  61. partItems: [
  62. {
  63. partNumber: 'BP-001',
  64. name: 'Brake Pads (Front)',
  65. quantity: 2,
  66. unitPrice: 45.0,
  67. total: 90.0,
  68. },
  69. {
  70. partNumber: 'OF-042',
  71. name: 'Oil Filter',
  72. quantity: 1,
  73. unitPrice: 12.0,
  74. total: 12.0,
  75. },
  76. {
  77. partNumber: 'SO-530',
  78. name: 'Synthetic Oil 5W-30',
  79. quantity: 5,
  80. unit: 'l',
  81. unitPrice: 8.5,
  82. total: 42.5,
  83. },
  84. ],
  85. laborItems: [
  86. {
  87. description: 'Brake Replacement',
  88. hours: 1.5,
  89. rate: 85.0,
  90. total: 127.5,
  91. },
  92. { description: 'Oil Change', hours: 0.5, rate: 85.0, total: 42.5 },
  93. ],
  94. vehicle: {
  95. make: 'Toyota',
  96. model: 'Camry',
  97. year: 2022,
  98. vin: '1HGBH41JXMN109186',
  99. licensePlate: 'ABC-1234',
  100. mileage: 45230,
  101. customer: {
  102. name: 'John Smith',
  103. email: 'john@example.com',
  104. phone: '(555) 123-4567',
  105. address: '123 Main Street, Springfield',
  106. company: 'Smith Auto Group',
  107. taxId: 'GB123456789',
  108. },
  109. },
  110. }
  111. const DUMMY_QUOTE_DATA = {
  112. quoteNumber: 'QT-2026-1001',
  113. title: 'Brake Service & Oil Change',
  114. description: null,
  115. validUntil: new Date('2026-04-10'),
  116. createdAt: new Date('2026-03-10'),
  117. subtotal: 314.5,
  118. taxRate: 8,
  119. taxAmount: 25.16,
  120. discountType: null,
  121. discountValue: 0,
  122. discountAmount: 0,
  123. totalAmount: 339.66,
  124. notes:
  125. '<p>Front brake pads replacement recommended. Oil and filter change with synthetic 5W-30.</p>',
  126. partItems: [
  127. {
  128. partNumber: 'BP-001',
  129. name: 'Brake Pads (Front)',
  130. quantity: 2,
  131. unitPrice: 45.0,
  132. total: 90.0,
  133. },
  134. {
  135. partNumber: 'OF-042',
  136. name: 'Oil Filter',
  137. quantity: 1,
  138. unitPrice: 12.0,
  139. total: 12.0,
  140. },
  141. {
  142. partNumber: 'SO-530',
  143. name: 'Synthetic Oil 5W-30',
  144. quantity: 5,
  145. unit: 'l',
  146. unitPrice: 8.5,
  147. total: 42.5,
  148. },
  149. ],
  150. laborItems: [
  151. {
  152. description: 'Brake Replacement',
  153. hours: 1.5,
  154. rate: 85.0,
  155. total: 127.5,
  156. },
  157. { description: 'Oil Change', hours: 0.5, rate: 85.0, total: 42.5 },
  158. ],
  159. customer: {
  160. name: 'John Smith',
  161. email: 'john@example.com',
  162. phone: '(555) 123-4567',
  163. address: '123 Main Street, Springfield',
  164. company: 'Smith Auto Group',
  165. taxId: 'GB123456789',
  166. },
  167. vehicle: {
  168. make: 'Toyota',
  169. model: 'Camry',
  170. year: 2022,
  171. vin: '1HGBH41JXMN109186',
  172. licensePlate: 'ABC-1234',
  173. },
  174. }
  175. const DUMMY_WORKSHOP = {
  176. name: 'Your Workshop',
  177. address: '123 Main Street, Springfield',
  178. phone: '(555) 123-4567',
  179. email: 'shop@example.com',
  180. }
  181. const DUMMY_INVOICE_SETTINGS = {
  182. bankAccount: '1234 5678 9012 3456',
  183. orgNumber: '912 345 678',
  184. paymentTerms: 'Net 14',
  185. footerNote: '',
  186. showBankAccount: true,
  187. showOrgNumber: true,
  188. dueDays: 14,
  189. currencyCode: 'USD',
  190. currencyFormat: 'symbol' as const,
  191. unitSystem: 'imperial',
  192. }
  193. // ---------------------------------------------------------------------------
  194. // Renderer (loaded only client-side via dynamic import)
  195. // ---------------------------------------------------------------------------
  196. export function InvoiceLayoutPreviewRenderer({
  197. config,
  198. documentType,
  199. customFields,
  200. template,
  201. logoUrl,
  202. }: InvoiceLayoutPreviewProps) {
  203. const messages = useMessages()
  204. const serviceType = useServiceType()
  205. const labels = useMemo(() => {
  206. const pdf = (messages?.pdf ?? {}) as Record<string, Record<string, string>>
  207. const namespace = documentType === 'quote' ? 'quote' : 'invoice'
  208. const baseLabels = {
  209. ...(pdf[namespace] ?? {}),
  210. ...(pdf.common ?? {}),
  211. }
  212. // Override labels for marine service type
  213. if (serviceType === 'marine') {
  214. const ns = pdf[namespace] ?? {}
  215. if (ns.mileageMarine) baseLabels.mileage = ns.mileageMarine
  216. if (ns.vinMarine) baseLabels.vin = ns.vinMarine
  217. if (ns.plateMarine) baseLabels.plate = ns.plateMarine
  218. if (ns.vehicleMarine) baseLabels.vehicle = ns.vehicleMarine
  219. baseLabels.km = 'hrs'
  220. baseLabels.mi = 'hrs'
  221. }
  222. return baseLabels
  223. }, [messages, documentType, serviceType])
  224. const dummyCf = useMemo(() => buildDummyCustomFields(customFields), [customFields])
  225. // Stable key to force PDFViewer remount when layout changes
  226. const configKey = useMemo(
  227. () =>
  228. config.sections.map((s) => `${s.id}:${s.order}:${s.visible}:${s.column ?? 'f'}`).join('|'),
  229. [config]
  230. )
  231. const templateConfig = useMemo(
  232. () => ({
  233. primaryColor: template.primaryColor,
  234. fontFamily: template.fontFamily,
  235. headerStyle: template.headerStyle,
  236. logoSize: template.logoSize,
  237. showLogo: true,
  238. showCompanyName: true,
  239. layoutConfig: config,
  240. }),
  241. [template, config]
  242. )
  243. const invoiceData = useMemo(() => ({ ...DUMMY_INVOICE_DATA, customFields: dummyCf }), [dummyCf])
  244. const isQuote = documentType === 'quote'
  245. // Generate dummy Telegram QR for preview if section is visible
  246. const telegramQrVisible = config.sections.some((s) => s.id === 'telegram_qr' && s.visible)
  247. const [telegramQrDataUri, setTelegramQrDataUri] = useState<string | undefined>()
  248. useEffect(() => {
  249. if (!telegramQrVisible) {
  250. setTelegramQrDataUri(undefined)
  251. return
  252. }
  253. import('qrcode').then((QRCode) => {
  254. QRCode.toDataURL('https://t.me/example_bot', { width: 200, margin: 1 }).then((uri) =>
  255. setTelegramQrDataUri(uri)
  256. )
  257. })
  258. }, [telegramQrVisible])
  259. return (
  260. <div className="rounded-lg shadow-sm overflow-hidden bg-gray-100">
  261. <PDFViewer
  262. key={configKey}
  263. width="100%"
  264. height={920}
  265. showToolbar={false}
  266. style={{ border: 'none' }}
  267. >
  268. {isQuote ? (
  269. <QuotePDF
  270. data={DUMMY_QUOTE_DATA}
  271. workshop={DUMMY_WORKSHOP}
  272. currencyCode={DUMMY_INVOICE_SETTINGS.currencyCode}
  273. logoDataUri={logoUrl || undefined}
  274. template={templateConfig}
  275. customFields={dummyCf}
  276. labels={labels}
  277. layoutConfig={config}
  278. />
  279. ) : (
  280. <InvoicePDF
  281. data={invoiceData}
  282. workshop={DUMMY_WORKSHOP}
  283. invoiceSettings={DUMMY_INVOICE_SETTINGS}
  284. logoDataUri={logoUrl || undefined}
  285. template={templateConfig}
  286. telegramQrDataUri={telegramQrDataUri}
  287. telegramLabel="Chat with us on Telegram"
  288. labels={labels}
  289. />
  290. )}
  291. </PDFViewer>
  292. </div>
  293. )
  294. }