invoiceLayoutSchema.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  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. /** Body text size in points. Headings scale with it. */
  40. fontSize: z.number().min(5).max(24).optional(),
  41. /** Typeface for this section, from the families the app embeds. */
  42. fontFamily: z.string().optional(),
  43. /** Preferred width in points for a block that hangs to one side of the
  44. * sheet, like the totals box. Ignored in a column, which sets the width. */
  45. width: z.number().min(140).max(515).optional(),
  46. /** Where the section sets its mark and lines. Unset follows the header
  47. * style: compact leans left, standard right, modern centers. */
  48. align: z.enum(['left', 'center', 'right']).optional(),
  49. /** Where a logo sits when the section prints one, on its own rather than
  50. * with the section's text: a footer mark can sit hard left under a margin
  51. * while the closing line stays centered. Unset centers it. */
  52. logoAlign: z.enum(['left', 'center', 'right']).optional(),
  53. /** Room inside the section's panel, in points. Unset keeps each panel's
  54. * own default, box or no box. */
  55. padding: z.number().min(0).max(40).optional(),
  56. /** Extra room around the section in the flow, in points per edge. */
  57. marginTop: z.number().min(0).max(120).optional(),
  58. marginBottom: z.number().min(0).max(120).optional(),
  59. marginLeft: z.number().min(0).max(200).optional(),
  60. marginRight: z.number().min(0).max(200).optional(),
  61. })
  62. export const invoiceSectionSchema = z.object({
  63. id: z.string(),
  64. visible: z.boolean(),
  65. order: z.number().int(),
  66. /** When set, the section renders in a 2-column row alongside other column sections. */
  67. column: z.enum(['left', 'right']).optional(),
  68. /**
  69. * Whether the section prints inside a panel. Unset means boxed, which is what
  70. * every layout did before the choice existed.
  71. */
  72. boxed: z.boolean().optional(),
  73. /**
  74. * A named preset look for sections that offer several, the way the payment
  75. * panel can print as an accent card, a plain panel, an outline or bare
  76. * lines. Unset means the section's default.
  77. */
  78. variant: z.string().optional(),
  79. /**
  80. * Whether the section prints its own small heading, like BILL TO over the
  81. * customer card. Unset means shown, which every layout has always done.
  82. */
  83. heading: z.boolean().optional(),
  84. /** Appearance overrides for this section. Unset uses the document's own. */
  85. style: invoiceSectionStyleSchema.optional(),
  86. /** Controls which fields are shown within this section. */
  87. fields: z.array(invoiceFieldConfigSchema).optional(),
  88. })
  89. /**
  90. * Appearance that belongs to the whole sheet rather than to one section.
  91. *
  92. * Lives in the layout alongside the sections for the same reason their styles
  93. * do: a workshop sets it, and no setting key, no plumbing through every PDF
  94. * builder and no translations have to be written for each new option.
  95. */
  96. export const invoiceDocumentStyleSchema = z.object({
  97. /** Base text size in points. Everything else scales from it. */
  98. fontSize: z.number().min(6).max(14).optional(),
  99. /** Vertical padding in a table row, in points. Lower is denser. */
  100. rowPadding: z.number().min(0).max(12).optional(),
  101. /** Page margin in points. The framed sheet keeps its own top and left. */
  102. margin: z.number().min(12).max(72).optional(),
  103. /** Banding behind alternate table rows. False prints them all the same. */
  104. stripes: z.boolean().optional(),
  105. /** The band's color when stripes are on. */
  106. stripeColor: z.string().optional(),
  107. /** Section headings and the rule above the total. Defaults to the primary. */
  108. accentColor: z.string().optional(),
  109. /** Typeface for the whole sheet, from the families the app embeds. */
  110. fontFamily: z.string().optional(),
  111. })
  112. /**
  113. * Where something sits once it has been dragged out of the flow.
  114. *
  115. * Keyed by node id, which is a section id for a whole block and an element id
  116. * for one piece of it, so a logo and a customer panel are positioned by exactly
  117. * the same mechanism. Coordinates are points from the top-left of the sheet.
  118. */
  119. export const anchorSchema = z.object({
  120. x: z.number(),
  121. y: z.number(),
  122. width: z.number().optional(),
  123. page: z.number().int().min(1).optional(),
  124. })
  125. export const invoiceLayoutConfigSchema = z.object({
  126. sections: z.array(invoiceSectionSchema),
  127. /** Whole-sheet appearance. Unset leaves every default in place. */
  128. document: invoiceDocumentStyleSchema.optional(),
  129. /** Anything positioned by hand, keyed by node id. */
  130. anchors: z.record(z.string(), anchorSchema).optional(),
  131. /**
  132. * Which era saved this layout. Absent means the layout predates the
  133. * full-screen designer (or was never saved at all), and the print keeps the
  134. * classic look those organizations have always mailed out.
  135. */
  136. version: z.number().int().optional(),
  137. })
  138. /** Stamped on every layout the designer saves. */
  139. export const DESIGNER_LAYOUT_VERSION = 2
  140. /**
  141. * Whether this layout was saved from the full-screen designer. Anything else,
  142. * including no saved layout at all, keeps the classic pre-designer rendering
  143. * so a deploy never restyles an organization's documents behind its back.
  144. */
  145. export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
  146. return (config?.version ?? 1) >= DESIGNER_LAYOUT_VERSION
  147. }
  148. // ---------------------------------------------------------------------------
  149. // TypeScript types (derived from Zod)
  150. // ---------------------------------------------------------------------------
  151. export type InvoiceSectionStyle = z.infer<typeof invoiceSectionStyleSchema>
  152. export type InvoiceDocumentStyle = z.infer<typeof invoiceDocumentStyleSchema>
  153. export type InvoiceAnchor = z.infer<typeof anchorSchema>
  154. export type InvoiceFieldConfig = z.infer<typeof invoiceFieldConfigSchema>
  155. export type InvoiceSection = z.infer<typeof invoiceSectionSchema>
  156. export type InvoiceLayoutConfig = z.infer<typeof invoiceLayoutConfigSchema>
  157. // ---------------------------------------------------------------------------
  158. // Custom field ID helpers
  159. // ---------------------------------------------------------------------------
  160. export const CUSTOM_FIELD_PREFIX = 'cf_'
  161. export function isCustomFieldId(id: string): boolean {
  162. return id.startsWith(CUSTOM_FIELD_PREFIX)
  163. }
  164. export function toCustomFieldId(definitionId: string): string {
  165. return `${CUSTOM_FIELD_PREFIX}${definitionId}`
  166. }
  167. export function fromCustomFieldId(cfId: string): string {
  168. return cfId.slice(CUSTOM_FIELD_PREFIX.length)
  169. }
  170. // ---------------------------------------------------------------------------
  171. // Constants – built-in section & field definitions
  172. // ---------------------------------------------------------------------------
  173. export const BUILTIN_SECTIONS = [
  174. { id: 'header', name: 'Header' },
  175. // Its own section, not a line inside the header, so it can be placed,
  176. // paired and styled like anything else on the sheet.
  177. { id: 'slogan', name: 'Slogan' },
  178. { id: 'customer', name: 'Customer' },
  179. { id: 'vehicle', name: 'Vehicle' },
  180. { id: 'service', name: 'Service' },
  181. { id: 'document_title', name: 'Document Title' },
  182. { id: 'items_table', name: 'Items Table' },
  183. { id: 'parts_table', name: 'Parts Table' },
  184. { id: 'labor_table', name: 'Labor Table' },
  185. { id: 'findings', name: 'Findings' },
  186. { id: 'totals', name: 'Totals' },
  187. { id: 'notes', name: 'Notes' },
  188. // Its own section rather than a tail on the notes, so the list of what
  189. // rides along with the document can be placed and styled like anything else.
  190. { id: 'attached_documents', name: 'Attached Documents' },
  191. { id: 'warranty', name: 'Warranty' },
  192. { id: 'bank_account', name: 'Bank Account' },
  193. { id: 'footer', name: 'Footer' },
  194. { id: 'telegram_qr', name: 'Telegram QR' },
  195. { id: 'general', name: 'General' },
  196. ] as const
  197. export const BUILTIN_CUSTOMER_FIELDS = [
  198. { id: 'customer_name', name: 'Customer Name' },
  199. { id: 'customer_company', name: 'Customer Company' },
  200. { id: 'customer_address', name: 'Customer Address' },
  201. { id: 'customer_email', name: 'Customer Email' },
  202. { id: 'customer_phone', name: 'Customer Phone' },
  203. { id: 'customer_tax_id', name: 'Customer Tax ID' },
  204. ] as const
  205. export const BUILTIN_VEHICLE_FIELDS = [
  206. { id: 'vehicle_name', name: 'Vehicle' },
  207. { id: 'vin', name: 'VIN' },
  208. { id: 'license_plate', name: 'License Plate' },
  209. { id: 'mileage', name: 'Mileage' },
  210. ] as const
  211. export const BUILTIN_SERVICE_FIELDS = [
  212. { id: 'service_title', name: 'Service Title' },
  213. { id: 'service_type', name: 'Service Type' },
  214. { id: 'tech_name', name: 'Technician' },
  215. ] as const
  216. /** @deprecated Use BUILTIN_CUSTOMER_FIELDS, BUILTIN_VEHICLE_FIELDS, BUILTIN_SERVICE_FIELDS */
  217. export const BUILTIN_INFO_FIELDS = [
  218. ...BUILTIN_CUSTOMER_FIELDS,
  219. ...BUILTIN_VEHICLE_FIELDS,
  220. ...BUILTIN_SERVICE_FIELDS,
  221. ] as const
  222. export const BUILTIN_HEADER_FIELDS = [
  223. { id: 'logo', name: 'Logo' },
  224. { id: 'company_name', name: 'Company Name' },
  225. { id: 'company_address', name: 'Address' },
  226. { id: 'company_phone', name: 'Phone' },
  227. { id: 'company_email', name: 'Email' },
  228. { id: 'company_org_number', name: 'Organization Number' },
  229. ] as const
  230. /**
  231. * Company details a workshop can move down to the footer, the way printed
  232. * stationery carries them: the shop up top, the ways to reach it along the
  233. * bottom. All off by default, so a footer stays the one line it has always
  234. * been until somebody asks for more.
  235. */
  236. export const BUILTIN_FOOTER_FIELDS = [
  237. { id: 'footer_note', name: 'Footer Note' },
  238. // The customer's link to their portal. On by default because the sheet has
  239. // always printed it when a portal link exists; now the footer can decline.
  240. { id: 'portal_link', name: 'Portal Link' },
  241. // Off unless asked for, the way the rest of the footer details are: a shop
  242. // that wants its mark at the foot of the page as well as the top can say so.
  243. { id: 'logo', name: 'Logo' },
  244. { id: 'company_name', name: 'Company Name' },
  245. { id: 'company_address', name: 'Address' },
  246. { id: 'company_phone', name: 'Phone' },
  247. { id: 'company_email', name: 'Email' },
  248. { id: 'bank_account', name: 'Bank Account' },
  249. { id: 'company_org_number', name: 'Organization Number' },
  250. ] as const
  251. export const BUILTIN_BANK_ACCOUNT_FIELDS = [
  252. { id: 'bank_account', name: 'Bank Account' },
  253. { id: 'org_number', name: 'Organization Number' },
  254. ] as const
  255. /** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
  256. export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
  257. /**
  258. * The footer's detail lines flowed top-to-bottom into up to three columns, in
  259. * the order given, so the stored field order is the order the sheet shows.
  260. * The generator and the designer's field list both read this, so the drag
  261. * order and the print agree on which line heads which column.
  262. */
  263. export function footerColumnsOf<T>(entries: T[]): T[][] {
  264. const colCount = Math.min(3, entries.length)
  265. if (!colCount) return []
  266. const rows = Math.ceil(entries.length / colCount)
  267. return Array.from({ length: colCount }, (_, c) => entries.slice(c * rows, (c + 1) * rows)).filter(
  268. (column) => column.length > 0
  269. )
  270. }
  271. export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
  272. export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
  273. export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
  274. export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]['id']
  275. export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
  276. export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
  277. export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
  278. export type BuiltinFooterFieldId = (typeof BUILTIN_FOOTER_FIELDS)[number]['id']
  279. /** Sections that have configurable fields */
  280. export const SECTIONS_WITH_FIELDS = new Set<string>([
  281. 'header',
  282. 'footer',
  283. 'customer',
  284. 'vehicle',
  285. 'service',
  286. 'bank_account',
  287. 'general',
  288. ])
  289. /** Sections that print inside a panel and can have it taken away. */
  290. export const BOXED_ELIGIBLE_SECTIONS = new Set<string>([
  291. 'customer',
  292. 'vehicle',
  293. 'service',
  294. 'general',
  295. 'notes',
  296. 'attached_documents',
  297. 'warranty',
  298. 'telegram_qr',
  299. ])
  300. /** Sections that can be placed in left/right columns */
  301. export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
  302. 'slogan',
  303. 'totals',
  304. 'customer',
  305. 'vehicle',
  306. 'service',
  307. 'general',
  308. 'notes',
  309. 'attached_documents',
  310. 'bank_account',
  311. ])
  312. /** Sections that MUST be full-width (cannot be in columns) */
  313. export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
  314. 'header',
  315. 'document_title',
  316. 'items_table',
  317. 'parts_table',
  318. 'labor_table',
  319. 'footer',
  320. 'telegram_qr',
  321. ])
  322. /** Default column assignment for column-eligible sections */
  323. const DEFAULT_COLUMN: Record<string, 'left' | 'right'> = {
  324. customer: 'left',
  325. vehicle: 'left',
  326. service: 'right',
  327. }
  328. // ---------------------------------------------------------------------------
  329. // Defaults
  330. // ---------------------------------------------------------------------------
  331. function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | undefined {
  332. switch (sectionId) {
  333. case 'customer':
  334. return BUILTIN_CUSTOMER_FIELDS.map((f) => ({ id: f.id, visible: true }))
  335. case 'vehicle':
  336. return BUILTIN_VEHICLE_FIELDS.map((f) => ({ id: f.id, visible: true }))
  337. case 'service':
  338. return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }))
  339. case 'header':
  340. return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
  341. case 'bank_account':
  342. return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
  343. case 'footer':
  344. // Only the note and the portal link, which is the footer every existing
  345. // invoice already has.
  346. return BUILTIN_FOOTER_FIELDS.map((f) => ({
  347. id: f.id,
  348. visible: f.id === 'footer_note' || f.id === 'portal_link',
  349. }))
  350. case 'general':
  351. return [] // no built-in fields, only custom fields
  352. default:
  353. return undefined
  354. }
  355. }
  356. /**
  357. * Sections a workshop has to switch on before they appear.
  358. *
  359. * `items_table` is one because it replaces the separate parts and labor tables
  360. * rather than joining them, and `document_title` because the standard headers
  361. * already print the title themselves.
  362. */
  363. const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
  364. 'general',
  365. 'telegram_qr',
  366. 'items_table',
  367. 'document_title',
  368. ])
  369. export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
  370. return {
  371. sections: BUILTIN_SECTIONS.map((s, index) => {
  372. const fields = getDefaultFieldsForSection(s.id)
  373. const column = DEFAULT_COLUMN[s.id]
  374. return {
  375. id: s.id,
  376. visible: !HIDDEN_BY_DEFAULT_SECTIONS.has(s.id),
  377. order: index,
  378. ...(column ? { column } : {}),
  379. ...(fields ? { fields } : {}),
  380. }
  381. }),
  382. }
  383. }
  384. /** A section's appearance overrides, or nothing if it has none. */
  385. export function getSectionStyle(
  386. config: InvoiceLayoutConfig | undefined | null,
  387. sectionId: string
  388. ): InvoiceSectionStyle | undefined {
  389. const style = config?.sections.find((s) => s.id === sectionId)?.style
  390. // An empty object is the same as none, and saves the renderer a clone.
  391. return style && Object.values(style).some((v) => v !== undefined && v !== '') ? style : undefined
  392. }
  393. // ---------------------------------------------------------------------------
  394. // Letterhead mark
  395. // ---------------------------------------------------------------------------
  396. export type LetterheadMark = 'logo' | 'company_name'
  397. /**
  398. * Which of the two the header band carries. Layouts that show neither, or that
  399. * have no header fields at all, read as the logo, which is what every header
  400. * has always preferred.
  401. */
  402. export function getLetterheadMark(config: InvoiceLayoutConfig | undefined | null): LetterheadMark {
  403. const fields = config?.sections.find((s) => s.id === 'header')?.fields
  404. if (!fields) return 'logo'
  405. return fields.find((f) => f.id === 'logo')?.visible === false ? 'company_name' : 'logo'
  406. }
  407. /**
  408. * Flip the band from one mark to the other. It sets both fields, because the
  409. * band shows one and leaving the other visible would only mislead whoever opens
  410. * the layout editor next.
  411. */
  412. export function withLetterheadMark(
  413. config: InvoiceLayoutConfig,
  414. mark: LetterheadMark
  415. ): InvoiceLayoutConfig {
  416. return {
  417. sections: config.sections.map((section) => {
  418. if (section.id !== 'header') return section
  419. const fields = section.fields ?? getDefaultFieldsForSection('header') ?? []
  420. return {
  421. ...section,
  422. fields: fields.map((field) =>
  423. field.id === 'logo'
  424. ? { ...field, visible: mark === 'logo' }
  425. : field.id === 'company_name'
  426. ? { ...field, visible: mark === 'company_name' }
  427. : field
  428. ),
  429. }
  430. }),
  431. }
  432. }
  433. // ---------------------------------------------------------------------------
  434. // Field lookup helpers (for rendering)
  435. // ---------------------------------------------------------------------------
  436. /** Get all built-in field definitions for a section */
  437. export function getBuiltinFieldsForSection(
  438. sectionId: string
  439. ): ReadonlyArray<{ id: string; name: string }> {
  440. switch (sectionId) {
  441. case 'customer':
  442. return BUILTIN_CUSTOMER_FIELDS
  443. case 'vehicle':
  444. return BUILTIN_VEHICLE_FIELDS
  445. case 'service':
  446. return BUILTIN_SERVICE_FIELDS
  447. case 'header':
  448. return BUILTIN_HEADER_FIELDS
  449. case 'bank_account':
  450. return BUILTIN_BANK_ACCOUNT_FIELDS
  451. case 'footer':
  452. return BUILTIN_FOOTER_FIELDS
  453. default:
  454. return []
  455. }
  456. }
  457. /** Get the display name for a built-in field across all sections */
  458. export function getBuiltinFieldName(fieldId: string): string | undefined {
  459. const allFields = [
  460. ...BUILTIN_CUSTOMER_FIELDS,
  461. ...BUILTIN_VEHICLE_FIELDS,
  462. ...BUILTIN_SERVICE_FIELDS,
  463. ...BUILTIN_HEADER_FIELDS,
  464. ...BUILTIN_BANK_ACCOUNT_FIELDS,
  465. ...BUILTIN_FOOTER_FIELDS,
  466. ]
  467. return allFields.find((f) => f.id === fieldId)?.name
  468. }
  469. /**
  470. * Make a hidden-but-drawn section real at the position it is drawn in.
  471. *
  472. * The one such section is the title strip the generator borrows under the
  473. * header when Document Title is switched off: on screen it sits right after
  474. * the header, while the hidden section's stored order points somewhere else
  475. * entirely. A designer gesture that references it resolves against this, so
  476. * the drop lands where the canvas showed it. A visible section passes
  477. * through untouched.
  478. */
  479. export function materializeHiddenSection(
  480. config: InvoiceLayoutConfig,
  481. refId: string | null
  482. ): InvoiceLayoutConfig {
  483. if (!refId) return config
  484. const ref = config.sections.find((s) => s.id === refId)
  485. if (!ref || ref.visible) return config
  486. const ordered = [...config.sections]
  487. .sort((a, b) => a.order - b.order)
  488. .filter((s) => s.id !== refId)
  489. const headerAt = ordered.findIndex((s) => s.id === 'header')
  490. ordered.splice(headerAt + 1, 0, { ...ref, visible: true })
  491. return { ...config, sections: ordered.map((s, i) => ({ ...s, order: i })) }
  492. }
  493. // ---------------------------------------------------------------------------
  494. // Merge helper – fills in missing sections/fields with defaults
  495. // ---------------------------------------------------------------------------
  496. export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceLayoutConfig {
  497. const defaults = getDefaultInvoiceLayout()
  498. if (!saved.sections || saved.sections.length === 0) {
  499. return saved.version !== undefined ? { ...defaults, version: saved.version } : defaults
  500. }
  501. // Migrate old format: split "info" into customer/vehicle/service
  502. const migrated = migrateFromLegacy(saved.sections)
  503. const merged: InvoiceSection[] = []
  504. const seen = new Set<string>()
  505. for (const section of migrated) {
  506. // Skip duplicate section IDs (keep first occurrence)
  507. if (seen.has(section.id)) continue
  508. seen.add(section.id)
  509. const defaultFields = getDefaultFieldsForSection(section.id)
  510. if (defaultFields) {
  511. merged.push({
  512. ...section,
  513. fields: mergeSectionFields(section.fields, defaultFields),
  514. })
  515. } else {
  516. merged.push(section)
  517. }
  518. }
  519. // Append any new built-in sections that are missing from saved.
  520. // Insert each after its natural predecessor from the default order,
  521. // so e.g. "findings" lands after "labor_table" instead of at the end.
  522. const defaultOrder = defaults.sections.map((s) => s.id)
  523. const toInsert: { section: InvoiceSection; afterIdx: number }[] = []
  524. for (const def of defaults.sections) {
  525. if (seen.has(def.id)) continue
  526. const defaultIdx = defaultOrder.indexOf(def.id)
  527. let insertAfterIdx = -1
  528. for (let i = defaultIdx - 1; i >= 0; i--) {
  529. const idx = merged.findIndex((s) => s.id === defaultOrder[i])
  530. if (idx !== -1) {
  531. insertAfterIdx = idx
  532. break
  533. }
  534. }
  535. toInsert.push({ section: def, afterIdx: insertAfterIdx })
  536. }
  537. if (toInsert.length > 0) {
  538. // Insert in reverse so indices stay stable
  539. toInsert.sort((a, b) => b.afterIdx - a.afterIdx)
  540. for (const { section, afterIdx } of toInsert) {
  541. merged.splice(afterIdx + 1, 0, { ...section, order: 0 })
  542. }
  543. // Renumber all orders as clean integers
  544. for (let i = 0; i < merged.length; i++) {
  545. merged[i] = { ...merged[i], order: i }
  546. }
  547. }
  548. // Auto-assign column values to column-eligible sections if none have columns
  549. const hasAnyColumn = merged.some((s) => s.column)
  550. if (!hasAnyColumn) {
  551. for (const section of merged) {
  552. if (DEFAULT_COLUMN[section.id]) {
  553. section.column = DEFAULT_COLUMN[section.id]
  554. }
  555. }
  556. }
  557. return {
  558. sections: merged,
  559. ...(saved.document ? { document: saved.document } : {}),
  560. ...(saved.anchors ? { anchors: saved.anchors } : {}),
  561. ...(saved.version !== undefined ? { version: saved.version } : {}),
  562. }
  563. }
  564. // ---------------------------------------------------------------------------
  565. // Legacy migration: "info" → customer/vehicle/service,
  566. // "custom_fields" → "general"
  567. // ---------------------------------------------------------------------------
  568. const CUSTOMER_FIELD_IDS: Set<string> = new Set(BUILTIN_CUSTOMER_FIELDS.map((f) => f.id))
  569. const VEHICLE_FIELD_IDS: Set<string> = new Set(BUILTIN_VEHICLE_FIELDS.map((f) => f.id))
  570. const SERVICE_FIELD_IDS: Set<string> = new Set(BUILTIN_SERVICE_FIELDS.map((f) => f.id))
  571. function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
  572. const hasInfo = sections.some((s) => s.id === 'info')
  573. const hasCustomFields = sections.some((s) => s.id === 'custom_fields')
  574. // Already in new format
  575. if (!hasInfo && !hasCustomFields) {
  576. return sections
  577. }
  578. const result: InvoiceSection[] = []
  579. for (const section of sections) {
  580. if (section.id === 'info') {
  581. // Split into customer, vehicle, service
  582. const customerFields: InvoiceFieldConfig[] = []
  583. const vehicleFields: InvoiceFieldConfig[] = []
  584. const serviceFields: InvoiceFieldConfig[] = []
  585. const customFieldRefs: InvoiceFieldConfig[] = []
  586. if (section.fields) {
  587. for (const field of section.fields) {
  588. if (CUSTOMER_FIELD_IDS.has(field.id)) {
  589. customerFields.push(field)
  590. } else if (VEHICLE_FIELD_IDS.has(field.id)) {
  591. vehicleFields.push(field)
  592. } else if (SERVICE_FIELD_IDS.has(field.id)) {
  593. serviceFields.push(field)
  594. } else if (isCustomFieldId(field.id)) {
  595. customFieldRefs.push(field)
  596. }
  597. }
  598. }
  599. // Use the info section's order as base, insert three sections
  600. const baseOrder = section.order
  601. result.push({
  602. id: 'customer',
  603. visible: section.visible,
  604. order: baseOrder,
  605. fields: customerFields.length > 0 ? customerFields : undefined,
  606. })
  607. result.push({
  608. id: 'vehicle',
  609. visible: section.visible,
  610. order: baseOrder + 0.1,
  611. fields: vehicleFields.length > 0 ? vehicleFields : undefined,
  612. })
  613. result.push({
  614. id: 'service',
  615. visible: section.visible,
  616. order: baseOrder + 0.2,
  617. fields: serviceFields.length > 0 ? serviceFields : undefined,
  618. })
  619. // If the old info section had custom fields, add them to general
  620. if (customFieldRefs.length > 0) {
  621. result.push({
  622. id: 'general',
  623. visible: section.visible,
  624. order: baseOrder + 0.3,
  625. fields: customFieldRefs,
  626. })
  627. }
  628. } else if (section.id === 'custom_fields') {
  629. // Rename to general, keep any cf_ field references
  630. result.push({
  631. ...section,
  632. id: 'general',
  633. })
  634. } else {
  635. result.push(section)
  636. }
  637. }
  638. // Normalize order values to integers
  639. result.sort((a, b) => a.order - b.order)
  640. result.forEach((s, i) => {
  641. s.order = i
  642. })
  643. return result
  644. }
  645. // ---------------------------------------------------------------------------
  646. // Field merge helper
  647. // ---------------------------------------------------------------------------
  648. // ---------------------------------------------------------------------------
  649. // Rendering helper – groups sections into full-width or 2-column rows
  650. // ---------------------------------------------------------------------------
  651. export type RenderGroup =
  652. | { type: 'full-width'; sectionId: string }
  653. | { type: 'columns'; left: string[]; right: string[] }
  654. /**
  655. * Groups sorted visible sections for rendering.
  656. * Consecutive column-assigned sections are grouped into a single 2-column row.
  657. * Sections without a column render as full-width.
  658. */
  659. export function groupSectionsForRendering(sections: InvoiceSection[]): RenderGroup[] {
  660. // Deduplicate by section ID (keep first occurrence by order)
  661. const seen = new Set<string>()
  662. const sorted = [...sections]
  663. .filter((s) => s.visible)
  664. .sort((a, b) => a.order - b.order)
  665. .filter((s) => {
  666. if (seen.has(s.id)) return false
  667. seen.add(s.id)
  668. return true
  669. })
  670. const groups: RenderGroup[] = []
  671. let pendingLeft: string[] = []
  672. let pendingRight: string[] = []
  673. const flushColumns = () => {
  674. if (pendingLeft.length > 0 || pendingRight.length > 0) {
  675. groups.push({ type: 'columns', left: [...pendingLeft], right: [...pendingRight] })
  676. pendingLeft = []
  677. pendingRight = []
  678. }
  679. }
  680. for (const section of sorted) {
  681. if (section.column === 'left' || section.column === 'right') {
  682. if (section.column === 'left') pendingLeft.push(section.id)
  683. else pendingRight.push(section.id)
  684. } else {
  685. flushColumns()
  686. groups.push({ type: 'full-width', sectionId: section.id })
  687. }
  688. }
  689. flushColumns()
  690. return groups
  691. }
  692. // ---------------------------------------------------------------------------
  693. // Shared field-ordering helper
  694. // ---------------------------------------------------------------------------
  695. /**
  696. * Returns field IDs in the order specified by a layout config's visible fields Set.
  697. * The Set's iteration order reflects the layout config ordering.
  698. * Falls back to `defaults` when no config is provided.
  699. */
  700. export function getOrderedFieldIds(
  701. visibleFields: Set<string> | null | undefined,
  702. defaults: string[]
  703. ): string[] {
  704. if (!visibleFields) return defaults
  705. // Set iteration order = insertion order = layout config order
  706. const ordered = [...visibleFields].filter((id) => !isCustomFieldId(id))
  707. return ordered.length > 0 ? ordered : defaults
  708. }
  709. /**
  710. * Returns a Set of visible field IDs for a given section, preserving field order.
  711. * Returns null if no layout config is present (meaning show all fields).
  712. */
  713. export function getVisibleFieldsForSection(
  714. layoutConfig: InvoiceLayoutConfig | undefined | null,
  715. sectionId: string
  716. ): Set<string> | null {
  717. if (!layoutConfig) return null
  718. const section = layoutConfig.sections.find((s) => s.id === sectionId)
  719. if (!section?.fields) return null
  720. return new Set(section.fields.filter((f) => f.visible).map((f) => f.id))
  721. }
  722. // ---------------------------------------------------------------------------
  723. // Field merge helper
  724. // ---------------------------------------------------------------------------
  725. function mergeSectionFields(
  726. savedFields: InvoiceFieldConfig[] | undefined,
  727. defaults: InvoiceFieldConfig[]
  728. ): InvoiceFieldConfig[] {
  729. if (!savedFields || savedFields.length === 0) {
  730. return defaults
  731. }
  732. const seen = new Set<string>()
  733. const merged: InvoiceFieldConfig[] = []
  734. for (const field of savedFields) {
  735. seen.add(field.id)
  736. merged.push(field)
  737. }
  738. // Append any missing default fields
  739. for (const def of defaults) {
  740. if (!seen.has(def.id)) {
  741. merged.push(def)
  742. }
  743. }
  744. return merged
  745. }