invoiceLayoutSchema.ts 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428
  1. import { z } from 'zod'
  2. // ---------------------------------------------------------------------------
  3. // Zod schemas
  4. // ---------------------------------------------------------------------------
  5. export const invoiceFieldConfigSchema = z.object({
  6. id: z.string(),
  7. visible: z.boolean(),
  8. /**
  9. * An explicit weight for this line. Unset keeps the section's automatic
  10. * emphasis (the first line of a panel, a footer column's lead); the moment
  11. * any field in a section carries a choice, only the choices apply.
  12. */
  13. bold: z.boolean().optional(),
  14. })
  15. /**
  16. * How one section looks. Five keys that mean the same thing wherever they are
  17. * applied, so one control in the editor styles a detail panel, a table or the
  18. * totals without knowing which it is.
  19. *
  20. * This is where new appearance options belong. A setting per option meant a
  21. * code change, plumbing through every PDF builder and twelve translations for
  22. * each one; here a workshop sets it and nothing has to be written at all.
  23. */
  24. export const invoiceSectionStyleSchema = z.object({
  25. /** Body text: values, table cells, notes. */
  26. textColor: z.string().optional(),
  27. /** The section's own heading, and a table's column headings. */
  28. labelColor: z.string().optional(),
  29. /** Panel fill, or the bar behind a table's column headings. */
  30. backgroundColor: z.string().optional(),
  31. /** Panel border, and the rule between table rows. */
  32. borderColor: z.string().optional(),
  33. /** Thickness of the panel border and of table row rules, in points. */
  34. borderWidth: z.number().min(0).max(4).optional(),
  35. /** Draw a border around the whole table, not only rules between rows. */
  36. outerBorder: z.boolean().optional(),
  37. /** Banding behind alternate rows for this table. Unset follows the sheet. */
  38. stripes: z.boolean().optional(),
  39. /** Rows set close together, for a table that should take less of the page. */
  40. dense: z.boolean().optional(),
  41. /** Body text size in points. Headings scale with it. */
  42. fontSize: z.number().min(5).max(24).optional(),
  43. /** Typeface for this section, from the families the app embeds. */
  44. fontFamily: z.string().optional(),
  45. /** Preferred width in points for a block that hangs to one side of the
  46. * sheet, like the totals box. Ignored in a column, which sets the width. */
  47. width: z.number().min(140).max(515).optional(),
  48. /** Where the section sets its mark and lines. Unset follows the header
  49. * style: compact leans left, standard right, modern centers. */
  50. align: z.enum(['left', 'center', 'right']).optional(),
  51. /** Where a logo sits when the section prints one, on its own rather than
  52. * with the section's text: a footer mark can sit hard left under a margin
  53. * while the closing line stays centered. Unset centers it. */
  54. logoAlign: z.enum(['left', 'center', 'right']).optional(),
  55. /** Room inside the section's panel, in points. Unset keeps each panel's
  56. * own default, box or no box. */
  57. padding: z.number().min(0).max(40).optional(),
  58. /** Extra room around the section in the flow, in points per edge. */
  59. marginTop: z.number().min(0).max(120).optional(),
  60. marginBottom: z.number().min(0).max(120).optional(),
  61. marginLeft: z.number().min(0).max(200).optional(),
  62. marginRight: z.number().min(0).max(200).optional(),
  63. })
  64. export const invoiceSectionSchema = z.object({
  65. id: z.string(),
  66. visible: z.boolean(),
  67. order: z.number().int(),
  68. /** When set, the section renders in a 2-column row alongside other column sections. */
  69. column: z.enum(['left', 'right']).optional(),
  70. /**
  71. * Whether the section prints inside a panel. Unset means boxed, which is what
  72. * every layout did before the choice existed.
  73. */
  74. boxed: z.boolean().optional(),
  75. /**
  76. * A named preset look for sections that offer several, the way the payment
  77. * panel can print as an accent card, a plain panel, an outline or bare
  78. * lines. Unset means the section's default.
  79. */
  80. variant: z.string().optional(),
  81. /**
  82. * Whether the section prints its own small heading, like BILL TO over the
  83. * customer card. Unset means shown, which every layout has always done.
  84. */
  85. heading: z.boolean().optional(),
  86. /**
  87. * Words this section prints in place of the ones the app would print. The
  88. * title strip is the section that has any: what a sheet must call itself is
  89. * a matter of local law and local habit, not of translation, and a business
  90. * that has to print "Tax Invoice" cannot wait for a release to do it.
  91. * Blank or unset prints the document's own name in the reader's language.
  92. */
  93. text: z.string().max(60).optional(),
  94. /**
  95. * How a table of lines is divided. `category` prints the part lines under
  96. * a heading per category with a subtotal for each, the way a long bill is
  97. * easier to read when brakes, service and tyres are added up on their own.
  98. * Only the items and parts tables offer it; labor lines have no category
  99. * and print after the parts in their own group. Unset prints one flat list.
  100. */
  101. groupBy: z.enum(['category']).optional(),
  102. /** Appearance overrides for this section. Unset uses the document's own. */
  103. style: invoiceSectionStyleSchema.optional(),
  104. /** Controls which fields are shown within this section. */
  105. fields: z.array(invoiceFieldConfigSchema).optional(),
  106. })
  107. /**
  108. * Appearance that belongs to the whole sheet rather than to one section.
  109. *
  110. * Lives in the layout alongside the sections for the same reason their styles
  111. * do: a workshop sets it, and no setting key, no plumbing through every PDF
  112. * builder and no translations have to be written for each new option.
  113. */
  114. export const invoiceDocumentStyleSchema = z.object({
  115. /** Base text size in points. Everything else scales from it. */
  116. fontSize: z.number().min(6).max(14).optional(),
  117. /** Vertical padding in a table row, in points. Lower is denser. */
  118. rowPadding: z.number().min(0).max(12).optional(),
  119. /** Page margin in points. The framed sheet keeps its own top and left. */
  120. margin: z.number().min(12).max(72).optional(),
  121. /** Banding behind alternate table rows. False prints them all the same. */
  122. stripes: z.boolean().optional(),
  123. /** The band's color when stripes are on. */
  124. stripeColor: z.string().optional(),
  125. /** Section headings and the rule above the total. Defaults to the primary. */
  126. accentColor: z.string().optional(),
  127. /** Typeface for the whole sheet, from the families the app embeds. */
  128. fontFamily: z.string().optional(),
  129. })
  130. /**
  131. * Where something sits once it has been dragged out of the flow.
  132. *
  133. * Keyed by node id, which is a section id for a whole block and an element id
  134. * for one piece of it, so a logo and a customer panel are positioned by exactly
  135. * the same mechanism. Coordinates are points from the top-left of the sheet.
  136. */
  137. export const anchorSchema = z.object({
  138. x: z.number(),
  139. y: z.number(),
  140. width: z.number().optional(),
  141. page: z.number().int().min(1).optional(),
  142. })
  143. export const invoiceLayoutConfigSchema = z.object({
  144. sections: z.array(invoiceSectionSchema),
  145. /** Whole-sheet appearance. Unset leaves every default in place. */
  146. document: invoiceDocumentStyleSchema.optional(),
  147. /** Anything positioned by hand, keyed by node id. */
  148. anchors: z.record(z.string(), anchorSchema).optional(),
  149. /**
  150. * Which era saved this layout. Absent means the layout predates the
  151. * full-screen designer (or was never saved at all), and the print keeps the
  152. * classic look those organizations have always mailed out.
  153. */
  154. version: z.number().int().optional(),
  155. /**
  156. * Which document this arranges. Absent means an invoice or a quote, which
  157. * share one section list; a certificate has sections of its own, so its
  158. * layout has to say so to be merged with the right defaults.
  159. */
  160. documentType: z.enum(['invoice', 'quote', 'certificate', 'work_order']).optional(),
  161. })
  162. /** Stamped on every layout the designer saves. */
  163. export const DESIGNER_LAYOUT_VERSION = 3
  164. /**
  165. * The stamp that means "saved from the full-screen designer". It is not the
  166. * version the designer writes: that one moves whenever a layout needs reading
  167. * differently, and moving this with it would drop every organization back to
  168. * the classic sheet.
  169. */
  170. const DESIGNER_ERA_VERSION = 2
  171. /**
  172. * The first version whose Document Title switch is obeyed. Below it, a hidden
  173. * title strip was drawn anyway, under the header, so hiding it there was never
  174. * a choice to keep.
  175. */
  176. const HONEST_TITLE_SWITCH_VERSION = 3
  177. /**
  178. * Whether this layout was saved from the full-screen designer. Anything else,
  179. * including no saved layout at all, keeps the classic pre-designer rendering
  180. * so a deploy never restyles an organization's documents behind its back.
  181. */
  182. export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
  183. return (config?.version ?? 1) >= DESIGNER_ERA_VERSION
  184. }
  185. // ---------------------------------------------------------------------------
  186. // TypeScript types (derived from Zod)
  187. // ---------------------------------------------------------------------------
  188. export type InvoiceSectionStyle = z.infer<typeof invoiceSectionStyleSchema>
  189. export type InvoiceDocumentStyle = z.infer<typeof invoiceDocumentStyleSchema>
  190. export type InvoiceAnchor = z.infer<typeof anchorSchema>
  191. export type InvoiceFieldConfig = z.infer<typeof invoiceFieldConfigSchema>
  192. export type InvoiceSection = z.infer<typeof invoiceSectionSchema>
  193. export type InvoiceLayoutConfig = z.infer<typeof invoiceLayoutConfigSchema>
  194. // ---------------------------------------------------------------------------
  195. // Custom field ID helpers
  196. // ---------------------------------------------------------------------------
  197. export const CUSTOM_FIELD_PREFIX = 'cf_'
  198. export function isCustomFieldId(id: string): boolean {
  199. return id.startsWith(CUSTOM_FIELD_PREFIX)
  200. }
  201. export function toCustomFieldId(definitionId: string): string {
  202. return `${CUSTOM_FIELD_PREFIX}${definitionId}`
  203. }
  204. export function fromCustomFieldId(cfId: string): string {
  205. return cfId.slice(CUSTOM_FIELD_PREFIX.length)
  206. }
  207. // ---------------------------------------------------------------------------
  208. // Constants – built-in section & field definitions
  209. // ---------------------------------------------------------------------------
  210. export const BUILTIN_SECTIONS = [
  211. { id: 'header', name: 'Header' },
  212. // Directly under the header, which is where the strip carrying the number
  213. // and the date has always been drawn.
  214. { id: 'document_title', name: 'Document Title' },
  215. // Its own section, not a line inside the header, so it can be placed,
  216. // paired and styled like anything else on the sheet.
  217. { id: 'slogan', name: 'Slogan' },
  218. { id: 'customer', name: 'Customer' },
  219. { id: 'vehicle', name: 'Vehicle' },
  220. { id: 'service', name: 'Service' },
  221. { id: 'items_table', name: 'Items Table' },
  222. { id: 'parts_table', name: 'Parts Table' },
  223. { id: 'labor_table', name: 'Labor Table' },
  224. { id: 'findings', name: 'Findings' },
  225. { id: 'totals', name: 'Totals' },
  226. { id: 'notes', name: 'Notes' },
  227. // Its own section rather than a tail on the notes, so the list of what
  228. // rides along with the document can be placed and styled like anything else.
  229. { id: 'attached_documents', name: 'Attached Documents' },
  230. { id: 'warranty', name: 'Warranty' },
  231. { id: 'bank_account', name: 'Bank Account' },
  232. // What the inspection linked to the job, or the one a quote was raised
  233. // from, found: the certificate's own two blocks. In front of the condition
  234. // map, so the inspection's findings and the body's sit together as the
  235. // appendix. Off until a workshop wants the check's results on the bill.
  236. { id: 'defects', name: 'Defects' },
  237. { id: 'results_table', name: 'All Results' },
  238. // The car's condition this visit: the job's drop-off and the inspection
  239. // linked to it. Last, as an appendix: the bill first, then the record of
  240. // the dents that were already there. Off until a workshop wants it on
  241. // every invoice; a single job asks for it from its drop-off tab.
  242. { id: 'condition_map', name: 'Vehicle Condition' },
  243. // Above the footer, where a signature goes on paper. Off until a workshop
  244. // switches it on, so no sheet gains a signature line by a deploy.
  245. { id: 'signature', name: 'Signature' },
  246. { id: 'footer', name: 'Footer' },
  247. { id: 'telegram_qr', name: 'Telegram QR' },
  248. { id: 'general', name: 'General' },
  249. ] as const
  250. /**
  251. * What a completed inspection prints. The letterhead, title strip, customer
  252. * and vehicle panels, notes, attachments and footer are the shared ones; the
  253. * rest is the test: what it found, in the order a reader wants it.
  254. */
  255. export const CERTIFICATE_SECTIONS = [
  256. { id: 'header', name: 'Header' },
  257. { id: 'document_title', name: 'Document Title' },
  258. { id: 'slogan', name: 'Slogan' },
  259. { id: 'result', name: 'Result' },
  260. { id: 'customer', name: 'Customer' },
  261. { id: 'vehicle', name: 'Vehicle' },
  262. { id: 'test_details', name: 'Test Details' },
  263. { id: 'defects', name: 'Defects' },
  264. { id: 'results_table', name: 'All Results' },
  265. { id: 'condition_map', name: 'Vehicle Condition' },
  266. { id: 'inspection_photos', name: 'Photos' },
  267. { id: 'notes', name: 'Notes' },
  268. { id: 'attached_documents', name: 'Attached Documents' },
  269. { id: 'signature', name: 'Signature' },
  270. { id: 'footer', name: 'Footer' },
  271. ] as const
  272. /**
  273. * What a work order prints: the sheet a customer signs at the counter and the
  274. * technician takes off the board. The invoice's sections, less what belongs
  275. * to a bill (bank details, payments), plus the facts of the job, the
  276. * customer's concerns, a checklist of the work and a code that opens the
  277. * job on a phone. The results of the inspection linked to the job can ride
  278. * along, in the two sections the certificate prints them with.
  279. */
  280. export const WORK_ORDER_SECTIONS = [
  281. { id: 'header', name: 'Header' },
  282. { id: 'document_title', name: 'Document Title' },
  283. { id: 'slogan', name: 'Slogan' },
  284. { id: 'customer', name: 'Customer' },
  285. { id: 'vehicle', name: 'Vehicle' },
  286. { id: 'service', name: 'Service' },
  287. { id: 'job_details', name: 'Job Details' },
  288. { id: 'job_qr', name: 'Open on Phone' },
  289. { id: 'concerns', name: 'Customer Concerns' },
  290. { id: 'job_description', name: 'Work Requested' },
  291. { id: 'condition_map', name: 'Vehicle Condition' },
  292. // What the inspection linked to the job found, drawn by the certificate's
  293. // own blocks. Off until a workshop wants the check's results on the sheet.
  294. { id: 'defects', name: 'Defects' },
  295. { id: 'results_table', name: 'All Results' },
  296. { id: 'work_checklist', name: 'Work Checklist' },
  297. { id: 'items_table', name: 'Items Table' },
  298. { id: 'parts_table', name: 'Parts Table' },
  299. { id: 'labor_table', name: 'Labor Table' },
  300. { id: 'findings', name: 'Findings' },
  301. { id: 'totals', name: 'Totals' },
  302. { id: 'notes', name: 'Notes' },
  303. { id: 'attached_documents', name: 'Attached Documents' },
  304. { id: 'warranty', name: 'Warranty' },
  305. { id: 'general', name: 'General' },
  306. { id: 'signature', name: 'Signature' },
  307. { id: 'footer', name: 'Footer' },
  308. ] as const
  309. export type LayoutDocumentType = 'invoice' | 'quote' | 'certificate' | 'work_order'
  310. /** The sections a document is built from. */
  311. export function sectionsFor(
  312. documentType: LayoutDocumentType | undefined
  313. ): ReadonlyArray<{ id: string; name: string }> {
  314. if (documentType === 'certificate') return CERTIFICATE_SECTIONS
  315. if (documentType === 'work_order') return WORK_ORDER_SECTIONS
  316. return BUILTIN_SECTIONS
  317. }
  318. /**
  319. * The document a layout arranges. Absent means an invoice or a quote, which
  320. * share one section list; the others have to say so.
  321. */
  322. export function layoutDocumentType(
  323. saved: Pick<Partial<InvoiceLayoutConfig>, 'documentType'> | null | undefined
  324. ): LayoutDocumentType {
  325. if (saved?.documentType === 'certificate') return 'certificate'
  326. if (saved?.documentType === 'work_order') return 'work_order'
  327. return 'invoice'
  328. }
  329. export const BUILTIN_CUSTOMER_FIELDS = [
  330. { id: 'customer_name', name: 'Customer Name' },
  331. { id: 'customer_company', name: 'Customer Company' },
  332. { id: 'customer_address', name: 'Customer Address' },
  333. { id: 'customer_email', name: 'Customer Email' },
  334. { id: 'customer_phone', name: 'Customer Phone' },
  335. { id: 'customer_tax_id', name: 'Customer Tax ID' },
  336. ] as const
  337. export const BUILTIN_VEHICLE_FIELDS = [
  338. { id: 'vehicle_name', name: 'Vehicle' },
  339. { id: 'vin', name: 'VIN' },
  340. { id: 'license_plate', name: 'License Plate' },
  341. { id: 'mileage', name: 'Mileage' },
  342. // The German type approval key. On by default: it prints nothing until a
  343. // vehicle has one, and a workshop that records it wants it on the paper.
  344. { id: 'hsn_tsn', name: 'HSN/TSN' },
  345. ] as const
  346. export const BUILTIN_SERVICE_FIELDS = [
  347. { id: 'service_title', name: 'Service Title' },
  348. { id: 'service_type', name: 'Service Type' },
  349. { id: 'tech_name', name: 'Technician' },
  350. ] as const
  351. /** @deprecated Use BUILTIN_CUSTOMER_FIELDS, BUILTIN_VEHICLE_FIELDS, BUILTIN_SERVICE_FIELDS */
  352. export const BUILTIN_INFO_FIELDS = [
  353. ...BUILTIN_CUSTOMER_FIELDS,
  354. ...BUILTIN_VEHICLE_FIELDS,
  355. ...BUILTIN_SERVICE_FIELDS,
  356. ] as const
  357. export const BUILTIN_HEADER_FIELDS = [
  358. { id: 'logo', name: 'Logo' },
  359. { id: 'company_name', name: 'Company Name' },
  360. { id: 'company_address', name: 'Address' },
  361. { id: 'company_phone', name: 'Phone' },
  362. { id: 'company_email', name: 'Email' },
  363. { id: 'company_org_number', name: 'Organization Number' },
  364. ] as const
  365. /**
  366. * Company details a workshop can move down to the footer, the way printed
  367. * stationery carries them: the shop up top, the ways to reach it along the
  368. * bottom. All off by default, so a footer stays the one line it has always
  369. * been until somebody asks for more.
  370. */
  371. export const BUILTIN_FOOTER_FIELDS = [
  372. { id: 'footer_note', name: 'Footer Note' },
  373. // The customer's link to their portal. On by default because the sheet has
  374. // always printed it when a portal link exists; now the footer can decline.
  375. { id: 'portal_link', name: 'Portal Link' },
  376. // Off unless asked for, the way the rest of the footer details are: a shop
  377. // that wants its mark at the foot of the page as well as the top can say so.
  378. { id: 'logo', name: 'Logo' },
  379. { id: 'company_name', name: 'Company Name' },
  380. { id: 'company_address', name: 'Address' },
  381. { id: 'company_phone', name: 'Phone' },
  382. { id: 'company_email', name: 'Email' },
  383. { id: 'bank_account', name: 'Bank Account' },
  384. { id: 'company_org_number', name: 'Organization Number' },
  385. ] as const
  386. /**
  387. * The title strip: the big word, and the cells beside it. Each is a switch,
  388. * because what a document must name differs by trade and by country, and the
  389. * strip is where a sheet says its number and its dates.
  390. */
  391. export const BUILTIN_DOCUMENT_TITLE_FIELDS = [
  392. { id: 'title', name: 'Title' },
  393. { id: 'invoice_number', name: 'Number' },
  394. { id: 'customer_number', name: 'Customer Number' },
  395. { id: 'date', name: 'Date' },
  396. { id: 'due_date', name: 'Due Date' },
  397. // The plate as a cell of its own, large enough to read a work order off a
  398. // board. Off everywhere but the work order until a design asks for it.
  399. { id: 'license_plate', name: 'License Plate' },
  400. ] as const
  401. /**
  402. * The facts of a job that an invoice never prints: where it stands, who has
  403. * it, when it is booked and when the customer was promised it.
  404. */
  405. export const BUILTIN_JOB_DETAILS_FIELDS = [
  406. { id: 'status', name: 'Status' },
  407. { id: 'technician', name: 'Technician' },
  408. { id: 'scheduled', name: 'Scheduled' },
  409. { id: 'promised', name: 'Promised' },
  410. { id: 'work_bay', name: 'Work Bay' },
  411. ] as const
  412. export const BUILTIN_BANK_ACCOUNT_FIELDS = [
  413. { id: 'bank_account', name: 'Bank Account' },
  414. { id: 'org_number', name: 'Organization Number' },
  415. { id: 'payment_terms', name: 'Payment Terms' },
  416. { id: 'due_date', name: 'Due Date' },
  417. ] as const
  418. /** The facts Annex IV wants on a certificate, each one a switch. */
  419. export const BUILTIN_TEST_DETAILS_FIELDS = [
  420. { id: 'test_date', name: 'Date of test' },
  421. { id: 'test_location', name: 'Place of test' },
  422. { id: 'inspector', name: 'Inspector' },
  423. { id: 'certificate_number', name: 'Certificate number' },
  424. { id: 'vehicle_category', name: 'Vehicle category' },
  425. { id: 'odometer', name: 'Odometer' },
  426. { id: 'next_test_due', name: 'Next test due' },
  427. ] as const
  428. /** What the result band says under the verdict. */
  429. export const BUILTIN_RESULT_FIELDS = [
  430. { id: 'result_detail', name: 'Explanation' },
  431. { id: 'result_summary', name: 'Counts' },
  432. ] as const
  433. /** What each defect carries beside its grade. */
  434. export const BUILTIN_DEFECTS_FIELDS = [
  435. { id: 'defect_notes', name: 'Notes' },
  436. { id: 'defect_photos', name: 'Photos' },
  437. /**
  438. * The line a car with nothing wrong prints under the heading. Off, a clean
  439. * inspection prints no defects section at all.
  440. */
  441. { id: 'no_defects_note', name: 'Note when there are no defects' },
  442. ] as const
  443. /**
  444. * What the signature block draws: the signer's saved signature, the line,
  445. * whether the name is printed under it, and the date. The line and name ids
  446. * are from when only certificates were signed, by an inspector; they are kept
  447. * so saved layouts still mean what they did.
  448. */
  449. export const BUILTIN_SIGNATURE_FIELDS = [
  450. { id: 'signature_image', name: 'Signature' },
  451. { id: 'inspector_line', name: 'Signature line' },
  452. { id: 'inspector_name', name: "Signer's name" },
  453. { id: 'date_line', name: 'Date line' },
  454. // A second, empty line for the customer's pen, beside the issuer's. On by
  455. // default only on the work order, which is the sheet a customer signs.
  456. { id: 'customer_line', name: 'Customer signature line' },
  457. ] as const
  458. /** What the condition map prints beside the drawing. */
  459. export const BUILTIN_CONDITION_MAP_FIELDS = [
  460. { id: 'legend', name: 'Legend' },
  461. /** Marks still open from earlier visits, drawn in grey. */
  462. { id: 'previous_marks', name: 'Earlier marks' },
  463. /**
  464. * The empty drawing with the key of kinds and ruled rows, printed when the
  465. * job has no marks yet: a form for a walk-round done with a pen. Only the
  466. * work order offers it, and only when a workshop switches it on.
  467. */
  468. { id: 'blank_sheet', name: 'Blank sheet to fill in by hand' },
  469. ] as const
  470. /** Which rows the full results table prints beyond the defects. */
  471. export const BUILTIN_RESULTS_TABLE_FIELDS = [
  472. { id: 'passed_checks', name: 'Passed checks' },
  473. { id: 'not_applicable_checks', name: 'Not applicable checks' },
  474. { id: 'check_notes', name: 'Notes column' },
  475. /** Every check in one table with a section column, instead of a table per section. */
  476. { id: 'combined_table', name: 'One table' },
  477. /**
  478. * The checks nobody has graded yet, in their places with the grade left
  479. * empty: an inspection that has not started prints as a sheet to fill in.
  480. */
  481. { id: 'ungraded_checks', name: 'Checks not graded yet' },
  482. ] as const
  483. /**
  484. * Fields that printed before they had a switch, by section.
  485. *
  486. * A field added to a section is off in every layout saved before it existed,
  487. * which is right for a line that never printed and wrong for one that always
  488. * did: switches arrived for the payment panel's terms and due date long after
  489. * the rows themselves, and defaulting them off would have quietly stripped
  490. * them from every existing design. A layout that does not mention them shows
  491. * them; switching one off is a choice somebody made, and is stored.
  492. */
  493. export const GRANDFATHERED_FIELDS: Record<string, readonly string[]> = {
  494. bank_account: ['payment_terms', 'due_date'],
  495. document_title: ['title', 'invoice_number', 'customer_number', 'date', 'due_date'],
  496. // "No deficiencies were recorded." printed on every clean certificate
  497. // before a design could leave it out.
  498. defects: ['no_defects_note'],
  499. }
  500. /** The ids of `sectionId`'s grandfathered fields that `fields` never mentions. */
  501. export function unmentionedGrandfathered(
  502. sectionId: string,
  503. fields: ReadonlyArray<{ id: string }> | undefined
  504. ): string[] {
  505. const ids = GRANDFATHERED_FIELDS[sectionId]
  506. if (!ids || !fields) return []
  507. return ids.filter((id) => !fields.some((f) => f.id === id))
  508. }
  509. /** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
  510. export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
  511. /**
  512. * The footer's detail lines flowed top-to-bottom into up to three columns, in
  513. * the order given, so the stored field order is the order the sheet shows.
  514. * The generator and the designer's field list both read this, so the drag
  515. * order and the print agree on which line heads which column.
  516. */
  517. export function footerColumnsOf<T>(entries: T[]): T[][] {
  518. const colCount = Math.min(3, entries.length)
  519. if (!colCount) return []
  520. const rows = Math.ceil(entries.length / colCount)
  521. return Array.from({ length: colCount }, (_, c) => entries.slice(c * rows, (c + 1) * rows)).filter(
  522. (column) => column.length > 0
  523. )
  524. }
  525. export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
  526. export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
  527. export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
  528. export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]['id']
  529. export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
  530. export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
  531. export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
  532. export type BuiltinDocumentTitleFieldId = (typeof BUILTIN_DOCUMENT_TITLE_FIELDS)[number]['id']
  533. export type BuiltinFooterFieldId = (typeof BUILTIN_FOOTER_FIELDS)[number]['id']
  534. /** Sections that have configurable fields */
  535. export const SECTIONS_WITH_FIELDS = new Set<string>([
  536. 'header',
  537. 'document_title',
  538. 'footer',
  539. 'customer',
  540. 'vehicle',
  541. 'service',
  542. 'bank_account',
  543. 'general',
  544. 'test_details',
  545. 'result',
  546. 'signature',
  547. 'defects',
  548. 'results_table',
  549. 'job_details',
  550. 'condition_map',
  551. ])
  552. /** Sections with fields of their own that print no workshop-defined fields. */
  553. export const NO_CUSTOM_FIELD_SECTIONS = new Set<string>(['condition_map'])
  554. /** Sections that print inside a panel and can have it taken away. */
  555. export const BOXED_ELIGIBLE_SECTIONS = new Set<string>([
  556. 'customer',
  557. 'vehicle',
  558. 'service',
  559. 'general',
  560. 'notes',
  561. 'attached_documents',
  562. 'warranty',
  563. 'telegram_qr',
  564. 'test_details',
  565. 'signature',
  566. 'result',
  567. 'job_details',
  568. 'job_qr',
  569. 'concerns',
  570. 'job_description',
  571. 'work_checklist',
  572. ])
  573. /** Sections that can be placed in left/right columns */
  574. /**
  575. * Fields the sheet prints in a place of its own, whatever order the list is
  576. * in: the company name above the header's details and the logo in its corner,
  577. * the word the document calls itself above the title strip's cells, and the
  578. * footer's portal line first with its closing note last.
  579. *
  580. * They are named here so the inspector can decline to offer a drag it cannot
  581. * honour. Dragging them did nothing at all, which reads as a broken editor
  582. * rather than as a slot that is fixed on purpose.
  583. * `src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts` works
  584. * out which fields actually ignore their position and holds this list to it,
  585. * so the offer and the print can never drift apart.
  586. */
  587. export const FIXED_SLOT_FIELDS: Record<string, readonly string[]> = {
  588. header: ['logo', 'company_name'],
  589. document_title: ['title'],
  590. footer: ['footer_note', 'portal_link', 'logo'],
  591. // The signature sits on its line, the name under it and the date beside:
  592. // where each goes is the shape of a signature block, not an order.
  593. signature: ['signature_image', 'inspector_line', 'inspector_name', 'date_line', 'customer_line'],
  594. // The inspection's two sections list switches, not rows: what a defect
  595. // carries and which kinds of check the table prints. None has a position.
  596. defects: ['defect_notes', 'defect_photos', 'no_defects_note'],
  597. results_table: [
  598. 'passed_checks',
  599. 'not_applicable_checks',
  600. 'check_notes',
  601. 'combined_table',
  602. 'ungraded_checks',
  603. ],
  604. }
  605. /** Whether this field prints where the list puts it, or in a slot of its own. */
  606. export function fieldHasFixedSlot(sectionId: string, fieldId: string): boolean {
  607. return (FIXED_SLOT_FIELDS[sectionId] ?? []).includes(fieldId)
  608. }
  609. export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
  610. 'slogan',
  611. 'totals',
  612. 'customer',
  613. 'vehicle',
  614. 'service',
  615. 'general',
  616. 'notes',
  617. 'attached_documents',
  618. 'bank_account',
  619. 'test_details',
  620. 'signature',
  621. 'result',
  622. 'job_details',
  623. 'job_qr',
  624. ])
  625. /** Sections that MUST be full-width (cannot be in columns) */
  626. export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
  627. 'header',
  628. 'document_title',
  629. 'items_table',
  630. 'parts_table',
  631. 'labor_table',
  632. 'footer',
  633. 'telegram_qr',
  634. 'defects',
  635. 'results_table',
  636. 'inspection_photos',
  637. 'concerns',
  638. 'job_description',
  639. 'work_checklist',
  640. 'condition_map',
  641. ])
  642. /** Default column assignment for column-eligible sections */
  643. const DEFAULT_COLUMN: Record<string, 'left' | 'right'> = {
  644. customer: 'left',
  645. vehicle: 'left',
  646. service: 'right',
  647. test_details: 'right',
  648. job_details: 'right',
  649. job_qr: 'right',
  650. }
  651. // ---------------------------------------------------------------------------
  652. // Defaults
  653. // ---------------------------------------------------------------------------
  654. function getDefaultFieldsForSection(
  655. sectionId: string,
  656. documentType: LayoutDocumentType = 'invoice'
  657. ): InvoiceFieldConfig[] | undefined {
  658. const workOrder = documentType === 'work_order'
  659. switch (sectionId) {
  660. // A work order says only what the technician and the customer need: who
  661. // the car belongs to and how to reach them, which car, and the job.
  662. case 'customer':
  663. return BUILTIN_CUSTOMER_FIELDS.map((f) => ({
  664. id: f.id,
  665. visible: workOrder ? f.id === 'customer_name' || f.id === 'customer_phone' : true,
  666. }))
  667. case 'vehicle':
  668. return BUILTIN_VEHICLE_FIELDS.map((f) => ({
  669. id: f.id,
  670. visible: workOrder ? f.id !== 'vin' : true,
  671. }))
  672. case 'service':
  673. return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }))
  674. case 'header':
  675. return BUILTIN_HEADER_FIELDS.map((f) => ({
  676. id: f.id,
  677. visible: workOrder ? f.id !== 'company_email' && f.id !== 'company_org_number' : true,
  678. }))
  679. case 'bank_account':
  680. return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
  681. case 'document_title':
  682. // The plate cell is the work order's; every other strip keeps the
  683. // cells it has always had.
  684. return BUILTIN_DOCUMENT_TITLE_FIELDS.map((f) => ({
  685. id: f.id,
  686. visible: workOrder
  687. ? f.id !== 'customer_number' && f.id !== 'due_date'
  688. : f.id !== 'license_plate',
  689. }))
  690. case 'job_details':
  691. return BUILTIN_JOB_DETAILS_FIELDS.map((f) => ({ id: f.id, visible: f.id !== 'work_bay' }))
  692. case 'condition_map':
  693. return builtinConditionMapFields(documentType).map((f) => ({
  694. id: f.id,
  695. visible: f.id !== 'blank_sheet',
  696. }))
  697. case 'footer':
  698. // Only the note and the portal link, which is the footer every existing
  699. // invoice already has. A work order is not sent, so no portal link.
  700. return BUILTIN_FOOTER_FIELDS.map((f) => ({
  701. id: f.id,
  702. visible: f.id === 'footer_note' || (f.id === 'portal_link' && !workOrder),
  703. }))
  704. case 'general':
  705. return [] // no built-in fields, only custom fields
  706. case 'test_details':
  707. return BUILTIN_TEST_DETAILS_FIELDS.map((f) => ({ id: f.id, visible: true }))
  708. case 'result':
  709. return BUILTIN_RESULT_FIELDS.map((f) => ({ id: f.id, visible: true }))
  710. case 'signature':
  711. // A work order is signed with a pen at the counter: the customer's
  712. // line is on and the issuer's saved image is off. Everywhere else the
  713. // customer line waits to be switched on.
  714. return BUILTIN_SIGNATURE_FIELDS.map((f) => ({
  715. id: f.id,
  716. visible:
  717. f.id === 'customer_line' ? workOrder : f.id === 'signature_image' ? !workOrder : true,
  718. }))
  719. case 'defects':
  720. return BUILTIN_DEFECTS_FIELDS.map((f) => ({ id: f.id, visible: true }))
  721. case 'results_table':
  722. // Only the checks that were not OK, with their notes, a table per
  723. // section: what a reader wants to know. Passed, not-applicable and
  724. // ungraded rows and the one-table form are switches a design turns on.
  725. return BUILTIN_RESULTS_TABLE_FIELDS.map((f) => ({
  726. id: f.id,
  727. visible: f.id === 'check_notes',
  728. }))
  729. default:
  730. return undefined
  731. }
  732. }
  733. /**
  734. * Sections a workshop has to switch on before they appear.
  735. *
  736. * `items_table` is one because it replaces the separate parts and labor tables
  737. * rather than joining them, and `document_title` because the standard headers
  738. * already print the title themselves. `signature` because most invoices and
  739. * quotes go out unsigned.
  740. */
  741. const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
  742. 'general',
  743. 'telegram_qr',
  744. 'items_table',
  745. 'signature',
  746. 'condition_map',
  747. 'defects',
  748. 'results_table',
  749. ])
  750. /**
  751. * Sections that join a saved invoice or quote design in front of the ones
  752. * that close the sheet, rather than after their neighbour in the default
  753. * order. The condition map is an appendix: after everything, before the
  754. * signing line and the footer. The inspection's results are part of the
  755. * same appendix and close the sheet the same way.
  756. */
  757. const INSERTS_BEFORE: Record<string, readonly string[]> = {
  758. condition_map: ['signature', 'footer'],
  759. defects: ['signature', 'footer'],
  760. results_table: ['signature', 'footer'],
  761. }
  762. /**
  763. * Sections that join a saved invoice or quote design directly in front of
  764. * another one, wherever a workshop has put it. The inspection's results go
  765. * in front of the condition map; a design saved before the map existed has
  766. * none, and the closing rule above places all three together.
  767. */
  768. const INSERTS_IN_FRONT_OF: Record<string, string> = {
  769. defects: 'condition_map',
  770. results_table: 'condition_map',
  771. }
  772. /** A signature line is a choice; most certificates are issued unsigned. */
  773. const HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS = new Set<string>(['slogan', 'signature'])
  774. /**
  775. * The work order prints as short as it can be: the customer, the car, the
  776. * job, the work and parts with their prices, and the lines to sign. Workshops
  777. * print these all day, so everything else waits to be switched on.
  778. */
  779. const HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS = new Set<string>([
  780. 'general',
  781. 'items_table',
  782. 'work_checklist',
  783. 'slogan',
  784. 'service',
  785. 'findings',
  786. 'attached_documents',
  787. 'warranty',
  788. // The customer's notes are the invoice's, and the code to scan is the
  789. // board copy's; each costs the room that keeps the counter copy on one page.
  790. 'notes',
  791. 'job_qr',
  792. // Status, technician and times are the board's business, not the counter's.
  793. 'job_details',
  794. // The letterhead: the customer signs at the counter of the shop whose
  795. // name is over the door, and the strip already says which sheet this is.
  796. 'header',
  797. // The linked inspection's findings have a sheet of their own, the
  798. // certificate. They join the work order only where a workshop asks.
  799. 'defects',
  800. 'results_table',
  801. ])
  802. /** The work order's panels print as bare lines: no fills, no boxes, no colour. */
  803. const PLAIN_WORK_ORDER_SECTIONS = new Set<string>([
  804. 'customer',
  805. 'vehicle',
  806. 'job_details',
  807. 'job_qr',
  808. 'concerns',
  809. 'job_description',
  810. 'work_checklist',
  811. 'notes',
  812. 'signature',
  813. ])
  814. /**
  815. * Black on white for a sheet printed all day long: no coloured accent, no
  816. * banding, no dark bar over the tables, small type on a narrow margin.
  817. */
  818. export const WORK_ORDER_DOCUMENT_STYLE: InvoiceDocumentStyle = {
  819. accentColor: '#111827',
  820. fontSize: 9,
  821. rowPadding: 3,
  822. margin: 32,
  823. stripes: false,
  824. }
  825. /** The colour and header the work order prints with until a design says otherwise. */
  826. export const WORK_ORDER_TEMPLATE_DEFAULTS = { primaryColor: '#111827', headerStyle: 'compact' }
  827. /** A table's column heads set in plain ink on white, with a hairline under each row. */
  828. const PLAIN_TABLE_STYLE: InvoiceSectionStyle = { backgroundColor: '#ffffff', borderWidth: 0.5 }
  829. export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
  830. return getDefaultLayout('invoice')
  831. }
  832. /** The default arrangement for a document: every section, in its built-in order. */
  833. export function getDefaultLayout(documentType: LayoutDocumentType): InvoiceLayoutConfig {
  834. const hidden =
  835. documentType === 'certificate'
  836. ? HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS
  837. : documentType === 'work_order'
  838. ? HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS
  839. : HIDDEN_BY_DEFAULT_SECTIONS
  840. return {
  841. sections: sectionsFor(documentType).map((s, index) => {
  842. const fields = getDefaultFieldsForSection(s.id, documentType)
  843. const workOrder = documentType === 'work_order'
  844. // The car beside the customer, not under it: two short panels on one row.
  845. const column = workOrder && s.id === 'vehicle' ? 'right' : DEFAULT_COLUMN[s.id]
  846. const plain = workOrder && PLAIN_WORK_ORDER_SECTIONS.has(s.id)
  847. const table = workOrder && (s.id === 'labor_table' || s.id === 'parts_table')
  848. return {
  849. id: s.id,
  850. visible: !hidden.has(s.id),
  851. order: index,
  852. ...(column ? { column } : {}),
  853. ...(plain ? { boxed: false } : {}),
  854. ...(table ? { style: { ...PLAIN_TABLE_STYLE } } : {}),
  855. ...(fields ? { fields } : {}),
  856. }
  857. }),
  858. ...(documentType === 'work_order' ? { document: { ...WORK_ORDER_DOCUMENT_STYLE } } : {}),
  859. ...(documentType === 'certificate' || documentType === 'work_order' ? { documentType } : {}),
  860. }
  861. }
  862. /** A section's appearance overrides, or nothing if it has none. */
  863. export function getSectionStyle(
  864. config: InvoiceLayoutConfig | undefined | null,
  865. sectionId: string
  866. ): InvoiceSectionStyle | undefined {
  867. const style = config?.sections.find((s) => s.id === sectionId)?.style
  868. // An empty object is the same as none, and saves the renderer a clone.
  869. return style && Object.values(style).some((v) => v !== undefined && v !== '') ? style : undefined
  870. }
  871. // ---------------------------------------------------------------------------
  872. // Letterhead mark
  873. // ---------------------------------------------------------------------------
  874. export type LetterheadMark = 'logo' | 'company_name'
  875. /**
  876. * Which of the two the header band carries. Layouts that show neither, or that
  877. * have no header fields at all, read as the logo, which is what every header
  878. * has always preferred.
  879. */
  880. export function getLetterheadMark(config: InvoiceLayoutConfig | undefined | null): LetterheadMark {
  881. const fields = config?.sections.find((s) => s.id === 'header')?.fields
  882. if (!fields) return 'logo'
  883. return fields.find((f) => f.id === 'logo')?.visible === false ? 'company_name' : 'logo'
  884. }
  885. /**
  886. * Flip the band from one mark to the other. It sets both fields, because the
  887. * band shows one and leaving the other visible would only mislead whoever opens
  888. * the layout editor next.
  889. */
  890. export function withLetterheadMark(
  891. config: InvoiceLayoutConfig,
  892. mark: LetterheadMark
  893. ): InvoiceLayoutConfig {
  894. return {
  895. sections: config.sections.map((section) => {
  896. if (section.id !== 'header') return section
  897. const fields = section.fields ?? getDefaultFieldsForSection('header') ?? []
  898. return {
  899. ...section,
  900. fields: fields.map((field) =>
  901. field.id === 'logo'
  902. ? { ...field, visible: mark === 'logo' }
  903. : field.id === 'company_name'
  904. ? { ...field, visible: mark === 'company_name' }
  905. : field
  906. ),
  907. }
  908. }),
  909. }
  910. }
  911. // ---------------------------------------------------------------------------
  912. // Field lookup helpers (for rendering)
  913. // ---------------------------------------------------------------------------
  914. /**
  915. * The condition map's fields on a document. Earlier visits' marks are the
  916. * work order's and the certificate's business: an invoice or a quote speaks
  917. * about this visit only, so it has no switch for them.
  918. */
  919. function builtinConditionMapFields(
  920. documentType: LayoutDocumentType
  921. ): ReadonlyArray<{ id: string; name: string }> {
  922. if (documentType === 'work_order') return BUILTIN_CONDITION_MAP_FIELDS
  923. // A certificate is a finished record and an invoice a bill: neither is a
  924. // form to write on.
  925. const printed = BUILTIN_CONDITION_MAP_FIELDS.filter((f) => f.id !== 'blank_sheet')
  926. return documentType === 'invoice' || documentType === 'quote'
  927. ? printed.filter((f) => f.id !== 'previous_marks')
  928. : printed
  929. }
  930. /** Get all built-in field definitions for a section, on a document of this type. */
  931. export function getBuiltinFieldsForSection(
  932. sectionId: string,
  933. documentType: LayoutDocumentType = 'invoice'
  934. ): ReadonlyArray<{ id: string; name: string }> {
  935. switch (sectionId) {
  936. case 'customer':
  937. return BUILTIN_CUSTOMER_FIELDS
  938. case 'vehicle':
  939. return BUILTIN_VEHICLE_FIELDS
  940. case 'service':
  941. return BUILTIN_SERVICE_FIELDS
  942. case 'header':
  943. return BUILTIN_HEADER_FIELDS
  944. case 'bank_account':
  945. return BUILTIN_BANK_ACCOUNT_FIELDS
  946. case 'document_title':
  947. return BUILTIN_DOCUMENT_TITLE_FIELDS
  948. case 'footer':
  949. return BUILTIN_FOOTER_FIELDS
  950. case 'test_details':
  951. return BUILTIN_TEST_DETAILS_FIELDS
  952. case 'result':
  953. return BUILTIN_RESULT_FIELDS
  954. case 'signature':
  955. return BUILTIN_SIGNATURE_FIELDS
  956. case 'defects':
  957. return BUILTIN_DEFECTS_FIELDS
  958. case 'results_table':
  959. return BUILTIN_RESULTS_TABLE_FIELDS
  960. case 'job_details':
  961. return BUILTIN_JOB_DETAILS_FIELDS
  962. case 'condition_map':
  963. return builtinConditionMapFields(documentType)
  964. default:
  965. return []
  966. }
  967. }
  968. /** Get the display name for a built-in field across all sections */
  969. export function getBuiltinFieldName(fieldId: string): string | undefined {
  970. const allFields = [
  971. ...BUILTIN_CUSTOMER_FIELDS,
  972. ...BUILTIN_VEHICLE_FIELDS,
  973. ...BUILTIN_SERVICE_FIELDS,
  974. ...BUILTIN_HEADER_FIELDS,
  975. ...BUILTIN_BANK_ACCOUNT_FIELDS,
  976. ...BUILTIN_DOCUMENT_TITLE_FIELDS,
  977. ...BUILTIN_FOOTER_FIELDS,
  978. ...BUILTIN_TEST_DETAILS_FIELDS,
  979. ...BUILTIN_RESULT_FIELDS,
  980. ...BUILTIN_SIGNATURE_FIELDS,
  981. ...BUILTIN_DEFECTS_FIELDS,
  982. ...BUILTIN_RESULTS_TABLE_FIELDS,
  983. ...BUILTIN_JOB_DETAILS_FIELDS,
  984. ...BUILTIN_CONDITION_MAP_FIELDS,
  985. ]
  986. return allFields.find((f) => f.id === fieldId)?.name
  987. }
  988. /**
  989. * Make a hidden-but-drawn section real at the position it is drawn in.
  990. *
  991. * The one such section is the title strip the generator borrows under the
  992. * header when Document Title is switched off: on screen it sits right after
  993. * the header, while the hidden section's stored order points somewhere else
  994. * entirely. A designer gesture that references it resolves against this, so
  995. * the drop lands where the canvas showed it. A visible section passes
  996. * through untouched.
  997. */
  998. export function materializeHiddenSection(
  999. config: InvoiceLayoutConfig,
  1000. refId: string | null
  1001. ): InvoiceLayoutConfig {
  1002. if (!refId) return config
  1003. const ref = config.sections.find((s) => s.id === refId)
  1004. if (!ref || ref.visible) return config
  1005. const ordered = [...config.sections]
  1006. .sort((a, b) => a.order - b.order)
  1007. .filter((s) => s.id !== refId)
  1008. const headerAt = ordered.findIndex((s) => s.id === 'header')
  1009. ordered.splice(headerAt + 1, 0, { ...ref, visible: true })
  1010. return { ...config, sections: ordered.map((s, i) => ({ ...s, order: i })) }
  1011. }
  1012. // ---------------------------------------------------------------------------
  1013. // Merge helper – fills in missing sections/fields with defaults
  1014. // ---------------------------------------------------------------------------
  1015. export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceLayoutConfig {
  1016. // A certificate or a work order layout says so and is filled in from its
  1017. // own sections; anything else is an invoice or a quote, which share theirs.
  1018. const documentType = layoutDocumentType(saved)
  1019. const defaults = getDefaultLayout(documentType)
  1020. if (!saved.sections || saved.sections.length === 0) {
  1021. return saved.version !== undefined ? { ...defaults, version: saved.version } : defaults
  1022. }
  1023. // Migrate old format: split "info" into customer/vehicle/service
  1024. const migrated = migrateFromLegacy(saved.sections)
  1025. const merged: InvoiceSection[] = []
  1026. const seen = new Set<string>()
  1027. for (const section of migrated) {
  1028. // Skip duplicate section IDs (keep first occurrence)
  1029. if (seen.has(section.id)) continue
  1030. seen.add(section.id)
  1031. const defaultFields = getDefaultFieldsForSection(section.id, documentType)
  1032. if (defaultFields) {
  1033. merged.push({
  1034. ...section,
  1035. fields: mergeSectionFields(section.fields, defaultFields),
  1036. })
  1037. } else {
  1038. merged.push(section)
  1039. }
  1040. }
  1041. // Append any new built-in sections that are missing from saved.
  1042. // Insert each after its natural predecessor from the default order,
  1043. // so e.g. "findings" lands after "labor_table" instead of at the end.
  1044. // An appendix instead goes in front of the sections that close the sheet,
  1045. // wherever a workshop has put those: the condition map printed after the
  1046. // signature in a design whose bank details sat below the signing line.
  1047. // The inspection's results go in front of the condition map itself.
  1048. const defaultOrder = defaults.sections.map((s) => s.id)
  1049. // Positions below are read off the list and the list is renumbered as it
  1050. // stands, so it has to be in the order the sheet prints first. A design
  1051. // built from a starting point and saved without moving a section keeps
  1052. // the built-in list with the starting point's numbers on it; left like
  1053. // that, the first section added afterwards put the whole sheet back in the
  1054. // built-in order, on every design and on every issued copy of one.
  1055. if (defaults.sections.some((def) => !seen.has(def.id))) {
  1056. merged.sort((a, b) => a.order - b.order)
  1057. }
  1058. const toInsert: { section: InvoiceSection; afterIdx: number; defaultIdx: number }[] = []
  1059. for (const def of defaults.sections) {
  1060. if (seen.has(def.id)) continue
  1061. const defaultIdx = defaultOrder.indexOf(def.id)
  1062. let insertAfterIdx = -1
  1063. // 'invoice' covers quotes too: they share one section list.
  1064. const invoice = documentType === 'invoice'
  1065. const neighbour = invoice ? INSERTS_IN_FRONT_OF[def.id] : undefined
  1066. const neighbourIdx = neighbour ? merged.findIndex((s) => s.id === neighbour) : -1
  1067. const closing = invoice ? INSERTS_BEFORE[def.id] : undefined
  1068. const closingIdx =
  1069. neighbourIdx !== -1
  1070. ? neighbourIdx
  1071. : closing
  1072. ? merged.findIndex((s) => closing.includes(s.id))
  1073. : -1
  1074. if (closingIdx !== -1) {
  1075. insertAfterIdx = closingIdx - 1
  1076. } else {
  1077. for (let i = defaultIdx - 1; i >= 0; i--) {
  1078. const idx = merged.findIndex((s) => s.id === defaultOrder[i])
  1079. if (idx !== -1) {
  1080. insertAfterIdx = idx
  1081. break
  1082. }
  1083. }
  1084. }
  1085. toInsert.push({ section: def, afterIdx: insertAfterIdx, defaultIdx })
  1086. }
  1087. if (toInsert.length > 0) {
  1088. // Insert in reverse so indices stay stable. Sections bound for the same
  1089. // spot go in latest-first too, so they come out in their default order
  1090. // rather than back to front.
  1091. toInsert.sort((a, b) => b.afterIdx - a.afterIdx || b.defaultIdx - a.defaultIdx)
  1092. for (const { section, afterIdx } of toInsert) {
  1093. merged.splice(afterIdx + 1, 0, { ...section, order: 0 })
  1094. }
  1095. // Renumber all orders as clean integers
  1096. for (let i = 0; i < merged.length; i++) {
  1097. merged[i] = { ...merged[i], order: i }
  1098. }
  1099. }
  1100. // Before the switch was obeyed, a hidden title strip was drawn anyway, under
  1101. // the header. Those layouts are read as showing it there, which is what they
  1102. // print today: without this, honouring the switch would take the number and
  1103. // the date off every sheet that never chose to lose them. The designer
  1104. // stamps the current version when it saves, so a workshop that switches the
  1105. // strip off from now on keeps it off.
  1106. // Placed by renumbering the whole run rather than by a fraction after the
  1107. // header: orders are whole numbers as far as the saved layout's schema is
  1108. // concerned, and a 0.5 that reached the designer was rejected on Save, which
  1109. // silently kept every such workshop on the layout they were trying to change.
  1110. if ((saved.version ?? 1) < HONEST_TITLE_SWITCH_VERSION) {
  1111. const title = merged.find((s) => s.id === 'document_title')
  1112. if (title && !title.visible) {
  1113. const rest = merged.filter((s) => s !== title).sort((a, b) => a.order - b.order)
  1114. const headerIdx = rest.findIndex((s) => s.id === 'header')
  1115. rest.splice(headerIdx + 1, 0, { ...title, visible: true })
  1116. merged.splice(0, merged.length, ...rest.map((s, i) => ({ ...s, order: i })))
  1117. }
  1118. }
  1119. // Auto-assign column values to column-eligible sections if none have columns
  1120. const hasAnyColumn = merged.some((s) => s.column)
  1121. if (!hasAnyColumn) {
  1122. for (const section of merged) {
  1123. if (DEFAULT_COLUMN[section.id]) {
  1124. section.column = DEFAULT_COLUMN[section.id]
  1125. }
  1126. }
  1127. }
  1128. return {
  1129. sections: merged,
  1130. ...(saved.document ? { document: saved.document } : {}),
  1131. ...(saved.anchors ? { anchors: saved.anchors } : {}),
  1132. ...(saved.version !== undefined ? { version: saved.version } : {}),
  1133. ...(documentType !== 'invoice' ? { documentType } : {}),
  1134. }
  1135. }
  1136. // ---------------------------------------------------------------------------
  1137. // Legacy migration: "info" → customer/vehicle/service,
  1138. // "custom_fields" → "general"
  1139. // ---------------------------------------------------------------------------
  1140. const CUSTOMER_FIELD_IDS: Set<string> = new Set(BUILTIN_CUSTOMER_FIELDS.map((f) => f.id))
  1141. const VEHICLE_FIELD_IDS: Set<string> = new Set(BUILTIN_VEHICLE_FIELDS.map((f) => f.id))
  1142. const SERVICE_FIELD_IDS: Set<string> = new Set(BUILTIN_SERVICE_FIELDS.map((f) => f.id))
  1143. function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
  1144. const hasInfo = sections.some((s) => s.id === 'info')
  1145. const hasCustomFields = sections.some((s) => s.id === 'custom_fields')
  1146. // Already in new format
  1147. if (!hasInfo && !hasCustomFields) {
  1148. return sections
  1149. }
  1150. const result: InvoiceSection[] = []
  1151. for (const section of sections) {
  1152. if (section.id === 'info') {
  1153. // Split into customer, vehicle, service
  1154. const customerFields: InvoiceFieldConfig[] = []
  1155. const vehicleFields: InvoiceFieldConfig[] = []
  1156. const serviceFields: InvoiceFieldConfig[] = []
  1157. const customFieldRefs: InvoiceFieldConfig[] = []
  1158. if (section.fields) {
  1159. for (const field of section.fields) {
  1160. if (CUSTOMER_FIELD_IDS.has(field.id)) {
  1161. customerFields.push(field)
  1162. } else if (VEHICLE_FIELD_IDS.has(field.id)) {
  1163. vehicleFields.push(field)
  1164. } else if (SERVICE_FIELD_IDS.has(field.id)) {
  1165. serviceFields.push(field)
  1166. } else if (isCustomFieldId(field.id)) {
  1167. customFieldRefs.push(field)
  1168. }
  1169. }
  1170. }
  1171. // Use the info section's order as base, insert three sections
  1172. const baseOrder = section.order
  1173. result.push({
  1174. id: 'customer',
  1175. visible: section.visible,
  1176. order: baseOrder,
  1177. fields: customerFields.length > 0 ? customerFields : undefined,
  1178. })
  1179. result.push({
  1180. id: 'vehicle',
  1181. visible: section.visible,
  1182. order: baseOrder + 0.1,
  1183. fields: vehicleFields.length > 0 ? vehicleFields : undefined,
  1184. })
  1185. result.push({
  1186. id: 'service',
  1187. visible: section.visible,
  1188. order: baseOrder + 0.2,
  1189. fields: serviceFields.length > 0 ? serviceFields : undefined,
  1190. })
  1191. // If the old info section had custom fields, add them to general
  1192. if (customFieldRefs.length > 0) {
  1193. result.push({
  1194. id: 'general',
  1195. visible: section.visible,
  1196. order: baseOrder + 0.3,
  1197. fields: customFieldRefs,
  1198. })
  1199. }
  1200. } else if (section.id === 'custom_fields') {
  1201. // Rename to general, keep any cf_ field references
  1202. result.push({
  1203. ...section,
  1204. id: 'general',
  1205. })
  1206. } else {
  1207. result.push(section)
  1208. }
  1209. }
  1210. // Normalize order values to integers
  1211. result.sort((a, b) => a.order - b.order)
  1212. result.forEach((s, i) => {
  1213. s.order = i
  1214. })
  1215. return result
  1216. }
  1217. // ---------------------------------------------------------------------------
  1218. // Field merge helper
  1219. // ---------------------------------------------------------------------------
  1220. // ---------------------------------------------------------------------------
  1221. // Rendering helper – groups sections into full-width or 2-column rows
  1222. // ---------------------------------------------------------------------------
  1223. export type RenderGroup =
  1224. | { type: 'full-width'; sectionId: string }
  1225. | { type: 'columns'; left: string[]; right: string[] }
  1226. /**
  1227. * Groups sorted visible sections for rendering.
  1228. * Consecutive column-assigned sections are grouped into a single 2-column row.
  1229. * Sections without a column render as full-width.
  1230. */
  1231. export function groupSectionsForRendering(sections: InvoiceSection[]): RenderGroup[] {
  1232. // Deduplicate by section ID (keep first occurrence by order)
  1233. const seen = new Set<string>()
  1234. const sorted = [...sections]
  1235. .filter((s) => s.visible)
  1236. .sort((a, b) => a.order - b.order)
  1237. .filter((s) => {
  1238. if (seen.has(s.id)) return false
  1239. seen.add(s.id)
  1240. return true
  1241. })
  1242. const groups: RenderGroup[] = []
  1243. let pendingLeft: string[] = []
  1244. let pendingRight: string[] = []
  1245. const flushColumns = () => {
  1246. if (pendingLeft.length > 0 || pendingRight.length > 0) {
  1247. groups.push({ type: 'columns', left: [...pendingLeft], right: [...pendingRight] })
  1248. pendingLeft = []
  1249. pendingRight = []
  1250. }
  1251. }
  1252. for (const section of sorted) {
  1253. if (section.column === 'left' || section.column === 'right') {
  1254. if (section.column === 'left') pendingLeft.push(section.id)
  1255. else pendingRight.push(section.id)
  1256. } else {
  1257. flushColumns()
  1258. groups.push({ type: 'full-width', sectionId: section.id })
  1259. }
  1260. }
  1261. flushColumns()
  1262. return groups
  1263. }
  1264. // ---------------------------------------------------------------------------
  1265. // Shared field-ordering helper
  1266. // ---------------------------------------------------------------------------
  1267. /**
  1268. * Returns field IDs in the order specified by a layout config's visible fields Set.
  1269. * The Set's iteration order reflects the layout config ordering.
  1270. * Falls back to `defaults` when no config is provided.
  1271. */
  1272. export function getOrderedFieldIds(
  1273. visibleFields: Set<string> | null | undefined,
  1274. defaults: string[]
  1275. ): string[] {
  1276. if (!visibleFields) return defaults
  1277. // Set iteration order = insertion order = layout config order
  1278. const ordered = [...visibleFields].filter((id) => !isCustomFieldId(id))
  1279. return ordered.length > 0 ? ordered : defaults
  1280. }
  1281. /**
  1282. * Returns a Set of visible field IDs for a given section, preserving field order.
  1283. * Returns null if no layout config is present (meaning show all fields).
  1284. */
  1285. export function getVisibleFieldsForSection(
  1286. layoutConfig: InvoiceLayoutConfig | undefined | null,
  1287. sectionId: string
  1288. ): Set<string> | null {
  1289. if (!layoutConfig) return null
  1290. const section = layoutConfig.sections.find((s) => s.id === sectionId)
  1291. if (!section?.fields) return null
  1292. return new Set(section.fields.filter((f) => f.visible).map((f) => f.id))
  1293. }
  1294. // ---------------------------------------------------------------------------
  1295. // Field merge helper
  1296. // ---------------------------------------------------------------------------
  1297. function mergeSectionFields(
  1298. savedFields: InvoiceFieldConfig[] | undefined,
  1299. defaults: InvoiceFieldConfig[]
  1300. ): InvoiceFieldConfig[] {
  1301. if (!savedFields || savedFields.length === 0) {
  1302. return defaults
  1303. }
  1304. const seen = new Set<string>()
  1305. const merged: InvoiceFieldConfig[] = []
  1306. for (const field of savedFields) {
  1307. seen.add(field.id)
  1308. merged.push(field)
  1309. }
  1310. // Append any missing default fields
  1311. for (const def of defaults) {
  1312. if (!seen.has(def.id)) {
  1313. merged.push(def)
  1314. }
  1315. }
  1316. return merged
  1317. }