| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428 |
- import { z } from 'zod'
- // ---------------------------------------------------------------------------
- // Zod schemas
- // ---------------------------------------------------------------------------
- export const invoiceFieldConfigSchema = z.object({
- id: z.string(),
- visible: z.boolean(),
- /**
- * An explicit weight for this line. Unset keeps the section's automatic
- * emphasis (the first line of a panel, a footer column's lead); the moment
- * any field in a section carries a choice, only the choices apply.
- */
- bold: z.boolean().optional(),
- })
- /**
- * How one section looks. Five keys that mean the same thing wherever they are
- * applied, so one control in the editor styles a detail panel, a table or the
- * totals without knowing which it is.
- *
- * This is where new appearance options belong. A setting per option meant a
- * code change, plumbing through every PDF builder and twelve translations for
- * each one; here a workshop sets it and nothing has to be written at all.
- */
- export const invoiceSectionStyleSchema = z.object({
- /** Body text: values, table cells, notes. */
- textColor: z.string().optional(),
- /** The section's own heading, and a table's column headings. */
- labelColor: z.string().optional(),
- /** Panel fill, or the bar behind a table's column headings. */
- backgroundColor: z.string().optional(),
- /** Panel border, and the rule between table rows. */
- borderColor: z.string().optional(),
- /** Thickness of the panel border and of table row rules, in points. */
- borderWidth: z.number().min(0).max(4).optional(),
- /** Draw a border around the whole table, not only rules between rows. */
- outerBorder: z.boolean().optional(),
- /** Banding behind alternate rows for this table. Unset follows the sheet. */
- stripes: z.boolean().optional(),
- /** Rows set close together, for a table that should take less of the page. */
- dense: z.boolean().optional(),
- /** Body text size in points. Headings scale with it. */
- fontSize: z.number().min(5).max(24).optional(),
- /** Typeface for this section, from the families the app embeds. */
- fontFamily: z.string().optional(),
- /** Preferred width in points for a block that hangs to one side of the
- * sheet, like the totals box. Ignored in a column, which sets the width. */
- width: z.number().min(140).max(515).optional(),
- /** Where the section sets its mark and lines. Unset follows the header
- * style: compact leans left, standard right, modern centers. */
- align: z.enum(['left', 'center', 'right']).optional(),
- /** Where a logo sits when the section prints one, on its own rather than
- * with the section's text: a footer mark can sit hard left under a margin
- * while the closing line stays centered. Unset centers it. */
- logoAlign: z.enum(['left', 'center', 'right']).optional(),
- /** Room inside the section's panel, in points. Unset keeps each panel's
- * own default, box or no box. */
- padding: z.number().min(0).max(40).optional(),
- /** Extra room around the section in the flow, in points per edge. */
- marginTop: z.number().min(0).max(120).optional(),
- marginBottom: z.number().min(0).max(120).optional(),
- marginLeft: z.number().min(0).max(200).optional(),
- marginRight: z.number().min(0).max(200).optional(),
- })
- export const invoiceSectionSchema = z.object({
- id: z.string(),
- visible: z.boolean(),
- order: z.number().int(),
- /** When set, the section renders in a 2-column row alongside other column sections. */
- column: z.enum(['left', 'right']).optional(),
- /**
- * Whether the section prints inside a panel. Unset means boxed, which is what
- * every layout did before the choice existed.
- */
- boxed: z.boolean().optional(),
- /**
- * A named preset look for sections that offer several, the way the payment
- * panel can print as an accent card, a plain panel, an outline or bare
- * lines. Unset means the section's default.
- */
- variant: z.string().optional(),
- /**
- * Whether the section prints its own small heading, like BILL TO over the
- * customer card. Unset means shown, which every layout has always done.
- */
- heading: z.boolean().optional(),
- /**
- * Words this section prints in place of the ones the app would print. The
- * title strip is the section that has any: what a sheet must call itself is
- * a matter of local law and local habit, not of translation, and a business
- * that has to print "Tax Invoice" cannot wait for a release to do it.
- * Blank or unset prints the document's own name in the reader's language.
- */
- text: z.string().max(60).optional(),
- /**
- * How a table of lines is divided. `category` prints the part lines under
- * a heading per category with a subtotal for each, the way a long bill is
- * easier to read when brakes, service and tyres are added up on their own.
- * Only the items and parts tables offer it; labor lines have no category
- * and print after the parts in their own group. Unset prints one flat list.
- */
- groupBy: z.enum(['category']).optional(),
- /** Appearance overrides for this section. Unset uses the document's own. */
- style: invoiceSectionStyleSchema.optional(),
- /** Controls which fields are shown within this section. */
- fields: z.array(invoiceFieldConfigSchema).optional(),
- })
- /**
- * Appearance that belongs to the whole sheet rather than to one section.
- *
- * Lives in the layout alongside the sections for the same reason their styles
- * do: a workshop sets it, and no setting key, no plumbing through every PDF
- * builder and no translations have to be written for each new option.
- */
- export const invoiceDocumentStyleSchema = z.object({
- /** Base text size in points. Everything else scales from it. */
- fontSize: z.number().min(6).max(14).optional(),
- /** Vertical padding in a table row, in points. Lower is denser. */
- rowPadding: z.number().min(0).max(12).optional(),
- /** Page margin in points. The framed sheet keeps its own top and left. */
- margin: z.number().min(12).max(72).optional(),
- /** Banding behind alternate table rows. False prints them all the same. */
- stripes: z.boolean().optional(),
- /** The band's color when stripes are on. */
- stripeColor: z.string().optional(),
- /** Section headings and the rule above the total. Defaults to the primary. */
- accentColor: z.string().optional(),
- /** Typeface for the whole sheet, from the families the app embeds. */
- fontFamily: z.string().optional(),
- })
- /**
- * Where something sits once it has been dragged out of the flow.
- *
- * Keyed by node id, which is a section id for a whole block and an element id
- * for one piece of it, so a logo and a customer panel are positioned by exactly
- * the same mechanism. Coordinates are points from the top-left of the sheet.
- */
- export const anchorSchema = z.object({
- x: z.number(),
- y: z.number(),
- width: z.number().optional(),
- page: z.number().int().min(1).optional(),
- })
- export const invoiceLayoutConfigSchema = z.object({
- sections: z.array(invoiceSectionSchema),
- /** Whole-sheet appearance. Unset leaves every default in place. */
- document: invoiceDocumentStyleSchema.optional(),
- /** Anything positioned by hand, keyed by node id. */
- anchors: z.record(z.string(), anchorSchema).optional(),
- /**
- * Which era saved this layout. Absent means the layout predates the
- * full-screen designer (or was never saved at all), and the print keeps the
- * classic look those organizations have always mailed out.
- */
- version: z.number().int().optional(),
- /**
- * Which document this arranges. Absent means an invoice or a quote, which
- * share one section list; a certificate has sections of its own, so its
- * layout has to say so to be merged with the right defaults.
- */
- documentType: z.enum(['invoice', 'quote', 'certificate', 'work_order']).optional(),
- })
- /** Stamped on every layout the designer saves. */
- export const DESIGNER_LAYOUT_VERSION = 3
- /**
- * The stamp that means "saved from the full-screen designer". It is not the
- * version the designer writes: that one moves whenever a layout needs reading
- * differently, and moving this with it would drop every organization back to
- * the classic sheet.
- */
- const DESIGNER_ERA_VERSION = 2
- /**
- * The first version whose Document Title switch is obeyed. Below it, a hidden
- * title strip was drawn anyway, under the header, so hiding it there was never
- * a choice to keep.
- */
- const HONEST_TITLE_SWITCH_VERSION = 3
- /**
- * Whether this layout was saved from the full-screen designer. Anything else,
- * including no saved layout at all, keeps the classic pre-designer rendering
- * so a deploy never restyles an organization's documents behind its back.
- */
- export function isDesignerLayout(config?: Partial<InvoiceLayoutConfig> | null): boolean {
- return (config?.version ?? 1) >= DESIGNER_ERA_VERSION
- }
- // ---------------------------------------------------------------------------
- // TypeScript types (derived from Zod)
- // ---------------------------------------------------------------------------
- export type InvoiceSectionStyle = z.infer<typeof invoiceSectionStyleSchema>
- export type InvoiceDocumentStyle = z.infer<typeof invoiceDocumentStyleSchema>
- export type InvoiceAnchor = z.infer<typeof anchorSchema>
- export type InvoiceFieldConfig = z.infer<typeof invoiceFieldConfigSchema>
- export type InvoiceSection = z.infer<typeof invoiceSectionSchema>
- export type InvoiceLayoutConfig = z.infer<typeof invoiceLayoutConfigSchema>
- // ---------------------------------------------------------------------------
- // Custom field ID helpers
- // ---------------------------------------------------------------------------
- export const CUSTOM_FIELD_PREFIX = 'cf_'
- export function isCustomFieldId(id: string): boolean {
- return id.startsWith(CUSTOM_FIELD_PREFIX)
- }
- export function toCustomFieldId(definitionId: string): string {
- return `${CUSTOM_FIELD_PREFIX}${definitionId}`
- }
- export function fromCustomFieldId(cfId: string): string {
- return cfId.slice(CUSTOM_FIELD_PREFIX.length)
- }
- // ---------------------------------------------------------------------------
- // Constants – built-in section & field definitions
- // ---------------------------------------------------------------------------
- export const BUILTIN_SECTIONS = [
- { id: 'header', name: 'Header' },
- // Directly under the header, which is where the strip carrying the number
- // and the date has always been drawn.
- { id: 'document_title', name: 'Document Title' },
- // Its own section, not a line inside the header, so it can be placed,
- // paired and styled like anything else on the sheet.
- { id: 'slogan', name: 'Slogan' },
- { id: 'customer', name: 'Customer' },
- { id: 'vehicle', name: 'Vehicle' },
- { id: 'service', name: 'Service' },
- { id: 'items_table', name: 'Items Table' },
- { id: 'parts_table', name: 'Parts Table' },
- { id: 'labor_table', name: 'Labor Table' },
- { id: 'findings', name: 'Findings' },
- { id: 'totals', name: 'Totals' },
- { id: 'notes', name: 'Notes' },
- // Its own section rather than a tail on the notes, so the list of what
- // rides along with the document can be placed and styled like anything else.
- { id: 'attached_documents', name: 'Attached Documents' },
- { id: 'warranty', name: 'Warranty' },
- { id: 'bank_account', name: 'Bank Account' },
- // What the inspection linked to the job, or the one a quote was raised
- // from, found: the certificate's own two blocks. In front of the condition
- // map, so the inspection's findings and the body's sit together as the
- // appendix. Off until a workshop wants the check's results on the bill.
- { id: 'defects', name: 'Defects' },
- { id: 'results_table', name: 'All Results' },
- // The car's condition this visit: the job's drop-off and the inspection
- // linked to it. Last, as an appendix: the bill first, then the record of
- // the dents that were already there. Off until a workshop wants it on
- // every invoice; a single job asks for it from its drop-off tab.
- { id: 'condition_map', name: 'Vehicle Condition' },
- // Above the footer, where a signature goes on paper. Off until a workshop
- // switches it on, so no sheet gains a signature line by a deploy.
- { id: 'signature', name: 'Signature' },
- { id: 'footer', name: 'Footer' },
- { id: 'telegram_qr', name: 'Telegram QR' },
- { id: 'general', name: 'General' },
- ] as const
- /**
- * What a completed inspection prints. The letterhead, title strip, customer
- * and vehicle panels, notes, attachments and footer are the shared ones; the
- * rest is the test: what it found, in the order a reader wants it.
- */
- export const CERTIFICATE_SECTIONS = [
- { id: 'header', name: 'Header' },
- { id: 'document_title', name: 'Document Title' },
- { id: 'slogan', name: 'Slogan' },
- { id: 'result', name: 'Result' },
- { id: 'customer', name: 'Customer' },
- { id: 'vehicle', name: 'Vehicle' },
- { id: 'test_details', name: 'Test Details' },
- { id: 'defects', name: 'Defects' },
- { id: 'results_table', name: 'All Results' },
- { id: 'condition_map', name: 'Vehicle Condition' },
- { id: 'inspection_photos', name: 'Photos' },
- { id: 'notes', name: 'Notes' },
- { id: 'attached_documents', name: 'Attached Documents' },
- { id: 'signature', name: 'Signature' },
- { id: 'footer', name: 'Footer' },
- ] as const
- /**
- * What a work order prints: the sheet a customer signs at the counter and the
- * technician takes off the board. The invoice's sections, less what belongs
- * to a bill (bank details, payments), plus the facts of the job, the
- * customer's concerns, a checklist of the work and a code that opens the
- * job on a phone. The results of the inspection linked to the job can ride
- * along, in the two sections the certificate prints them with.
- */
- export const WORK_ORDER_SECTIONS = [
- { id: 'header', name: 'Header' },
- { id: 'document_title', name: 'Document Title' },
- { id: 'slogan', name: 'Slogan' },
- { id: 'customer', name: 'Customer' },
- { id: 'vehicle', name: 'Vehicle' },
- { id: 'service', name: 'Service' },
- { id: 'job_details', name: 'Job Details' },
- { id: 'job_qr', name: 'Open on Phone' },
- { id: 'concerns', name: 'Customer Concerns' },
- { id: 'job_description', name: 'Work Requested' },
- { id: 'condition_map', name: 'Vehicle Condition' },
- // What the inspection linked to the job found, drawn by the certificate's
- // own blocks. Off until a workshop wants the check's results on the sheet.
- { id: 'defects', name: 'Defects' },
- { id: 'results_table', name: 'All Results' },
- { id: 'work_checklist', name: 'Work Checklist' },
- { id: 'items_table', name: 'Items Table' },
- { id: 'parts_table', name: 'Parts Table' },
- { id: 'labor_table', name: 'Labor Table' },
- { id: 'findings', name: 'Findings' },
- { id: 'totals', name: 'Totals' },
- { id: 'notes', name: 'Notes' },
- { id: 'attached_documents', name: 'Attached Documents' },
- { id: 'warranty', name: 'Warranty' },
- { id: 'general', name: 'General' },
- { id: 'signature', name: 'Signature' },
- { id: 'footer', name: 'Footer' },
- ] as const
- export type LayoutDocumentType = 'invoice' | 'quote' | 'certificate' | 'work_order'
- /** The sections a document is built from. */
- export function sectionsFor(
- documentType: LayoutDocumentType | undefined
- ): ReadonlyArray<{ id: string; name: string }> {
- if (documentType === 'certificate') return CERTIFICATE_SECTIONS
- if (documentType === 'work_order') return WORK_ORDER_SECTIONS
- return BUILTIN_SECTIONS
- }
- /**
- * The document a layout arranges. Absent means an invoice or a quote, which
- * share one section list; the others have to say so.
- */
- export function layoutDocumentType(
- saved: Pick<Partial<InvoiceLayoutConfig>, 'documentType'> | null | undefined
- ): LayoutDocumentType {
- if (saved?.documentType === 'certificate') return 'certificate'
- if (saved?.documentType === 'work_order') return 'work_order'
- return 'invoice'
- }
- export const BUILTIN_CUSTOMER_FIELDS = [
- { id: 'customer_name', name: 'Customer Name' },
- { id: 'customer_company', name: 'Customer Company' },
- { id: 'customer_address', name: 'Customer Address' },
- { id: 'customer_email', name: 'Customer Email' },
- { id: 'customer_phone', name: 'Customer Phone' },
- { id: 'customer_tax_id', name: 'Customer Tax ID' },
- ] as const
- export const BUILTIN_VEHICLE_FIELDS = [
- { id: 'vehicle_name', name: 'Vehicle' },
- { id: 'vin', name: 'VIN' },
- { id: 'license_plate', name: 'License Plate' },
- { id: 'mileage', name: 'Mileage' },
- // The German type approval key. On by default: it prints nothing until a
- // vehicle has one, and a workshop that records it wants it on the paper.
- { id: 'hsn_tsn', name: 'HSN/TSN' },
- ] as const
- export const BUILTIN_SERVICE_FIELDS = [
- { id: 'service_title', name: 'Service Title' },
- { id: 'service_type', name: 'Service Type' },
- { id: 'tech_name', name: 'Technician' },
- ] as const
- /** @deprecated Use BUILTIN_CUSTOMER_FIELDS, BUILTIN_VEHICLE_FIELDS, BUILTIN_SERVICE_FIELDS */
- export const BUILTIN_INFO_FIELDS = [
- ...BUILTIN_CUSTOMER_FIELDS,
- ...BUILTIN_VEHICLE_FIELDS,
- ...BUILTIN_SERVICE_FIELDS,
- ] as const
- export const BUILTIN_HEADER_FIELDS = [
- { id: 'logo', name: 'Logo' },
- { id: 'company_name', name: 'Company Name' },
- { id: 'company_address', name: 'Address' },
- { id: 'company_phone', name: 'Phone' },
- { id: 'company_email', name: 'Email' },
- { id: 'company_org_number', name: 'Organization Number' },
- ] as const
- /**
- * Company details a workshop can move down to the footer, the way printed
- * stationery carries them: the shop up top, the ways to reach it along the
- * bottom. All off by default, so a footer stays the one line it has always
- * been until somebody asks for more.
- */
- export const BUILTIN_FOOTER_FIELDS = [
- { id: 'footer_note', name: 'Footer Note' },
- // The customer's link to their portal. On by default because the sheet has
- // always printed it when a portal link exists; now the footer can decline.
- { id: 'portal_link', name: 'Portal Link' },
- // Off unless asked for, the way the rest of the footer details are: a shop
- // that wants its mark at the foot of the page as well as the top can say so.
- { id: 'logo', name: 'Logo' },
- { id: 'company_name', name: 'Company Name' },
- { id: 'company_address', name: 'Address' },
- { id: 'company_phone', name: 'Phone' },
- { id: 'company_email', name: 'Email' },
- { id: 'bank_account', name: 'Bank Account' },
- { id: 'company_org_number', name: 'Organization Number' },
- ] as const
- /**
- * The title strip: the big word, and the cells beside it. Each is a switch,
- * because what a document must name differs by trade and by country, and the
- * strip is where a sheet says its number and its dates.
- */
- export const BUILTIN_DOCUMENT_TITLE_FIELDS = [
- { id: 'title', name: 'Title' },
- { id: 'invoice_number', name: 'Number' },
- { id: 'customer_number', name: 'Customer Number' },
- { id: 'date', name: 'Date' },
- { id: 'due_date', name: 'Due Date' },
- // The plate as a cell of its own, large enough to read a work order off a
- // board. Off everywhere but the work order until a design asks for it.
- { id: 'license_plate', name: 'License Plate' },
- ] as const
- /**
- * The facts of a job that an invoice never prints: where it stands, who has
- * it, when it is booked and when the customer was promised it.
- */
- export const BUILTIN_JOB_DETAILS_FIELDS = [
- { id: 'status', name: 'Status' },
- { id: 'technician', name: 'Technician' },
- { id: 'scheduled', name: 'Scheduled' },
- { id: 'promised', name: 'Promised' },
- { id: 'work_bay', name: 'Work Bay' },
- ] as const
- export const BUILTIN_BANK_ACCOUNT_FIELDS = [
- { id: 'bank_account', name: 'Bank Account' },
- { id: 'org_number', name: 'Organization Number' },
- { id: 'payment_terms', name: 'Payment Terms' },
- { id: 'due_date', name: 'Due Date' },
- ] as const
- /** The facts Annex IV wants on a certificate, each one a switch. */
- export const BUILTIN_TEST_DETAILS_FIELDS = [
- { id: 'test_date', name: 'Date of test' },
- { id: 'test_location', name: 'Place of test' },
- { id: 'inspector', name: 'Inspector' },
- { id: 'certificate_number', name: 'Certificate number' },
- { id: 'vehicle_category', name: 'Vehicle category' },
- { id: 'odometer', name: 'Odometer' },
- { id: 'next_test_due', name: 'Next test due' },
- ] as const
- /** What the result band says under the verdict. */
- export const BUILTIN_RESULT_FIELDS = [
- { id: 'result_detail', name: 'Explanation' },
- { id: 'result_summary', name: 'Counts' },
- ] as const
- /** What each defect carries beside its grade. */
- export const BUILTIN_DEFECTS_FIELDS = [
- { id: 'defect_notes', name: 'Notes' },
- { id: 'defect_photos', name: 'Photos' },
- /**
- * The line a car with nothing wrong prints under the heading. Off, a clean
- * inspection prints no defects section at all.
- */
- { id: 'no_defects_note', name: 'Note when there are no defects' },
- ] as const
- /**
- * What the signature block draws: the signer's saved signature, the line,
- * whether the name is printed under it, and the date. The line and name ids
- * are from when only certificates were signed, by an inspector; they are kept
- * so saved layouts still mean what they did.
- */
- export const BUILTIN_SIGNATURE_FIELDS = [
- { id: 'signature_image', name: 'Signature' },
- { id: 'inspector_line', name: 'Signature line' },
- { id: 'inspector_name', name: "Signer's name" },
- { id: 'date_line', name: 'Date line' },
- // A second, empty line for the customer's pen, beside the issuer's. On by
- // default only on the work order, which is the sheet a customer signs.
- { id: 'customer_line', name: 'Customer signature line' },
- ] as const
- /** What the condition map prints beside the drawing. */
- export const BUILTIN_CONDITION_MAP_FIELDS = [
- { id: 'legend', name: 'Legend' },
- /** Marks still open from earlier visits, drawn in grey. */
- { id: 'previous_marks', name: 'Earlier marks' },
- /**
- * The empty drawing with the key of kinds and ruled rows, printed when the
- * job has no marks yet: a form for a walk-round done with a pen. Only the
- * work order offers it, and only when a workshop switches it on.
- */
- { id: 'blank_sheet', name: 'Blank sheet to fill in by hand' },
- ] as const
- /** Which rows the full results table prints beyond the defects. */
- export const BUILTIN_RESULTS_TABLE_FIELDS = [
- { id: 'passed_checks', name: 'Passed checks' },
- { id: 'not_applicable_checks', name: 'Not applicable checks' },
- { id: 'check_notes', name: 'Notes column' },
- /** Every check in one table with a section column, instead of a table per section. */
- { id: 'combined_table', name: 'One table' },
- /**
- * The checks nobody has graded yet, in their places with the grade left
- * empty: an inspection that has not started prints as a sheet to fill in.
- */
- { id: 'ungraded_checks', name: 'Checks not graded yet' },
- ] as const
- /**
- * Fields that printed before they had a switch, by section.
- *
- * A field added to a section is off in every layout saved before it existed,
- * which is right for a line that never printed and wrong for one that always
- * did: switches arrived for the payment panel's terms and due date long after
- * the rows themselves, and defaulting them off would have quietly stripped
- * them from every existing design. A layout that does not mention them shows
- * them; switching one off is a choice somebody made, and is stored.
- */
- export const GRANDFATHERED_FIELDS: Record<string, readonly string[]> = {
- bank_account: ['payment_terms', 'due_date'],
- document_title: ['title', 'invoice_number', 'customer_number', 'date', 'due_date'],
- // "No deficiencies were recorded." printed on every clean certificate
- // before a design could leave it out.
- defects: ['no_defects_note'],
- }
- /** The ids of `sectionId`'s grandfathered fields that `fields` never mentions. */
- export function unmentionedGrandfathered(
- sectionId: string,
- fields: ReadonlyArray<{ id: string }> | undefined
- ): string[] {
- const ids = GRANDFATHERED_FIELDS[sectionId]
- if (!ids || !fields) return []
- return ids.filter((id) => !fields.some((f) => f.id === id))
- }
- /** The footer's rows that are not detail lines: the mark, the portal link and the closing note. */
- export const FOOTER_SPECIAL_FIELD_IDS: Set<string> = new Set(['logo', 'portal_link', 'footer_note'])
- /**
- * The footer's detail lines flowed top-to-bottom into up to three columns, in
- * the order given, so the stored field order is the order the sheet shows.
- * The generator and the designer's field list both read this, so the drag
- * order and the print agree on which line heads which column.
- */
- export function footerColumnsOf<T>(entries: T[]): T[][] {
- const colCount = Math.min(3, entries.length)
- if (!colCount) return []
- const rows = Math.ceil(entries.length / colCount)
- return Array.from({ length: colCount }, (_, c) => entries.slice(c * rows, (c + 1) * rows)).filter(
- (column) => column.length > 0
- )
- }
- export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
- export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
- export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
- export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]['id']
- export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
- export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
- export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
- export type BuiltinDocumentTitleFieldId = (typeof BUILTIN_DOCUMENT_TITLE_FIELDS)[number]['id']
- export type BuiltinFooterFieldId = (typeof BUILTIN_FOOTER_FIELDS)[number]['id']
- /** Sections that have configurable fields */
- export const SECTIONS_WITH_FIELDS = new Set<string>([
- 'header',
- 'document_title',
- 'footer',
- 'customer',
- 'vehicle',
- 'service',
- 'bank_account',
- 'general',
- 'test_details',
- 'result',
- 'signature',
- 'defects',
- 'results_table',
- 'job_details',
- 'condition_map',
- ])
- /** Sections with fields of their own that print no workshop-defined fields. */
- export const NO_CUSTOM_FIELD_SECTIONS = new Set<string>(['condition_map'])
- /** Sections that print inside a panel and can have it taken away. */
- export const BOXED_ELIGIBLE_SECTIONS = new Set<string>([
- 'customer',
- 'vehicle',
- 'service',
- 'general',
- 'notes',
- 'attached_documents',
- 'warranty',
- 'telegram_qr',
- 'test_details',
- 'signature',
- 'result',
- 'job_details',
- 'job_qr',
- 'concerns',
- 'job_description',
- 'work_checklist',
- ])
- /** Sections that can be placed in left/right columns */
- /**
- * Fields the sheet prints in a place of its own, whatever order the list is
- * in: the company name above the header's details and the logo in its corner,
- * the word the document calls itself above the title strip's cells, and the
- * footer's portal line first with its closing note last.
- *
- * They are named here so the inspector can decline to offer a drag it cannot
- * honour. Dragging them did nothing at all, which reads as a broken editor
- * rather than as a slot that is fixed on purpose.
- * `src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts` works
- * out which fields actually ignore their position and holds this list to it,
- * so the offer and the print can never drift apart.
- */
- export const FIXED_SLOT_FIELDS: Record<string, readonly string[]> = {
- header: ['logo', 'company_name'],
- document_title: ['title'],
- footer: ['footer_note', 'portal_link', 'logo'],
- // The signature sits on its line, the name under it and the date beside:
- // where each goes is the shape of a signature block, not an order.
- signature: ['signature_image', 'inspector_line', 'inspector_name', 'date_line', 'customer_line'],
- // The inspection's two sections list switches, not rows: what a defect
- // carries and which kinds of check the table prints. None has a position.
- defects: ['defect_notes', 'defect_photos', 'no_defects_note'],
- results_table: [
- 'passed_checks',
- 'not_applicable_checks',
- 'check_notes',
- 'combined_table',
- 'ungraded_checks',
- ],
- }
- /** Whether this field prints where the list puts it, or in a slot of its own. */
- export function fieldHasFixedSlot(sectionId: string, fieldId: string): boolean {
- return (FIXED_SLOT_FIELDS[sectionId] ?? []).includes(fieldId)
- }
- export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
- 'slogan',
- 'totals',
- 'customer',
- 'vehicle',
- 'service',
- 'general',
- 'notes',
- 'attached_documents',
- 'bank_account',
- 'test_details',
- 'signature',
- 'result',
- 'job_details',
- 'job_qr',
- ])
- /** Sections that MUST be full-width (cannot be in columns) */
- export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
- 'header',
- 'document_title',
- 'items_table',
- 'parts_table',
- 'labor_table',
- 'footer',
- 'telegram_qr',
- 'defects',
- 'results_table',
- 'inspection_photos',
- 'concerns',
- 'job_description',
- 'work_checklist',
- 'condition_map',
- ])
- /** Default column assignment for column-eligible sections */
- const DEFAULT_COLUMN: Record<string, 'left' | 'right'> = {
- customer: 'left',
- vehicle: 'left',
- service: 'right',
- test_details: 'right',
- job_details: 'right',
- job_qr: 'right',
- }
- // ---------------------------------------------------------------------------
- // Defaults
- // ---------------------------------------------------------------------------
- function getDefaultFieldsForSection(
- sectionId: string,
- documentType: LayoutDocumentType = 'invoice'
- ): InvoiceFieldConfig[] | undefined {
- const workOrder = documentType === 'work_order'
- switch (sectionId) {
- // A work order says only what the technician and the customer need: who
- // the car belongs to and how to reach them, which car, and the job.
- case 'customer':
- return BUILTIN_CUSTOMER_FIELDS.map((f) => ({
- id: f.id,
- visible: workOrder ? f.id === 'customer_name' || f.id === 'customer_phone' : true,
- }))
- case 'vehicle':
- return BUILTIN_VEHICLE_FIELDS.map((f) => ({
- id: f.id,
- visible: workOrder ? f.id !== 'vin' : true,
- }))
- case 'service':
- return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }))
- case 'header':
- return BUILTIN_HEADER_FIELDS.map((f) => ({
- id: f.id,
- visible: workOrder ? f.id !== 'company_email' && f.id !== 'company_org_number' : true,
- }))
- case 'bank_account':
- return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
- case 'document_title':
- // The plate cell is the work order's; every other strip keeps the
- // cells it has always had.
- return BUILTIN_DOCUMENT_TITLE_FIELDS.map((f) => ({
- id: f.id,
- visible: workOrder
- ? f.id !== 'customer_number' && f.id !== 'due_date'
- : f.id !== 'license_plate',
- }))
- case 'job_details':
- return BUILTIN_JOB_DETAILS_FIELDS.map((f) => ({ id: f.id, visible: f.id !== 'work_bay' }))
- case 'condition_map':
- return builtinConditionMapFields(documentType).map((f) => ({
- id: f.id,
- visible: f.id !== 'blank_sheet',
- }))
- case 'footer':
- // Only the note and the portal link, which is the footer every existing
- // invoice already has. A work order is not sent, so no portal link.
- return BUILTIN_FOOTER_FIELDS.map((f) => ({
- id: f.id,
- visible: f.id === 'footer_note' || (f.id === 'portal_link' && !workOrder),
- }))
- case 'general':
- return [] // no built-in fields, only custom fields
- case 'test_details':
- return BUILTIN_TEST_DETAILS_FIELDS.map((f) => ({ id: f.id, visible: true }))
- case 'result':
- return BUILTIN_RESULT_FIELDS.map((f) => ({ id: f.id, visible: true }))
- case 'signature':
- // A work order is signed with a pen at the counter: the customer's
- // line is on and the issuer's saved image is off. Everywhere else the
- // customer line waits to be switched on.
- return BUILTIN_SIGNATURE_FIELDS.map((f) => ({
- id: f.id,
- visible:
- f.id === 'customer_line' ? workOrder : f.id === 'signature_image' ? !workOrder : true,
- }))
- case 'defects':
- return BUILTIN_DEFECTS_FIELDS.map((f) => ({ id: f.id, visible: true }))
- case 'results_table':
- // Only the checks that were not OK, with their notes, a table per
- // section: what a reader wants to know. Passed, not-applicable and
- // ungraded rows and the one-table form are switches a design turns on.
- return BUILTIN_RESULTS_TABLE_FIELDS.map((f) => ({
- id: f.id,
- visible: f.id === 'check_notes',
- }))
- default:
- return undefined
- }
- }
- /**
- * Sections a workshop has to switch on before they appear.
- *
- * `items_table` is one because it replaces the separate parts and labor tables
- * rather than joining them, and `document_title` because the standard headers
- * already print the title themselves. `signature` because most invoices and
- * quotes go out unsigned.
- */
- const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>([
- 'general',
- 'telegram_qr',
- 'items_table',
- 'signature',
- 'condition_map',
- 'defects',
- 'results_table',
- ])
- /**
- * Sections that join a saved invoice or quote design in front of the ones
- * that close the sheet, rather than after their neighbour in the default
- * order. The condition map is an appendix: after everything, before the
- * signing line and the footer. The inspection's results are part of the
- * same appendix and close the sheet the same way.
- */
- const INSERTS_BEFORE: Record<string, readonly string[]> = {
- condition_map: ['signature', 'footer'],
- defects: ['signature', 'footer'],
- results_table: ['signature', 'footer'],
- }
- /**
- * Sections that join a saved invoice or quote design directly in front of
- * another one, wherever a workshop has put it. The inspection's results go
- * in front of the condition map; a design saved before the map existed has
- * none, and the closing rule above places all three together.
- */
- const INSERTS_IN_FRONT_OF: Record<string, string> = {
- defects: 'condition_map',
- results_table: 'condition_map',
- }
- /** A signature line is a choice; most certificates are issued unsigned. */
- const HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS = new Set<string>(['slogan', 'signature'])
- /**
- * The work order prints as short as it can be: the customer, the car, the
- * job, the work and parts with their prices, and the lines to sign. Workshops
- * print these all day, so everything else waits to be switched on.
- */
- const HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS = new Set<string>([
- 'general',
- 'items_table',
- 'work_checklist',
- 'slogan',
- 'service',
- 'findings',
- 'attached_documents',
- 'warranty',
- // The customer's notes are the invoice's, and the code to scan is the
- // board copy's; each costs the room that keeps the counter copy on one page.
- 'notes',
- 'job_qr',
- // Status, technician and times are the board's business, not the counter's.
- 'job_details',
- // The letterhead: the customer signs at the counter of the shop whose
- // name is over the door, and the strip already says which sheet this is.
- 'header',
- // The linked inspection's findings have a sheet of their own, the
- // certificate. They join the work order only where a workshop asks.
- 'defects',
- 'results_table',
- ])
- /** The work order's panels print as bare lines: no fills, no boxes, no colour. */
- const PLAIN_WORK_ORDER_SECTIONS = new Set<string>([
- 'customer',
- 'vehicle',
- 'job_details',
- 'job_qr',
- 'concerns',
- 'job_description',
- 'work_checklist',
- 'notes',
- 'signature',
- ])
- /**
- * Black on white for a sheet printed all day long: no coloured accent, no
- * banding, no dark bar over the tables, small type on a narrow margin.
- */
- export const WORK_ORDER_DOCUMENT_STYLE: InvoiceDocumentStyle = {
- accentColor: '#111827',
- fontSize: 9,
- rowPadding: 3,
- margin: 32,
- stripes: false,
- }
- /** The colour and header the work order prints with until a design says otherwise. */
- export const WORK_ORDER_TEMPLATE_DEFAULTS = { primaryColor: '#111827', headerStyle: 'compact' }
- /** A table's column heads set in plain ink on white, with a hairline under each row. */
- const PLAIN_TABLE_STYLE: InvoiceSectionStyle = { backgroundColor: '#ffffff', borderWidth: 0.5 }
- export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
- return getDefaultLayout('invoice')
- }
- /** The default arrangement for a document: every section, in its built-in order. */
- export function getDefaultLayout(documentType: LayoutDocumentType): InvoiceLayoutConfig {
- const hidden =
- documentType === 'certificate'
- ? HIDDEN_BY_DEFAULT_CERTIFICATE_SECTIONS
- : documentType === 'work_order'
- ? HIDDEN_BY_DEFAULT_WORK_ORDER_SECTIONS
- : HIDDEN_BY_DEFAULT_SECTIONS
- return {
- sections: sectionsFor(documentType).map((s, index) => {
- const fields = getDefaultFieldsForSection(s.id, documentType)
- const workOrder = documentType === 'work_order'
- // The car beside the customer, not under it: two short panels on one row.
- const column = workOrder && s.id === 'vehicle' ? 'right' : DEFAULT_COLUMN[s.id]
- const plain = workOrder && PLAIN_WORK_ORDER_SECTIONS.has(s.id)
- const table = workOrder && (s.id === 'labor_table' || s.id === 'parts_table')
- return {
- id: s.id,
- visible: !hidden.has(s.id),
- order: index,
- ...(column ? { column } : {}),
- ...(plain ? { boxed: false } : {}),
- ...(table ? { style: { ...PLAIN_TABLE_STYLE } } : {}),
- ...(fields ? { fields } : {}),
- }
- }),
- ...(documentType === 'work_order' ? { document: { ...WORK_ORDER_DOCUMENT_STYLE } } : {}),
- ...(documentType === 'certificate' || documentType === 'work_order' ? { documentType } : {}),
- }
- }
- /** A section's appearance overrides, or nothing if it has none. */
- export function getSectionStyle(
- config: InvoiceLayoutConfig | undefined | null,
- sectionId: string
- ): InvoiceSectionStyle | undefined {
- const style = config?.sections.find((s) => s.id === sectionId)?.style
- // An empty object is the same as none, and saves the renderer a clone.
- return style && Object.values(style).some((v) => v !== undefined && v !== '') ? style : undefined
- }
- // ---------------------------------------------------------------------------
- // Letterhead mark
- // ---------------------------------------------------------------------------
- export type LetterheadMark = 'logo' | 'company_name'
- /**
- * Which of the two the header band carries. Layouts that show neither, or that
- * have no header fields at all, read as the logo, which is what every header
- * has always preferred.
- */
- export function getLetterheadMark(config: InvoiceLayoutConfig | undefined | null): LetterheadMark {
- const fields = config?.sections.find((s) => s.id === 'header')?.fields
- if (!fields) return 'logo'
- return fields.find((f) => f.id === 'logo')?.visible === false ? 'company_name' : 'logo'
- }
- /**
- * Flip the band from one mark to the other. It sets both fields, because the
- * band shows one and leaving the other visible would only mislead whoever opens
- * the layout editor next.
- */
- export function withLetterheadMark(
- config: InvoiceLayoutConfig,
- mark: LetterheadMark
- ): InvoiceLayoutConfig {
- return {
- sections: config.sections.map((section) => {
- if (section.id !== 'header') return section
- const fields = section.fields ?? getDefaultFieldsForSection('header') ?? []
- return {
- ...section,
- fields: fields.map((field) =>
- field.id === 'logo'
- ? { ...field, visible: mark === 'logo' }
- : field.id === 'company_name'
- ? { ...field, visible: mark === 'company_name' }
- : field
- ),
- }
- }),
- }
- }
- // ---------------------------------------------------------------------------
- // Field lookup helpers (for rendering)
- // ---------------------------------------------------------------------------
- /**
- * The condition map's fields on a document. Earlier visits' marks are the
- * work order's and the certificate's business: an invoice or a quote speaks
- * about this visit only, so it has no switch for them.
- */
- function builtinConditionMapFields(
- documentType: LayoutDocumentType
- ): ReadonlyArray<{ id: string; name: string }> {
- if (documentType === 'work_order') return BUILTIN_CONDITION_MAP_FIELDS
- // A certificate is a finished record and an invoice a bill: neither is a
- // form to write on.
- const printed = BUILTIN_CONDITION_MAP_FIELDS.filter((f) => f.id !== 'blank_sheet')
- return documentType === 'invoice' || documentType === 'quote'
- ? printed.filter((f) => f.id !== 'previous_marks')
- : printed
- }
- /** Get all built-in field definitions for a section, on a document of this type. */
- export function getBuiltinFieldsForSection(
- sectionId: string,
- documentType: LayoutDocumentType = 'invoice'
- ): ReadonlyArray<{ id: string; name: string }> {
- switch (sectionId) {
- case 'customer':
- return BUILTIN_CUSTOMER_FIELDS
- case 'vehicle':
- return BUILTIN_VEHICLE_FIELDS
- case 'service':
- return BUILTIN_SERVICE_FIELDS
- case 'header':
- return BUILTIN_HEADER_FIELDS
- case 'bank_account':
- return BUILTIN_BANK_ACCOUNT_FIELDS
- case 'document_title':
- return BUILTIN_DOCUMENT_TITLE_FIELDS
- case 'footer':
- return BUILTIN_FOOTER_FIELDS
- case 'test_details':
- return BUILTIN_TEST_DETAILS_FIELDS
- case 'result':
- return BUILTIN_RESULT_FIELDS
- case 'signature':
- return BUILTIN_SIGNATURE_FIELDS
- case 'defects':
- return BUILTIN_DEFECTS_FIELDS
- case 'results_table':
- return BUILTIN_RESULTS_TABLE_FIELDS
- case 'job_details':
- return BUILTIN_JOB_DETAILS_FIELDS
- case 'condition_map':
- return builtinConditionMapFields(documentType)
- default:
- return []
- }
- }
- /** Get the display name for a built-in field across all sections */
- export function getBuiltinFieldName(fieldId: string): string | undefined {
- const allFields = [
- ...BUILTIN_CUSTOMER_FIELDS,
- ...BUILTIN_VEHICLE_FIELDS,
- ...BUILTIN_SERVICE_FIELDS,
- ...BUILTIN_HEADER_FIELDS,
- ...BUILTIN_BANK_ACCOUNT_FIELDS,
- ...BUILTIN_DOCUMENT_TITLE_FIELDS,
- ...BUILTIN_FOOTER_FIELDS,
- ...BUILTIN_TEST_DETAILS_FIELDS,
- ...BUILTIN_RESULT_FIELDS,
- ...BUILTIN_SIGNATURE_FIELDS,
- ...BUILTIN_DEFECTS_FIELDS,
- ...BUILTIN_RESULTS_TABLE_FIELDS,
- ...BUILTIN_JOB_DETAILS_FIELDS,
- ...BUILTIN_CONDITION_MAP_FIELDS,
- ]
- return allFields.find((f) => f.id === fieldId)?.name
- }
- /**
- * Make a hidden-but-drawn section real at the position it is drawn in.
- *
- * The one such section is the title strip the generator borrows under the
- * header when Document Title is switched off: on screen it sits right after
- * the header, while the hidden section's stored order points somewhere else
- * entirely. A designer gesture that references it resolves against this, so
- * the drop lands where the canvas showed it. A visible section passes
- * through untouched.
- */
- export function materializeHiddenSection(
- config: InvoiceLayoutConfig,
- refId: string | null
- ): InvoiceLayoutConfig {
- if (!refId) return config
- const ref = config.sections.find((s) => s.id === refId)
- if (!ref || ref.visible) return config
- const ordered = [...config.sections]
- .sort((a, b) => a.order - b.order)
- .filter((s) => s.id !== refId)
- const headerAt = ordered.findIndex((s) => s.id === 'header')
- ordered.splice(headerAt + 1, 0, { ...ref, visible: true })
- return { ...config, sections: ordered.map((s, i) => ({ ...s, order: i })) }
- }
- // ---------------------------------------------------------------------------
- // Merge helper – fills in missing sections/fields with defaults
- // ---------------------------------------------------------------------------
- export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceLayoutConfig {
- // A certificate or a work order layout says so and is filled in from its
- // own sections; anything else is an invoice or a quote, which share theirs.
- const documentType = layoutDocumentType(saved)
- const defaults = getDefaultLayout(documentType)
- if (!saved.sections || saved.sections.length === 0) {
- return saved.version !== undefined ? { ...defaults, version: saved.version } : defaults
- }
- // Migrate old format: split "info" into customer/vehicle/service
- const migrated = migrateFromLegacy(saved.sections)
- const merged: InvoiceSection[] = []
- const seen = new Set<string>()
- for (const section of migrated) {
- // Skip duplicate section IDs (keep first occurrence)
- if (seen.has(section.id)) continue
- seen.add(section.id)
- const defaultFields = getDefaultFieldsForSection(section.id, documentType)
- if (defaultFields) {
- merged.push({
- ...section,
- fields: mergeSectionFields(section.fields, defaultFields),
- })
- } else {
- merged.push(section)
- }
- }
- // Append any new built-in sections that are missing from saved.
- // Insert each after its natural predecessor from the default order,
- // so e.g. "findings" lands after "labor_table" instead of at the end.
- // An appendix instead goes in front of the sections that close the sheet,
- // wherever a workshop has put those: the condition map printed after the
- // signature in a design whose bank details sat below the signing line.
- // The inspection's results go in front of the condition map itself.
- const defaultOrder = defaults.sections.map((s) => s.id)
- // Positions below are read off the list and the list is renumbered as it
- // stands, so it has to be in the order the sheet prints first. A design
- // built from a starting point and saved without moving a section keeps
- // the built-in list with the starting point's numbers on it; left like
- // that, the first section added afterwards put the whole sheet back in the
- // built-in order, on every design and on every issued copy of one.
- if (defaults.sections.some((def) => !seen.has(def.id))) {
- merged.sort((a, b) => a.order - b.order)
- }
- const toInsert: { section: InvoiceSection; afterIdx: number; defaultIdx: number }[] = []
- for (const def of defaults.sections) {
- if (seen.has(def.id)) continue
- const defaultIdx = defaultOrder.indexOf(def.id)
- let insertAfterIdx = -1
- // 'invoice' covers quotes too: they share one section list.
- const invoice = documentType === 'invoice'
- const neighbour = invoice ? INSERTS_IN_FRONT_OF[def.id] : undefined
- const neighbourIdx = neighbour ? merged.findIndex((s) => s.id === neighbour) : -1
- const closing = invoice ? INSERTS_BEFORE[def.id] : undefined
- const closingIdx =
- neighbourIdx !== -1
- ? neighbourIdx
- : closing
- ? merged.findIndex((s) => closing.includes(s.id))
- : -1
- if (closingIdx !== -1) {
- insertAfterIdx = closingIdx - 1
- } else {
- for (let i = defaultIdx - 1; i >= 0; i--) {
- const idx = merged.findIndex((s) => s.id === defaultOrder[i])
- if (idx !== -1) {
- insertAfterIdx = idx
- break
- }
- }
- }
- toInsert.push({ section: def, afterIdx: insertAfterIdx, defaultIdx })
- }
- if (toInsert.length > 0) {
- // Insert in reverse so indices stay stable. Sections bound for the same
- // spot go in latest-first too, so they come out in their default order
- // rather than back to front.
- toInsert.sort((a, b) => b.afterIdx - a.afterIdx || b.defaultIdx - a.defaultIdx)
- for (const { section, afterIdx } of toInsert) {
- merged.splice(afterIdx + 1, 0, { ...section, order: 0 })
- }
- // Renumber all orders as clean integers
- for (let i = 0; i < merged.length; i++) {
- merged[i] = { ...merged[i], order: i }
- }
- }
- // Before the switch was obeyed, a hidden title strip was drawn anyway, under
- // the header. Those layouts are read as showing it there, which is what they
- // print today: without this, honouring the switch would take the number and
- // the date off every sheet that never chose to lose them. The designer
- // stamps the current version when it saves, so a workshop that switches the
- // strip off from now on keeps it off.
- // Placed by renumbering the whole run rather than by a fraction after the
- // header: orders are whole numbers as far as the saved layout's schema is
- // concerned, and a 0.5 that reached the designer was rejected on Save, which
- // silently kept every such workshop on the layout they were trying to change.
- if ((saved.version ?? 1) < HONEST_TITLE_SWITCH_VERSION) {
- const title = merged.find((s) => s.id === 'document_title')
- if (title && !title.visible) {
- const rest = merged.filter((s) => s !== title).sort((a, b) => a.order - b.order)
- const headerIdx = rest.findIndex((s) => s.id === 'header')
- rest.splice(headerIdx + 1, 0, { ...title, visible: true })
- merged.splice(0, merged.length, ...rest.map((s, i) => ({ ...s, order: i })))
- }
- }
- // Auto-assign column values to column-eligible sections if none have columns
- const hasAnyColumn = merged.some((s) => s.column)
- if (!hasAnyColumn) {
- for (const section of merged) {
- if (DEFAULT_COLUMN[section.id]) {
- section.column = DEFAULT_COLUMN[section.id]
- }
- }
- }
- return {
- sections: merged,
- ...(saved.document ? { document: saved.document } : {}),
- ...(saved.anchors ? { anchors: saved.anchors } : {}),
- ...(saved.version !== undefined ? { version: saved.version } : {}),
- ...(documentType !== 'invoice' ? { documentType } : {}),
- }
- }
- // ---------------------------------------------------------------------------
- // Legacy migration: "info" → customer/vehicle/service,
- // "custom_fields" → "general"
- // ---------------------------------------------------------------------------
- const CUSTOMER_FIELD_IDS: Set<string> = new Set(BUILTIN_CUSTOMER_FIELDS.map((f) => f.id))
- const VEHICLE_FIELD_IDS: Set<string> = new Set(BUILTIN_VEHICLE_FIELDS.map((f) => f.id))
- const SERVICE_FIELD_IDS: Set<string> = new Set(BUILTIN_SERVICE_FIELDS.map((f) => f.id))
- function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
- const hasInfo = sections.some((s) => s.id === 'info')
- const hasCustomFields = sections.some((s) => s.id === 'custom_fields')
- // Already in new format
- if (!hasInfo && !hasCustomFields) {
- return sections
- }
- const result: InvoiceSection[] = []
- for (const section of sections) {
- if (section.id === 'info') {
- // Split into customer, vehicle, service
- const customerFields: InvoiceFieldConfig[] = []
- const vehicleFields: InvoiceFieldConfig[] = []
- const serviceFields: InvoiceFieldConfig[] = []
- const customFieldRefs: InvoiceFieldConfig[] = []
- if (section.fields) {
- for (const field of section.fields) {
- if (CUSTOMER_FIELD_IDS.has(field.id)) {
- customerFields.push(field)
- } else if (VEHICLE_FIELD_IDS.has(field.id)) {
- vehicleFields.push(field)
- } else if (SERVICE_FIELD_IDS.has(field.id)) {
- serviceFields.push(field)
- } else if (isCustomFieldId(field.id)) {
- customFieldRefs.push(field)
- }
- }
- }
- // Use the info section's order as base, insert three sections
- const baseOrder = section.order
- result.push({
- id: 'customer',
- visible: section.visible,
- order: baseOrder,
- fields: customerFields.length > 0 ? customerFields : undefined,
- })
- result.push({
- id: 'vehicle',
- visible: section.visible,
- order: baseOrder + 0.1,
- fields: vehicleFields.length > 0 ? vehicleFields : undefined,
- })
- result.push({
- id: 'service',
- visible: section.visible,
- order: baseOrder + 0.2,
- fields: serviceFields.length > 0 ? serviceFields : undefined,
- })
- // If the old info section had custom fields, add them to general
- if (customFieldRefs.length > 0) {
- result.push({
- id: 'general',
- visible: section.visible,
- order: baseOrder + 0.3,
- fields: customFieldRefs,
- })
- }
- } else if (section.id === 'custom_fields') {
- // Rename to general, keep any cf_ field references
- result.push({
- ...section,
- id: 'general',
- })
- } else {
- result.push(section)
- }
- }
- // Normalize order values to integers
- result.sort((a, b) => a.order - b.order)
- result.forEach((s, i) => {
- s.order = i
- })
- return result
- }
- // ---------------------------------------------------------------------------
- // Field merge helper
- // ---------------------------------------------------------------------------
- // ---------------------------------------------------------------------------
- // Rendering helper – groups sections into full-width or 2-column rows
- // ---------------------------------------------------------------------------
- export type RenderGroup =
- | { type: 'full-width'; sectionId: string }
- | { type: 'columns'; left: string[]; right: string[] }
- /**
- * Groups sorted visible sections for rendering.
- * Consecutive column-assigned sections are grouped into a single 2-column row.
- * Sections without a column render as full-width.
- */
- export function groupSectionsForRendering(sections: InvoiceSection[]): RenderGroup[] {
- // Deduplicate by section ID (keep first occurrence by order)
- const seen = new Set<string>()
- const sorted = [...sections]
- .filter((s) => s.visible)
- .sort((a, b) => a.order - b.order)
- .filter((s) => {
- if (seen.has(s.id)) return false
- seen.add(s.id)
- return true
- })
- const groups: RenderGroup[] = []
- let pendingLeft: string[] = []
- let pendingRight: string[] = []
- const flushColumns = () => {
- if (pendingLeft.length > 0 || pendingRight.length > 0) {
- groups.push({ type: 'columns', left: [...pendingLeft], right: [...pendingRight] })
- pendingLeft = []
- pendingRight = []
- }
- }
- for (const section of sorted) {
- if (section.column === 'left' || section.column === 'right') {
- if (section.column === 'left') pendingLeft.push(section.id)
- else pendingRight.push(section.id)
- } else {
- flushColumns()
- groups.push({ type: 'full-width', sectionId: section.id })
- }
- }
- flushColumns()
- return groups
- }
- // ---------------------------------------------------------------------------
- // Shared field-ordering helper
- // ---------------------------------------------------------------------------
- /**
- * Returns field IDs in the order specified by a layout config's visible fields Set.
- * The Set's iteration order reflects the layout config ordering.
- * Falls back to `defaults` when no config is provided.
- */
- export function getOrderedFieldIds(
- visibleFields: Set<string> | null | undefined,
- defaults: string[]
- ): string[] {
- if (!visibleFields) return defaults
- // Set iteration order = insertion order = layout config order
- const ordered = [...visibleFields].filter((id) => !isCustomFieldId(id))
- return ordered.length > 0 ? ordered : defaults
- }
- /**
- * Returns a Set of visible field IDs for a given section, preserving field order.
- * Returns null if no layout config is present (meaning show all fields).
- */
- export function getVisibleFieldsForSection(
- layoutConfig: InvoiceLayoutConfig | undefined | null,
- sectionId: string
- ): Set<string> | null {
- if (!layoutConfig) return null
- const section = layoutConfig.sections.find((s) => s.id === sectionId)
- if (!section?.fields) return null
- return new Set(section.fields.filter((f) => f.visible).map((f) => f.id))
- }
- // ---------------------------------------------------------------------------
- // Field merge helper
- // ---------------------------------------------------------------------------
- function mergeSectionFields(
- savedFields: InvoiceFieldConfig[] | undefined,
- defaults: InvoiceFieldConfig[]
- ): InvoiceFieldConfig[] {
- if (!savedFields || savedFields.length === 0) {
- return defaults
- }
- const seen = new Set<string>()
- const merged: InvoiceFieldConfig[] = []
- for (const field of savedFields) {
- seen.add(field.id)
- merged.push(field)
- }
- // Append any missing default fields
- for (const def of defaults) {
- if (!seen.has(def.id)) {
- merged.push(def)
- }
- }
- return merged
- }
|