sample.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
  2. import { calculateTotals } from '@/lib/tax'
  3. import { taxComponentLabel } from '@/lib/tax-components'
  4. import type { DocumentData, PaymentPair, TotalLine } from '../Spec/buildSpec'
  5. import type { DesignerWorkshop, DocumentType } from './types'
  6. /**
  7. * What the canvas prints for each field the layout can show.
  8. *
  9. * Keyed by field id, because that is what a layout stores. Every value here
  10. * is deliberately made up: the designer is shown to every workshop, so it
  11. * must never carry a real customer, vehicle or account from anyone's books.
  12. * The workshop's own company details are the one exception, because the sheet
  13. * being previewed is that workshop's own sheet.
  14. *
  15. * The prose is translated, because the preview is meant to look like the sheet
  16. * the workshop will actually print, and that sheet is printed in the customer's
  17. * language. Names, plates and account numbers stay as they are: they read the
  18. * same everywhere, and translating them would only make them look real.
  19. */
  20. /** A translator, narrowed to what this file asks of it. */
  21. export type SampleT = (key: string, values?: Record<string, string | number>) => string
  22. /**
  23. * The document labels the print path resolves from `pdf.json`, so the preview
  24. * names its columns and panels the way the printed sheet does.
  25. */
  26. export type PrintLabels = Record<string, string>
  27. /** `{name}` style placeholders, the same substitution the print builders use. */
  28. export function fillTemplate(template: string, values: Record<string, string>): string {
  29. return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
  30. }
  31. export interface SampleTables {
  32. items: {
  33. n: number
  34. qty: string
  35. unit: string
  36. desc: string
  37. sku?: string
  38. price: string
  39. total: string
  40. }[]
  41. findings: { severity: string; color: string; description: string; notes: string }[]
  42. subtotal: string
  43. tax: string
  44. total: string
  45. number: string
  46. date: string
  47. due: string
  48. customerNumber: string
  49. notes: string
  50. warranty: string
  51. title: string
  52. }
  53. export function sampleTables(t: SampleT, labels: PrintLabels): SampleTables {
  54. const L = (key: string, fallback: string) => labels[key] || fallback
  55. return {
  56. items: [
  57. {
  58. n: 1,
  59. qty: '2.5',
  60. unit: L('hrs', 'hrs'),
  61. desc: t('sample.itemLabor'),
  62. price: '€ 89.00',
  63. total: '€ 222.50',
  64. },
  65. {
  66. n: 2,
  67. qty: '1',
  68. unit: t('sample.unitPcs'),
  69. desc: t('sample.itemDisc'),
  70. sku: 'BD-1042',
  71. price: '€ 149.00',
  72. total: '€ 149.00',
  73. },
  74. {
  75. n: 3,
  76. qty: '1',
  77. unit: t('sample.unitSet'),
  78. desc: t('sample.itemPads'),
  79. sku: 'BP-2210',
  80. price: '€ 96.50',
  81. total: '€ 96.50',
  82. },
  83. {
  84. n: 4,
  85. qty: '1',
  86. unit: t('sample.unitEach'),
  87. desc: t('sample.itemConsumables'),
  88. price: '€ 12.00',
  89. total: '€ 12.00',
  90. },
  91. ],
  92. findings: [
  93. {
  94. severity: L('findingSeverityNeedsWork', 'Medium'),
  95. color: '#f59e0b',
  96. description: t('sample.findingPads'),
  97. notes: t('sample.findingPadsNote'),
  98. },
  99. {
  100. severity: L('findingSeverityMonitor', 'Low'),
  101. color: '#3b82f6',
  102. description: t('sample.findingOilWeep'),
  103. notes: '',
  104. },
  105. ],
  106. subtotal: '€ 480.00',
  107. tax: '€ 120.00',
  108. total: '€ 600.00',
  109. number: 'INV-2026-0042',
  110. date: '14.08.2026',
  111. due: '28.08.2026',
  112. customerNumber: 'C-0117',
  113. notes: t('sample.notes'),
  114. warranty: t('sample.warranty'),
  115. title: L('title', 'INVOICE'),
  116. }
  117. }
  118. /**
  119. * The value each field prints. The workshop's own details where it has them,
  120. * so the sheet on screen is the workshop's sheet; everything about the sample
  121. * job is invented.
  122. */
  123. export function fieldValues(
  124. workshop: DesignerWorkshop,
  125. t: SampleT,
  126. labels: PrintLabels
  127. ): Record<string, string> {
  128. const L = (key: string, fallback: string) => labels[key] || fallback
  129. return {
  130. // A made-up customer, the same for every workshop.
  131. customer_name: 'Alex Carter',
  132. customer_company: 'Carter Logistics Ltd',
  133. // Two lines, because a customer address is written on two and the
  134. // designer should show what that does to the block before it prints.
  135. customer_address: '12 Harbour Road\nSpringfield',
  136. customer_email: 'alex@example.com',
  137. customer_phone: '+1 555 0134',
  138. customer_tax_id: `${L('customerTaxId', 'Tax ID')}: 000 000 000`,
  139. // A made-up vehicle.
  140. vehicle_name: '2020 Volvo V60',
  141. vin: fillTemplate(L('vin', 'VIN: {vin}'), { vin: 'YV1AA0000L0000000' }),
  142. license_plate: fillTemplate(L('plate', 'Plate: {plate}'), { plate: 'AB 12345' }),
  143. mileage: fillTemplate(L('mileage', 'Mileage: {mileage}'), {
  144. mileage: `84,120 ${L('km', 'km')}`,
  145. }),
  146. // A made-up job.
  147. service_title: t('sample.serviceTitle'),
  148. service_type: fillTemplate(L('type', 'Type: {type}'), { type: t('sample.serviceType') }),
  149. tech_name: fillTemplate(L('tech', 'Tech: {tech}'), { tech: 'Jamie Lee' }),
  150. // Company, on the letterhead and in the footer
  151. company_name: workshop.name || t('sample.companyName'),
  152. // A placeholder when the workshop has none, so the slogan section is
  153. // something to see and drag rather than an invisible sliver.
  154. company_slogan: workshop.slogan || t('sample.slogan'),
  155. company_address: workshop.address || '',
  156. company_phone: workshop.phone
  157. ? fillTemplate(L('tel', 'Tel: {phone}'), { phone: workshop.phone })
  158. : '',
  159. company_email: workshop.email || '',
  160. company_org_number: workshop.orgNumber
  161. ? fillTemplate(L('org', 'Org: {org}'), { org: workshop.orgNumber })
  162. : '',
  163. footer_note: t('sample.footerNote'),
  164. bank_account: 'XX00 1234 5678 9000 00',
  165. org_number: workshop.orgNumber
  166. ? fillTemplate(L('org', 'Org: {org}'), { org: workshop.orgNumber })
  167. : '',
  168. }
  169. }
  170. /** The sample's money, written the way its other figures are. */
  171. function sampleMoney(value: number): string {
  172. return `€ ${value.toFixed(2)}`
  173. }
  174. /**
  175. * The tax rows of the sample, and the registration numbers they bring: the
  176. * workshop's own split when it has one, taxed on the sample's net subtotal
  177. * so the lines add up on the canvas as they will on paper; otherwise the
  178. * single 25% line every workshop has always seen here.
  179. */
  180. function sampleTax(
  181. workshop: DesignerWorkshop,
  182. sample: SampleTables,
  183. labels: PrintLabels
  184. ): { lines: TotalLine[]; total: string; registrations: PaymentPair[] } {
  185. const L = (key: string, fallback: string) => labels[key] || fallback
  186. const components = workshop.taxComponents ?? null
  187. if (!components || components.length === 0) {
  188. return {
  189. lines: [
  190. {
  191. // The tax label carries the rate, the way the printed sheet does.
  192. label: fillTemplate(L('tax', 'Tax ({rate}%)'), { rate: '25' }),
  193. value: sample.tax,
  194. kind: 'line',
  195. },
  196. ],
  197. total: sample.total,
  198. registrations: [],
  199. }
  200. }
  201. const subtotal = Number(sample.subtotal.replace(/[^\d.]/g, ''))
  202. const totals = calculateTotals({
  203. subtotal,
  204. discountAmount: 0,
  205. taxRate: 0,
  206. taxInclusive: false,
  207. components,
  208. })
  209. return {
  210. lines: (totals.components ?? []).map((component) => ({
  211. label: taxComponentLabel(component),
  212. value: sampleMoney(component.amount),
  213. kind: 'line' as const,
  214. })),
  215. total: sampleMoney(totals.totalAmount),
  216. registrations: components
  217. .filter((component) => component.registrationNumber)
  218. .map((component) => ({
  219. label: fillTemplate(L('taxRegistrationLabel', '{name} No.'), { name: component.name }),
  220. value: component.registrationNumber as string,
  221. })),
  222. }
  223. }
  224. /**
  225. * The whole sample document: what a workshop's own sheet says, with the
  226. * sample standing in for a job. One builder, so the designer's canvas and the
  227. * template cards in settings preview exactly the same paper.
  228. */
  229. export function buildSampleData(
  230. workshop: DesignerWorkshop,
  231. customFields: { id: string; label?: string | null; name: string; isActive: boolean }[],
  232. t: SampleT,
  233. labels: PrintLabels,
  234. docType: DocumentType
  235. ): DocumentData {
  236. const L = (key: string, fallback: string) => labels[key] || fallback
  237. const sample = sampleTables(t, labels)
  238. const values = fieldValues(workshop, t, labels)
  239. const tax = sampleTax(workshop, sample, labels)
  240. return {
  241. fields: {
  242. ...values,
  243. // A custom field prints whatever the job carries; here it shows its
  244. // own name so the workshop can see where it will sit.
  245. ...Object.fromEntries(
  246. customFields
  247. .filter((f) => f.isActive)
  248. .map((f) => [toCustomFieldId(f.id), `${f.label || f.name}: ${t('sample.value')}`])
  249. ),
  250. },
  251. logoUrl: workshop.logoUrl || undefined,
  252. labels,
  253. meta: {
  254. title: sample.title,
  255. number: sample.number,
  256. customerNumber: sample.customerNumber,
  257. date: sample.date,
  258. due: sample.due,
  259. },
  260. items: sample.items.map((item) => ({
  261. n: String(item.n),
  262. qty: item.qty,
  263. unit: item.unit,
  264. desc: item.desc,
  265. sub: item.sku,
  266. price: item.price,
  267. total: item.total,
  268. })),
  269. parts: sample.items
  270. .filter((item) => item.sku)
  271. .map((item) => ({
  272. ref: item.sku as string,
  273. desc: item.desc,
  274. qty: item.qty,
  275. price: item.price,
  276. total: item.total,
  277. })),
  278. labor: sample.items
  279. .filter((item) => !item.sku)
  280. .map((item) => ({
  281. desc: item.desc,
  282. qty: `${item.qty} ${item.unit}`,
  283. rate: item.price,
  284. total: item.total,
  285. })),
  286. findings: sample.findings,
  287. totals: [
  288. { label: L('subtotal', 'Subtotal'), value: sample.subtotal, kind: 'line' as const },
  289. ...tax.lines,
  290. { label: L('total', 'Total'), value: tax.total, kind: 'total' as const },
  291. // A settled invoice, so the payment line and the paid stamp can be
  292. // seen and styled. Quotes never carry payments, so theirs ends at the
  293. // total.
  294. ...(docType === 'invoice'
  295. ? [
  296. {
  297. label: `${sample.date} (Visa)`,
  298. value: `-${tax.total}`,
  299. kind: 'payment' as const,
  300. },
  301. { label: L('paidInFull', 'PAID IN FULL'), value: '', kind: 'paid' as const },
  302. ]
  303. : []),
  304. ],
  305. notes: { html: sample.notes },
  306. // Stand-ins for files a job carries, so the block can be found, placed
  307. // and styled. Filenames need no translating.
  308. attachedDocuments: [
  309. fillTemplate(L('seeAppendedPages', '{name} (see appended pages)'), {
  310. name: 'inspection-report.pdf',
  311. }),
  312. 'tire-photos.jpg',
  313. ],
  314. warranty: { duration: sample.warranty },
  315. payment: [
  316. { id: 'bank_account', label: L('bankAccount', 'Bank Account'), value: values.bank_account },
  317. {
  318. id: 'org_number',
  319. label: L('orgNumberLabel', 'Org. Number'),
  320. value: values.org_number || `${L('org', 'Org: {org}').replace('{org}', '123 456 789')}`,
  321. },
  322. {
  323. // The workshop's own terms, or a stand-in when they have written none,
  324. // so the row is something to see and switch rather than an absence.
  325. // The stand-in prints as nothing, which is why the canvas marks it.
  326. id: 'payment_terms',
  327. label: L('paymentTermsLabel', 'Payment Terms'),
  328. value: workshop.paymentTerms?.trim() || t('sample.paymentTerms'),
  329. },
  330. { id: 'due_date', label: L('dueDateLabel', 'Due Date'), value: sample.due },
  331. // The workshop's registration for each of its taxes, where it has one;
  332. // the printed sheet carries these the same way, after the rows above.
  333. ...tax.registrations,
  334. ],
  335. // A stand-in link, so the canvas shows the portal line the printed sheet
  336. // carries and the footer's switch for it has something to switch.
  337. portalUrl: 'https://example.com/portal/a1b2c3',
  338. sectionLabels: {
  339. customer: L('billTo', 'Bill To'),
  340. vehicle: L('vehicle', 'Vehicle'),
  341. service: L('service', 'Service'),
  342. bank_account: L('paymentInformation', 'Payment Information'),
  343. general: L('customFieldsTitle', 'Additional Information'),
  344. findings: L('findings', 'Observations'),
  345. },
  346. }
  347. }