Просмотр исходного кода

Rebuild invoice and quote styling as a full-screen designer (#276)

* Print an invoice on a framed sheet

A workshop whose printed stationery is a color band across the top and a
rail down the left edge has had no way to reproduce it: every header
style put the logo on plain white. This adds the Framed preset, which
sets the band as the letterhead, draws the rail as the page's own left
border so it repeats on every page, and lists the work as one numbered
table instead of a parts table above a labor table.

Two new sections make the arrangement possible. `items_table` puts every
line on a single numbered list with the unit of measure in its own
column, and `document_title` lifts the title and its reference box out
of the header so a layout can set them below the addresses, where a
letterhead expects them. Both are hidden by default, so nothing changes
for anyone who does not pick the preset. A framed header with no title
section below it prints the title itself rather than losing it.

Presets carry a layout now, not just a color. Ones that do not leave
whatever arrangement the workshop already built alone, and the templates
page only writes a layout back once a preset has actually rearranged it,
so saving a color change never overwrites the layout editor's work.

The customer number reaches the PDF for the first time, as the reference
box wants it. The combined table drops the parts and labor subtotal
rows, which would otherwise break a sum into halves the reader never saw
split. And the quotation tab loads the quote layout instead of
previewing quotes through the invoice's.

* Let the framed sheet look printed, not assembled

Three things the render showed and the code did not.

The band and the rail are two rectangles that met at the page border, and
a hairline of white showed through the join. The band now reaches past
the rail and paints over it, so the corner is one solid shape.

A workshop with a logo got the logo and then its name beside it, which
reads as a mistake: a logo is already the name set in the workshop's own
type. The band carries one mark now, the logo when there is one.

And the sheet sat flat against the color with nothing between them. The
band and the rail drop a shadow onto it, three hairlines of falling
opacity, since react-pdf has no box-shadow of its own. The one down the
left edge is fixed, so it reaches the full height of every page.

While in there: absolute offsets are measured from inside the page
border, so the framed footer was indented by the rail's width on top of
its own padding.

* Choose what the band carries, and show the real name in the preview

The framed band shows one mark, and it preferred the logo, which left a
workshop with a logo no way to put its name there instead. The templates
page now offers the choice, and it writes to the header section's field
visibility, so the PDF, the layout editor and the preview all read the
same switch rather than each keeping its own idea. Only the framed style
gets the control: every other header shows both and needs no choice.

Separately, the preview said "Your Workshop" to a workshop that had
already filled in Company details. The sample shop was hardcoded and the
real name never reached the renderer. Both pages that show a preview now
pass the organization name, address, phone and email, and the sample
only fills the gaps.

Quotes read the letterhead toggle too: their header was still going by
the legacy template flags rather than the layout's header fields.

* Read the workshop name off the organization, not the membership list

The preview still greeted some workshops as "Your Workshop". The name was
being looked up in the membership list the layout data happens to carry,
which is empty for anyone viewing a workshop they are not a member of.
Both pages now read it straight off the organization, the way the real
PDF has always done, and the fallback to the sample shop is covered by a
test rather than left to the eye.

* Stop a missing label from taking the layout editor down

`warranty` had no name in any of the twelve locales, so opening the
editor on a layout that includes it threw. The editor did have a fallback
to the built-in English name, but it never ran: it was written expecting
next-intl to hand back the key it could not resolve, and next-intl raises
instead. Both lookups ask before they read now, and the name is
translated everywhere.

A test walks every section and field the editor can list against every
locale, so the next one that goes missing fails a run rather than a page.

* Put the contact details in the footer, and a slogan under the name

Three things the printed example does that the app could not.

The footer was one fixed line. It takes fields now, the same company
details the header offers, laid out in the three columns printed
stationery uses: who the shop is, how to reach it, how to pay it. A
column with nothing in it is dropped rather than left as an empty third.
Only the note is on by default, so every existing footer stays the line
it has always been, and the Framed preset moves the details down there
where the letterhead keeps only the name.

Company details takes a slogan, one line under the shop's name saying
what it does and where. It prints in every header style and can be
switched off per layout like any other field.

And the sheet can be tinted. Templates offers a background color beside
the primary one; empty leaves the paper white, which is what every
existing invoice keeps.

The footer had to be held together with wrap={false}: react-pdf was
breaking the columns mid-block and putting the second line of each on the
following page.

* Take the panel off a section, and stop inventing a slogan

The customer, vehicle and service blocks always printed inside a panel.
The layout editor offers Box or Plain on each of them now, and on the
general block. Unset reads as boxed, so every saved layout keeps the
invoice it already had.

Two bugs behind the slogan a workshop had set never showing up in the
preview. The sample workshop carried a slogan of its own, so a workshop
with none was given one it never wrote, and there was no way to tell that
apart from its own. There is no stand-in for the slogan now: a name and
an address are things every workshop has, so a placeholder there reads as
"yours goes here", but an invented slogan just reads as a lie. And saving
in Company details only revalidated /settings itself, so the sibling
pages that read those values kept serving what they had cached.

* Start a layout from an arrangement, and actually render the background field

Four starting arrangements at the top of the layout editor: Classic,
Letterhead, Compact and Detailed. They choose nothing but where things
sit, which is what separates them from the template presets on the
Templates page: a workshop can rearrange its invoice without also
restyling it. Picking one only changes the editor; nothing is written
until the page is saved, and every section survives the swap, so trying
one on and picking another never throws away a section that was
configured.

The background color from the last commit was never rendered. The value
was in the form state and the save call, but the input itself failed to
land in the primary color card, so there was no way to set it. It is
there now, with a Clear button that returns the sheet to white.

Building a layout from a preset filtered every section's fields against
the footer's field list, which emptied the customer, vehicle and service
blocks. Only the header and the footer take a field list from a preset;
a test now holds the other sections to their own fields.

* Show the whole footer on the detailed arrangement

Detailed is the arrangement that holds nothing back, so its footer now
carries every field: the shop and its address, the phone and email, the
bank details and the org number, with the note still under them.

Both "everything" lists are derived from the built-in field definitions
rather than written out again, so a field added later is carried by the
presets that show everything instead of quietly missing from them.

* Print the invoice number once, where the layout puts it

Four separate places printed the title, number and dates: each of the
three header styles, unconditionally, plus the Document Title section.
So turning that section on moved the block down on a framed sheet and
printed it twice on the other three, which from the outside reads as the
block wandering between the top and below the addresses on its own.

The section is authoritative now. When a layout carries one, no header
prints its own; when it does not, the header carries it as it always has.
One switch explains the position, and it is the same switch for all four
header styles. Quotes follow the same rule.

The test walks the header's element tree for the number rather than
reading the rendered PDF, calling our own components on the way down
since they are plain presentational functions. It fails on three of the
four styles without the change.

* Give the preview the real workshop, add a text color, tighten the cards

The Templates preview still greeted a workshop by the sample's name. The
page had the real one and TemplateTab was handed it, but the prop never
reached the preview: the edit that was meant to add it silently matched
nothing. Same failure as the background input a few commits ago, so every
edit in this pass asserts its anchor before replacing.

Text color joins primary and background. It sets headings and body copy
only. Rules, panel borders and the table header bar keep their own color:
they read as structure rather than as text, and following a light ink
onto a light sheet would erase them.

And the two cards were spending a lot of room on very little. Colors is
three rows now, each a swatch, a hex field and whatever that row needs
after it, with the primary swatches as dots rather than labelled chips.
Font & Layout is a two-by-two grid, so the logo slider sits beside the
header style instead of alone in half a card.

* Carry the chosen ink into every line, and let the frame edge be set

Text color reached the lines that had no color of their own and stopped
there. Everything styled inline stayed at one fixed gray: addresses, VIN
and plate, the reference box labels, the unit column, the footer, the
warranty and the payment panel. They all take their color from the ink
now, through a muted companion derived by carrying the ink partway toward
the sheet. Fixing that at one gray would leave secondary lines unreadable
on a dark sheet and mismatched on a tinted one, so it moves with both.
Nothing changes for a workshop that has not chosen an ink.

The framed sheet's edge is a setting now too: the line where the frame
meets the paper, in any color or none, and its shadow, on or flat. Both
apply down the left rail and along the top band together, because they
are one edge.

Rules, panel borders and the table header bar still keep their own color.
They read as structure rather than as text, and following a light ink
onto a light sheet would erase them.

* Redraw the preview when anything about the document changes

The frame line and shadow had no effect on screen because the preview
never redrew. Its viewer remounts on a key, and that key listed four
properties of each section: id, order, visible, column. Everything added
since fell outside it. The colors, the frame edge, whether a section is
boxed, which header fields print — all of it changed the document without
changing the key, so the viewer kept the copy it already had.

The key is the whole document now, so nothing can fall outside it again,
and it settles for a moment before remounting: a color picker fires on
every step of a drag and each distinct key is a full re-render.

The renderer itself was right all along, which is why the same options
came out correct in a generated PDF.

* Let the company name have its own color

The name was white wherever a header style sets it into a colored band,
so a workshop that drops the logo and picks a pale band gets a white name
on pale paper. It is a setting now, in every header style: white and the
primary color remain the defaults, but neither is forced.

* Move appearance into the layout, where a workshop can reach it

Every appearance option so far cost a settings key, plumbing through six
PDF builders, a control and twelve translations. That is one commit per
request, forever, and it is why the last several hours went the way they
did.

Appearance lives in the layout now. A section carries five keys — text,
label, background, border, size — and the sheet carries six more: base
size, row padding, margin, banding on or off, its color, and the accent
that section headings and the rule above the total take. Both fold into
the stylesheet before it reaches a section, and sections already receive
their stylesheet as a prop, so none of them had to change.

That covers the whole list at once: the table header bar, row striping,
panel borders and fill, the label accent, base size and row density, page
margins, and the totals rule. Nothing about it is specific to those; the
next request is a value in a JSON column rather than a commit.

The framed sheet keeps its own top and left padding under a margin
change. Those are not margins, they are the room the band and the rail
occupy, and shrinking them would put the text under the frame.

* Put the style controls in the editor, and link the two halves together

Every section opens now, whether or not it has fields to pick, and holds
the five keys it can override: text, heading, fill, border, size. Above
them sits the whole sheet: base size, row height, margin, accent, and
banding with its color. That is the seven outstanding requests done, and
the next one after them needs no code.

Every handler in the editor now carries the whole config through rather
than rebuilding it from sections alone, or styling the sheet and then
reordering a section would silently drop the styling.

And the two halves say where the other one is. Colors and the header
style are on Templates, arrangement and sections are on Invoice, which is
easy to get lost in when nothing on either page mentions the other.

* Make the font setting mean something, and let sections set their own

The font selector has never changed a single glyph. Every name it offers
was mapped to Roboto before it reached the page, because the built-in PDF
fonts those names refer to are Latin-1 only and would drop Cyrillic. So
the control was real, the choice was saved, and the document ignored it.

Three families are embedded now — Roboto, Noto Serif and Noto Sans Mono,
each covering Latin, Greek and Cyrillic, both redistributable — and the
names settings has always saved map onto them, so nothing migrates. The
selector picks a typeface for the first time.

Typeface is also one of the keys a section and the sheet can set, so the
letterhead can be set in one face and the body in another. The company
name follows the header section, which is what makes that reachable.

* Add a full-screen invoice designer

A gallery to start from a template, and a designer with no sidebar and
nothing else on screen: sections down the left to drag, hide and select,
the sheet in the middle, and what the selection can change on the right.

The canvas is HTML rather than the PDF renderer, on purpose. The point of
this page is a sheet you can click and drag, which react-pdf draws into
an iframe and cannot offer. It is a schematic of the same layout config
the PDF reads, so what moves here moves there; the PDF stays the last
word on how the paper actually comes out.

Nothing is removed yet. The settings pages still work exactly as they
did, and the designer writes the same layout config and the same template
settings they always have.

* Make the designer the only place a document is styled

Settings keeps a template selector and a way in: pick a starting point,
press Open Template Designer. Everything that used to be spread across a
colour form on Templates and an arrangement editor under Invoice is on
one page now, with the sheet in front of you.

The layout tab under Invoice is gone, and with it the second preview, the
second preset row and the split that made colours live in one place and
arrangement in another. Custom fields keep their tab; which sections a
field is placed in is a property of the layout, and that tab only reads
it now rather than editing it alongside.

* Audit the designer: make every control reach the sheet

Selecting the header and changing its text or heading color did nothing,
and it was not alone. The header section was excluded from styling in the
PDF outright; the title block never received a stylesheet at all; and in
the canvas a section's text color reached the first line of a panel and
stopped, because the bodies read the document theme rather than their own
section's resolved look.

Every section now resolves its own look once, and every line inside it
reads from that. The header takes its section's colors too: its heading
is the company name, its text the strapline. The canvas draws the logo,
the frame line and the frame shadow, none of which it drew before, so
three more controls stop being decorative.

A test walks the whole control surface and fails if a control is wired to
state but not to a renderer, which is how each of these got through.

* Keep the designer on A4, and give it the fields it was missing

The sheet is A4 and stays A4. Base size and margins change how much fits
on it, never how big it is, so the canvas measures each block once it is
laid out and deals the blocks into pages that hold them — a second sheet
appears below the first the way it comes out of the printer, with the
page count under each.

Selecting the header outlines the rail as well as the band: they are one
shape the header owns. Clicking the rail itself asks about the document
instead, since the rail is the sheet's edge and not a section.

And the inspector was missing an entire property. A section's fields were
not reachable at all, which is why there was no way to turn the logo off
and print the company name instead — and no way to hide a VIN, a plate,
an org number, or to place a custom field. Every section that has fields
now lists them, with the ones it has not placed yet offered underneath
and a picker for custom fields that are not on the document.

The audit test compares the inspector against the layout schema, so a
property added to the config and not to the designer fails a run.

* Show the header's own selection, not just its rail

Selecting the header outlined the rail and nothing else. The letterhead
bleeds to the sheet edge and the sheet clips to A4, so an outline drawn
outside its box landed exactly on that boundary and was cut away. It is
drawn inside the box now, and its label sits inside the band rather than
above the top of the page where it could not be seen either.

The rail's own outline starts below the band, so the two read as one
selection around the shape the header owns rather than two rectangles
overlapping in the corner.

* Render the designer canvas from the layout, not from prose

The canvas was a hand-written impression of an invoice: a paragraph per
section, written out by someone who already knew what a customer block
looks like. That is why turning a field on changed nothing — there was no
field to turn on — and why the frame came apart, because the band was
drawn by the header and the rail by the page and nothing held them
together.

It renders from the layout now. Each section asks which fields it shows
and prints those, in that order, falling back to all of them exactly as
the PDF does for a layout that predates the choice. The sample is keyed
by field id rather than by section, so a field that exists has a value
and a field that does not cannot be faked.

The frame is page chrome: band and rail drawn together, one shape, so
they cannot separate or leave a seam. The rail can run down either edge
now, in the PDF as well as here — the page border, the letterhead's
reach, the shadow and the footer all follow it — and the margin never
adds to the edge the rail occupies.

Selecting the header outlines the band and the rail together, on the
first sheet only, where the letterhead is. It was outlining the whole of
every later page.

Logo size moved to the header section, which is what prints it.

* Rebuild the designer on a document model

The designer and the PDF were two hand-written implementations of the
same document. Nothing derived from anything, so every property had to be
built twice and every fix in one silently disagreed with the other. That
is what the last several rounds have been: not bad luck, a missing layer.

There is a model now. A document is a page, an optional frame, and blocks
of nodes — stacks, rows, text, images, tables. One generator turns a
layout config and some data into that description, and renderers walk it.
The HTML renderer contains no section names at all; a test fails if one
appears, because that is the moment it starts being a second
implementation again.

What this buys immediately:

The footer is pinned to the foot of every sheet, as a printed footer is,
rather than flowing into the middle of page two. Anything can be dragged
anywhere on the sheet and it keeps its identity, because a position is a
property of a node rather than a different kind of thing; a button
returns it to the flow. Dragging snaps to the edges and centres of
everything else on the page, and to the margins, with guides drawn while
it moves. The page is A4 and stays A4; size and margins change what fits,
never the paper.

The PDF still renders through its own components. Porting it onto the
same model is the next step, and until it lands the designer is the
schematic and the PDF remains the last word.

* Stop the canvas measuring itself into a loop

Measured geometry was kept in state. Measuring produces a fresh Map every
pass, so every render scheduled another render, which measured again:
React stopped it with "Maximum update depth exceeded", correctly.

Nothing renders from that geometry — it is read when a drag starts and
while it moves — so it belongs in a ref. The one measurement that does
drive rendering, how tall each block is and therefore where the page
breaks, keeps its state and bails when nothing moved.

* Put two sections side by side again, and let a placed block be resized

Full, Left and Right did nothing: the pairing rule was lost when the
canvas was rebuilt on the model, and every block in the flow was drawn
across the full width. The rule lives in the model now — one function
that groups blocks into rows — so the sheet and the paper cannot group
differently, and a row is what a page break falls between.

Choosing a column is choosing to be in the flow, so a block that had been
dragged somewhere comes back and takes it. That is the way back that was
missing.

Anything placed by hand has handles on its sides. Width is the only
dimension worth pulling — a block is as tall as what is in it, here and
on paper — and the edge under the pointer snaps to the same guides a move
does, so two boxes can be made to line up exactly rather than nearly.

* Drop a block back into the flow, and bleed the band to the real edges

Dragging a block out let everything below it close the gap, but there was
no way to put it back that pushed them down again. A move now shows the
gap it would drop into and commits once, on release: into the flow at
that gap, or where it lies if it was dragged off the column. It also
stops writing to the layout on every pointer move, which it should never
have been doing.

The band stopped short of the right edge on any sheet whose margin had
been changed. It reached back out by the default insets rather than the
page's actual ones, so a wider margin left a stripe of paper beside it.
It takes the page's own padding now, and clears the rail on whichever
edge that runs down.

Base text size is one step larger, and every other size is measured from
it rather than written out one by one, so moving it moves the whole sheet
together instead of leaving a table at one scale and a heading at another.

* Make a template mean the whole look, and show it on the card

Every template looked the same because they only ever changed which
sections were on. The band, the rail, the colour and the typeface were
saved somewhere else and picking a template never touched them, so all
four came out as whatever the document already was — which on this branch
meant a left rail on all of them.

A template carries its look now. Classic is a ruled header in amber;
Letterhead is the banded sheet with a rail in orange; Compact is a tight
slate header with dense unbanded rows; Detailed is a blue banner set in
serif. Picking one sets the look and the arrangement together.

The cards are the templates. They were four identical mock-ups with the
current colour painted on, which told you nothing; each one now renders
what its template actually produces, through the same generator and the
same walker as the sheet itself.

Header style moved to the document panel. It is not a property of the
header section: it decides whether the sheet has a band and a rail at
all, and what the page's insets are.

* designer

* designer

* templates

* Translate the designer, and stop the quote sheet printing English

The invoice designer shipped with every string hard-coded: the gallery, the
toolbar, the section rail, the inspector and the drag chrome. Its preview also
passed an empty label map to the document builder, so the sample sheet named
its columns and panels in English while the document it stands for prints in
the reader's language. Both now read from `settings.designer` and from the same
`pdf.json` the print path uses, in all twelve locales.

The sample job is translated too. It is the sheet a workshop is judging, so it
should look like the one they will send; the invented names, plates and account
numbers stay as they are.

Three print-path gaps came out of the same reading:

- A quote is drawn by the invoice's builder but its labels were loaded from the
  `quote` namespace alone, which carries only the wording that differs. Every
  shared label (column heads, panel titles, warranty, payment terms) fell
  through to the English fallback. The quote now layers over the invoice.
- `customFieldsTitle` and `telegramConnect` had no keys at all, so
  "Additional Information" and "Chat with us on Telegram" printed in English on
  every locale's invoice.
- The shared quote page overrode the title with a hard-coded "QUOTE".

The settings preview got the same two fixes, its telegram label, and its sample
job translated.

* Delete the template presets nothing renders any more

templatePresets.ts was the settings page's list of starting points before the
designer took that job. Nothing imports it but the test written to guard it,
and the designer builds from layoutPresets instead: Letterhead is the same
sheet, down to the color, the rail side and the header and footer field lists.

The regression coverage is worth keeping, so the framed-sheet test now asserts
against buildLayoutFromPreset(letterhead) rather than a preset no workshop can
reach.

Also drops two imports invoice-settings.tsx stopped using when the designer
took over its layout saving, and two biome-ignore comments left behind by
rules that no longer fire.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
c685f5f4dc
100 измененных файлов с 8623 добавлено и 3701 удалено
  1. 10 2
      messages/de/pdf.json
  2. 250 2
      messages/de/settings.json
  3. 10 2
      messages/en/pdf.json
  4. 250 2
      messages/en/settings.json
  5. 10 2
      messages/es/pdf.json
  6. 250 2
      messages/es/settings.json
  7. 10 2
      messages/fr/pdf.json
  8. 250 2
      messages/fr/settings.json
  9. 10 2
      messages/it/pdf.json
  10. 250 2
      messages/it/settings.json
  11. 10 2
      messages/lt/pdf.json
  12. 250 2
      messages/lt/settings.json
  13. 10 2
      messages/nb/pdf.json
  14. 250 2
      messages/nb/settings.json
  15. 10 2
      messages/nl/pdf.json
  16. 250 2
      messages/nl/settings.json
  17. 10 2
      messages/pl/pdf.json
  18. 250 2
      messages/pl/settings.json
  19. 10 2
      messages/pt-BR/pdf.json
  20. 250 2
      messages/pt-BR/settings.json
  21. 10 2
      messages/ru/pdf.json
  22. 250 2
      messages/ru/settings.json
  23. 10 2
      messages/tr/pdf.json
  24. 250 2
      messages/tr/settings.json
  25. BIN
      public/fonts/Lato-Bold.ttf
  26. BIN
      public/fonts/Lato-Regular.ttf
  27. BIN
      public/fonts/Montserrat-Bold.ttf
  28. BIN
      public/fonts/Montserrat-Regular.ttf
  29. BIN
      public/fonts/NotoSansMono-Bold.ttf
  30. BIN
      public/fonts/NotoSansMono-Regular.ttf
  31. BIN
      public/fonts/NotoSerif-Bold.ttf
  32. BIN
      public/fonts/NotoSerif-Regular.ttf
  33. BIN
      public/fonts/OpenSans-Bold.ttf
  34. BIN
      public/fonts/OpenSans-Regular.ttf
  35. BIN
      public/fonts/PTSans-Bold.ttf
  36. BIN
      public/fonts/PTSans-Regular.ttf
  37. 62 0
      scripts/generate-font-widths.mjs
  38. 83 93
      src/__tests__/api/fetch-metadata-ssrf.test.ts
  39. 134 118
      src/__tests__/api/subscription-upgrade-preview.test.ts
  40. 220 222
      src/__tests__/api/subscription-upgrade.test.ts
  41. 86 83
      src/__tests__/auth/reset-password-validation.test.tsx
  42. 1 9
      src/__tests__/components/banner-slot.test.tsx
  43. 79 45
      src/__tests__/features/public/invoice-view.test.tsx
  44. 62 50
      src/__tests__/features/public/quote-view.test.tsx
  45. 141 0
      src/__tests__/features/settings/designer-controls.test.ts
  46. 110 0
      src/__tests__/features/settings/designer-flow.test.ts
  47. 215 0
      src/__tests__/features/settings/designer-layout-engine.test.ts
  48. 87 0
      src/__tests__/features/settings/document-title-placement.test.tsx
  49. 267 0
      src/__tests__/features/settings/framed-template-preset.test.tsx
  50. 77 0
      src/__tests__/features/settings/invoice-appearance.test.ts
  51. 10 10
      src/__tests__/features/settings/itemized-template-preset.test.tsx
  52. 54 0
      src/__tests__/features/settings/layout-editor-labels.test.ts
  53. 106 0
      src/__tests__/features/settings/layout-presets.test.ts
  54. 46 0
      src/__tests__/features/settings/layout-preview-workshop.test.ts
  55. 152 0
      src/__tests__/features/settings/layout-styling.test.ts
  56. 38 0
      src/__tests__/features/settings/preview-remount-key.test.ts
  57. 259 0
      src/__tests__/features/settings/spec-pdf-print.test.tsx
  58. 1 3
      src/__tests__/features/status-report-send.test.ts
  59. 71 71
      src/__tests__/hooks/use-debounced-search.test.ts
  60. 38 8
      src/__tests__/i18n/audit-summaries.test.ts
  61. 74 77
      src/__tests__/i18n/translation-parity.test.ts
  62. 120 122
      src/__tests__/lib/features.test.ts
  63. 69 69
      src/__tests__/lib/get-auth-context.test.ts
  64. 170 176
      src/__tests__/lib/license-expiry.test.ts
  65. 44 44
      src/__tests__/lib/permissions.test.ts
  66. 48 48
      src/__tests__/lib/safe-path.test.ts
  67. 107 107
      src/__tests__/lib/stripe-config.test.ts
  68. 54 58
      src/__tests__/lib/support.test.ts
  69. 265 240
      src/__tests__/lib/tax.test.ts
  70. 213 211
      src/__tests__/multitenancy/customer-inventory-isolation.test.ts
  71. 96 93
      src/__tests__/multitenancy/inspection-isolation.test.ts
  72. 98 98
      src/__tests__/multitenancy/labor-preset-isolation.test.ts
  73. 243 243
      src/__tests__/multitenancy/quote-inspection-isolation.test.ts
  74. 157 157
      src/__tests__/multitenancy/vehicle-service-isolation.test.ts
  75. 41 50
      src/__tests__/setup.ts
  76. 28 37
      src/app/(authenticated)/admin/admin-nav.tsx
  77. 14 20
      src/app/(authenticated)/admin/layout.tsx
  78. 10 10
      src/app/(authenticated)/admin/organizations/page.tsx
  79. 5 5
      src/app/(authenticated)/admin/page.tsx
  80. 6 11
      src/app/(authenticated)/admin/settings/page.tsx
  81. 26 26
      src/app/(authenticated)/ai/_page_disabled.tsx
  82. 186 142
      src/app/(authenticated)/audit-log/audit-log-client.tsx
  83. 19 21
      src/app/(authenticated)/audit-log/page.tsx
  84. 218 190
      src/app/(authenticated)/billing/billing-client.tsx
  85. 2 2
      src/app/(authenticated)/billing/loading.tsx
  86. 25 25
      src/app/(authenticated)/billing/page.tsx
  87. 18 18
      src/app/(authenticated)/billing/recurring/page.tsx
  88. 35 36
      src/app/(authenticated)/calendar/page.tsx
  89. 325 293
      src/app/(authenticated)/customers/customers-client.tsx
  90. 2 2
      src/app/(authenticated)/customers/loading.tsx
  91. 20 22
      src/app/(authenticated)/customers/page.tsx
  92. 18 18
      src/app/(authenticated)/inspections/[id]/page.tsx
  93. 23 25
      src/app/(authenticated)/inspections/page.tsx
  94. 2 2
      src/app/(authenticated)/inventory/loading.tsx
  95. 2 2
      src/app/(authenticated)/loading.tsx
  96. 173 140
      src/app/(authenticated)/observations/observations-client.tsx
  97. 17 19
      src/app/(authenticated)/observations/page.tsx
  98. 1 2
      src/app/(authenticated)/quotes/[id]/page.tsx
  99. 2 2
      src/app/(authenticated)/quotes/loading.tsx
  100. 128 78
      src/app/(authenticated)/quotes/quotes-client.tsx

+ 10 - 2
messages/de/pdf.json

@@ -22,6 +22,9 @@
     "items": "Positionen",
     "pos": "Pos.",
     "unitOfMeasure": "Einheit",
+    "invoiceNumberLabel": "Rechnungsnummer",
+    "customerNumberLabel": "Kundennummer",
+    "dateLabel": "Datum",
     "total": "Gesamt",
     "labor": "Arbeitsleistung",
     "hours": "Stunden",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Professionelle Werkstattverwaltung",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Zusätzliche Informationen",
+    "telegramConnect": "Chatten Sie mit uns auf Telegram"
   },
   "quote": {
     "title": "ANGEBOT",
@@ -103,7 +108,10 @@
     "validityFooter30": "Dieses Angebot ist 30 Tage gültig",
     "viewPortal": "Ihr Portal ansehen: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Zusätzliche Informationen",
+    "quoteNumberLabel": "Angebotsnr.",
+    "validUntilLabel": "Gültig bis"
   },
   "inspection": {
     "title": "FAHRZEUGINSPEKTION",

+ 250 - 2
messages/de/settings.json

@@ -161,6 +161,9 @@
     "email": "E-Mail",
     "emailPlaceholder": "service@beispiel.de",
     "address": "Adresse",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Ihre Meisterwerkstatt für alle Marken",
+    "sloganHint": "Eine Zeile unter Ihrem Namen auf Rechnungen und Angeboten.",
     "addressPlaceholder": "Hauptstrasse 123\nEtage 1\n12345 Stadt",
     "saveCompany": "Firmendetails speichern",
     "addNewCompany": "Neue Firma hinzufugen",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "{count} Kunden nummeriert",
     "allCustomersNumbered": "Alle Kunden haben eine Nummer.",
     "assignCustomerNumbersConfirmTitle": "Kundennummern vergeben?",
-    "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden."
+    "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden.",
+    "layoutColorsHint": "Farben, Schriften und der Briefkopf-Stil liegen unter Vorlagen.",
+    "goToTemplates": "Vorlagen öffnen"
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",
     "addField": "Feld hinzufügen",
     "custom": "benutzerdefiniert",
     "full": "Voll",
+    "boxed": "Rahmen",
+    "plain": "Ohne",
+    "boxedHint": "Wird in einem Kasten gedruckt",
+    "plainHint": "Wird ohne Kasten gedruckt",
     "L": "L",
     "R": "R",
     "left": "links",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Volle Breite — Klicken für Halbbreite (links)",
     "sections": {
       "header": "Kopfzeile",
+      "slogan": "Slogan",
       "customer": "Kunde",
       "vehicle": "Fahrzeug",
       "service": "Service",
+      "document_title": "Belegtitel",
       "items_table": "Positionstabelle",
       "parts_table": "Teile",
       "labor_table": "Arbeit",
@@ -577,6 +588,7 @@
       "notes": "Notizen",
       "diagnostic_notes": "Diagnosenotizen",
       "findings": "Beobachtungen",
+      "warranty": "Garantie",
       "bank_account": "Bankkonto",
       "footer": "Fußzeile",
       "general": "Allgemein",
@@ -598,12 +610,54 @@
       "tech_name": "Techniker",
       "logo": "Logo",
       "company_name": "Firmenname",
+      "company_slogan": "Slogan",
       "company_address": "Adresse",
       "company_phone": "Telefon",
       "company_email": "E-Mail",
       "company_org_number": "Organisationsnummer",
       "bank_account": "Bankkonto",
+      "footer_note": "Fußzeilen-Hinweis",
       "org_number": "Organisationsnummer"
+    },
+    "presetsTitle": "Mit einer Anordnung beginnen",
+    "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
+    "presets": {
+      "classic": {
+        "name": "Klassisch",
+        "description": "Teile und Arbeit in getrennten Tabellen, Angaben links untereinander"
+      },
+      "letterhead": {
+        "name": "Briefkopf",
+        "description": "Kontaktdaten unten, Titel unter den Adressen, eine nummerierte Liste"
+      },
+      "compact": {
+        "name": "Kompakt",
+        "description": "Ohne Kästen, eine nummerierte Liste, nur das Nötigste"
+      },
+      "itemized": {
+        "name": "Positionsliste",
+        "description": "Eine nummerierte Positionsliste mit eigener Einheitenspalte"
+      },
+      "detailed": {
+        "name": "Ausführlich",
+        "description": "Titel oben, Fahrzeug für sich, beide Tabellen und alle optionalen Blöcke"
+      }
+    },
+    "style": {
+      "documentTitle": "Ganzes Blatt",
+      "documentHint": "Größe, Abstände, Akzent, Streifen",
+      "textColor": "Text",
+      "labelColor": "Überschrift",
+      "backgroundColor": "Füllung",
+      "borderColor": "Rahmen",
+      "fontSize": "Größe",
+      "rowPadding": "Zeilenhöhe",
+      "margin": "Rand",
+      "accentColor": "Akzent",
+      "stripeColor": "Streifen",
+      "stripes": "Streifen an",
+      "fontFamily": "Schrift",
+      "fontInherit": "Erben"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Vorlagen-Presets",
     "primaryColor": "Primärfarbe",
+    "colors": "Farben",
+    "textColor": "Text",
+    "textColorHint": "Die Farbe von Überschriften und Fließtext",
+    "companyTextColor": "Firmenname",
+    "companyTextColorHint": "Die Farbe des Namens im Briefkopf",
+    "frameBorderColor": "Rahmenlinie",
+    "frameBorderColorHint": "Die Linie, wo der Rahmen auf das Papier trifft",
+    "frameShadow": "Rahmenschatten",
+    "frameShadowOn": "Schatten",
+    "frameShadowOff": "Flach",
+    "backgroundColor": "Hintergrund",
+    "backgroundColorClear": "Zurücksetzen",
+    "backgroundColorHint": "Leer lassen für weißes Papier. Ein heller Ton druckt als getöntes Blatt.",
     "fontAndLayout": "Schrift & Layout",
     "fontFamily": "Schriftfamilie",
     "helveticaDefault": "Helvetica (Standard)",
@@ -630,6 +697,10 @@
     "standard": "Standard",
     "compact": "Kompakt",
     "modern": "Modern",
+    "framed": "Gerahmt",
+    "letterheadMark": "Briefkopf",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Firmenname",
     "preview": "{name} Vorschau",
     "billTo": "Rechnungsempfänger",
     "preparedFor": "Erstellt für",
@@ -699,7 +770,11 @@
     "layoutDescription": "Passen Sie die Reihenfolge und Sichtbarkeit der Abschnitte in Ihren Rechnungen und Angeboten an.",
     "layoutSaved": "Layout erfolgreich gespeichert",
     "layoutDocInvoice": "Rechnung",
-    "layoutDocQuote": "Angebot"
+    "layoutDocQuote": "Angebot",
+    "goToLayout": "Layout und Abschnitte",
+    "openDesigner": "Vorlagen-Designer öffnen",
+    "designerIntro": "Wählen Sie einen Ausgangspunkt und öffnen Sie den Designer, um alles zu ändern.",
+    "designerHint": "Farben, Schriften, Abstände und alle Abschnitte liegen im Designer."
   },
   "payment": {
     "readMore": "Mehr erfahren",
@@ -1635,5 +1710,178 @@
         "description": "Telegram-Nachrichten mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
       }
     }
+  },
+  "designer": {
+    "invoice": "Rechnung",
+    "quote": "Angebot",
+    "eyebrow": "Dokument-Designer",
+    "galleryTitle": "Mit einer Vorlage beginnen",
+    "galleryHint": "Wählen Sie einen Ausgangspunkt. Im Designer lässt sich alles ändern.",
+    "yourDesigns": "Ihre Designs",
+    "templates": "Vorlagen",
+    "inUse": "In Verwendung",
+    "savedOn": "Gespeichert am {date}",
+    "deleteDesignTitle": "Design löschen",
+    "deleteDesignBody": "Das Design „{name}“ löschen? Rechnung und Angebot behalten, was angewendet ist; nur die gespeicherte Kopie verschwindet.",
+    "delete": "Löschen",
+    "deleteDesign": "Dieses Design löschen",
+    "continueCurrent": "Mit meinem aktuellen Layout fortfahren",
+    "backToSettings": "Zurück zu den Einstellungen",
+    "useInvoiceDesign": "Rechnungsdesign übernehmen",
+    "copiedToQuote": "Rechnungsdesign auf das Angebot übertragen",
+    "rulers": "Lineale",
+    "saveDesign": "Design speichern",
+    "saveDesignHint": "Eine benannte Kopie dieses Designs behalten, um sie in der Vorlagengalerie wiederzufinden",
+    "reset": "Zurücksetzen",
+    "save": "Speichern",
+    "saving": "Wird gespeichert…",
+    "saved": "Gespeichert",
+    "couldNotSave": "Speichern nicht möglich",
+    "couldNotSaveDesigns": "Ihre Designs konnten nicht gespeichert werden",
+    "designUpdated": "„{name}“ aktualisiert",
+    "designSaved": "Als „{name}“ gespeichert",
+    "defaultDesignName": "Mein {doc}-Design",
+    "documentStyling": "Dokumentgestaltung",
+    "sections": "Abschnitte",
+    "sectionEmpty": "leer",
+    "sectionEmptyHint": "Wird angezeigt, hat mit den aktuellen Daten aber nichts zu drucken.",
+    "sectionShownHint": "Auf dem Blatt sichtbar. Klicken, um ihn auszublenden.",
+    "sectionHiddenHint": "Vom Blatt ausgeblendet. Klicken, um ihn anzuzeigen.",
+    "railHint": "Ziehen Sie einen Abschnitt auf die Seite: zwischen Blöcken abgelegt ändert er die Reihenfolge, auf einer Karte sitzt er daneben, und überall sonst wird er frei platziert. Die Seite macht Platz um ihn herum.",
+    "returnToFlow": "{section} in den Fluss zurückholen",
+    "saveDialogTitle": "Dieses Design speichern",
+    "saveDialogBody": "Behält eine benannte Kopie der aktuellen Anordnung und Optik. Sie finden sie unter Ihre Designs in der Vorlagengalerie wieder.",
+    "designNamePlaceholder": "Name des Designs",
+    "nameExists": "Ein Design mit diesem Namen existiert; Speichern aktualisiert es.",
+    "updateDesign": "Design aktualisieren",
+    "cancel": "Abbrechen",
+    "insertHere": "Hier einfügen",
+    "dropLeftColumn": "Linke Spalte",
+    "dropRightColumn": "Rechte Spalte",
+    "freePosition": "Freie Position",
+    "sectionSettings": "Abschnittseinstellungen",
+    "panelStyle": "Panelstil",
+    "variant": {
+      "accent": "Akzent",
+      "panel": "Panel",
+      "outline": "Kontur",
+      "lines": "Linien",
+      "hint": "Akzent tönt die Karte in der Primärfarbe. Panel entspricht den anderen Detailkarten. Kontur zeichnet nur einen Rahmen, und Linien druckt ohne Kasten."
+    },
+    "placement": "Platzierung",
+    "visible": "Sichtbar",
+    "columnFull": "Voll",
+    "columnLeft": "Links",
+    "columnRight": "Rechts",
+    "drawBox": "Kasten zeichnen",
+    "showHeading": "Überschrift zeigen",
+    "spacing": "Abstände",
+    "placedByHand": "Dieser Abschnitt ist von Hand platziert, Fluss-Abstände bewegen ihn also nicht.",
+    "placedByHandFramed": "Der gerahmte Briefkopf liegt auf dem Band; ziehen Sie ihn, um ihn zu bewegen.",
+    "placedByHandDrag": "Ziehen Sie ihn oder holen Sie ihn in den Fluss zurück, um Abstände zu nutzen.",
+    "marginTop": "Oben",
+    "marginBottom": "Unten",
+    "marginLeft": "Links",
+    "marginRight": "Rechts",
+    "spacingHint": "Punkte an freiem Raum, den der Abschnitt im Fluss um sich behält.",
+    "logo": "Logo",
+    "size": "Größe",
+    "logoHint": "Schalten Sie das Logo unter Felder aus, um stattdessen den Firmennamen zu drucken.",
+    "fields": "Felder",
+    "removeField": "Aus diesem Abschnitt entfernen",
+    "yourCustomFields": "Ihre eigenen Felder",
+    "appearance": "Aussehen",
+    "details": "Details",
+    "text": "Text",
+    "companyName": "Firmenname",
+    "heading": "Überschrift",
+    "fill": "Füllung",
+    "border": "Rahmen",
+    "lineWidth": "Linienbreite",
+    "borderWidth": "Rahmenbreite",
+    "outerBorder": "Äußerer Rahmen",
+    "rowBanding": "Zeilenstreifen",
+    "likeSheet": "Wie das Blatt",
+    "on": "An",
+    "off": "Aus",
+    "font": "Schrift",
+    "inherit": "Erben",
+    "auto": "auto",
+    "sectionDragHint": "Ziehen Sie diesen Abschnitt in der Liste oder auf der Seite, um ihn zu bewegen. Einmal von Hand platziert, verschieben ihn die Pfeiltasten um einen Punkt, mit Umschalt um zehn, und Escape bricht ein Ziehen ab.",
+    "document": "Dokument",
+    "documentSubtitle": "Gilt für das ganze Blatt",
+    "headerStyleTitle": "Kopfstil",
+    "headerStyle": {
+      "standard": {
+        "name": "Standard",
+        "desc": "Name und Details auf Weiß, Linie darunter"
+      },
+      "compact": {
+        "name": "Kompakt",
+        "desc": "Enger Kopf, dichte Zeilen"
+      },
+      "modern": {
+        "name": "Modern",
+        "desc": "Farbiges Banner über die volle Breite"
+      },
+      "framed": {
+        "name": "Gerahmt",
+        "desc": "Band oben, Leiste an der linken Seite"
+      }
+    },
+    "colors": "Farben",
+    "primary": "Primär",
+    "background": "Hintergrund",
+    "accent": "Akzent",
+    "frame": "Rahmen",
+    "rail": "Leiste",
+    "leftEdge": "Linke Kante",
+    "rightEdge": "Rechte Kante",
+    "frameLine": "Rahmenlinie",
+    "cornerRadius": "Eckenradius",
+    "shadow": "Schatten",
+    "shadowThin": "Dünn",
+    "shadowNormal": "Normal",
+    "shadowWide": "Breit",
+    "typography": "Typografie",
+    "baseSize": "Grundgröße",
+    "page": "Seite",
+    "margins": "Ränder",
+    "rowHeight": "Zeilenhöhe",
+    "band": "Streifen",
+    "sample": {
+      "itemLabor": "Vordere Bremsen erneuert",
+      "itemDisc": "Bremsscheibe, vorne",
+      "itemPads": "Bremsbelagsatz, Vorderachse",
+      "itemConsumables": "Verbrauchsmaterial",
+      "unitPcs": "Stk",
+      "unitSet": "Satz",
+      "unitEach": "x",
+      "findingPads": "Hintere Beläge bei 15 %",
+      "findingPadsNote": "Innerhalb von 5.000 km ersetzen",
+      "findingOilWeep": "Leichter Ölaustritt am Ventildeckel",
+      "notes": "Danke, dass Sie unsere Werkstatt gewählt haben. Melden Sie sich bei Fragen gerne.",
+      "warranty": "12 Monate Garantie auf Teile und Arbeit.",
+      "serviceTitle": "Jahresservice und Bremsen",
+      "serviceType": "Reparatur",
+      "companyName": "Ihre Werkstatt",
+      "slogan": "Qualität, auf die Sie sich verlassen können",
+      "footerNote": "Vielen Dank für Ihren Auftrag",
+      "value": "Beispiel"
+    }
+  },
+  "preview": {
+    "title": "Bremsenservice und Ölwechsel",
+    "serviceType": "Wartung",
+    "findingPads": "Hintere Bremsbeläge auf 15 % abgenutzt",
+    "findingPadsNote": "Wechsel innerhalb von 8.000 km empfohlen",
+    "findingOilLeak": "Leichter Ölaustritt an der Ventildeckeldichtung",
+    "invoiceNotes": "Vordere Bremsbeläge erneuert. Öl und Filter mit synthetischem 5W-30 gewechselt. Nächster Service bei 80.000 km empfohlen.",
+    "quoteNotes": "Wechsel der vorderen Bremsbeläge empfohlen. Öl- und Filterwechsel mit synthetischem 5W-30.",
+    "partBrakePads": "Bremsbeläge (vorne)",
+    "partOilFilter": "Ölfilter",
+    "partSyntheticOil": "Synthetiköl 5W-30",
+    "laborBrakeReplacement": "Bremsenwechsel",
+    "laborOilChange": "Ölwechsel"
   }
 }

+ 10 - 2
messages/en/pdf.json

@@ -22,6 +22,9 @@
     "items": "Items",
     "pos": "#",
     "unitOfMeasure": "Unit",
+    "invoiceNumberLabel": "Invoice No.",
+    "customerNumberLabel": "Customer No.",
+    "dateLabel": "Date",
     "total": "Total",
     "labor": "Labor & Services",
     "hours": "Hours",
@@ -67,7 +70,9 @@
     "viewPortal": "View your portal: {url}",
     "professionalWorkshop": "Professional Workshop Management",
     "tel": "Tel: {phone}",
-    "org": "Org: {org}"
+    "org": "Org: {org}",
+    "customFieldsTitle": "Additional Information",
+    "telegramConnect": "Chat with us on Telegram"
   },
   "quote": {
     "title": "QUOTE",
@@ -103,7 +108,10 @@
     "validityFooterUntil": "This quote is valid until {date}",
     "validityFooter30": "This quote is valid for 30 days",
     "viewPortal": "View your portal: {url}",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFieldsTitle": "Additional Information",
+    "quoteNumberLabel": "Quote No.",
+    "validUntilLabel": "Valid Until"
   },
   "inspection": {
     "title": "VEHICLE INSPECTION",

+ 250 - 2
messages/en/settings.json

@@ -161,6 +161,9 @@
     "email": "Email",
     "emailPlaceholder": "service@example.com",
     "address": "Address",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Independent service and repair, every make",
+    "sloganHint": "One line under your name on invoices and quotes.",
     "addressPlaceholder": "123 Main Street\nSuite 100\nCity, State 12345",
     "saveCompany": "Save Company Details",
     "addNewCompany": "Add New Company",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Assigned numbers to {count} customers",
     "allCustomersNumbered": "All customers have a number.",
     "assignCustomerNumbersConfirmTitle": "Assign customer numbers?",
-    "assignCustomerNumbersConfirmDescription": "This will give sequential numbers to the {count} customers that have no number yet, in the order they were created, continuing after your highest existing number. Customers that already have a number are not changed. Numbers can be edited afterwards."
+    "assignCustomerNumbersConfirmDescription": "This will give sequential numbers to the {count} customers that have no number yet, in the order they were created, continuing after your highest existing number. Customers that already have a number are not changed. Numbers can be edited afterwards.",
+    "layoutColorsHint": "Colors, fonts and the header style live in Templates.",
+    "goToTemplates": "Open Templates"
   },
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",
     "addField": "Add field",
     "custom": "custom",
     "full": "Full",
+    "boxed": "Box",
+    "plain": "Plain",
+    "boxedHint": "Prints inside a panel",
+    "plainHint": "Prints without a panel",
     "L": "L",
     "R": "R",
     "left": "left",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Full width — click to make half width (left)",
     "sections": {
       "header": "Header",
+      "slogan": "Slogan",
       "customer": "Customer",
       "vehicle": "Vehicle",
       "service": "Service",
+      "document_title": "Document Title",
       "items_table": "Items Table",
       "parts_table": "Parts Table",
       "labor_table": "Labor Table",
@@ -577,6 +588,7 @@
       "notes": "Notes",
       "diagnostic_notes": "Diagnostic Notes",
       "findings": "Observations",
+      "warranty": "Warranty",
       "bank_account": "Bank Account",
       "footer": "Footer",
       "general": "General",
@@ -598,12 +610,54 @@
       "tech_name": "Technician",
       "logo": "Logo",
       "company_name": "Company Name",
+      "company_slogan": "Slogan",
       "company_address": "Address",
       "company_phone": "Phone",
       "company_email": "Email",
       "company_org_number": "Organization Number",
       "bank_account": "Bank Account",
+      "footer_note": "Footer Note",
       "org_number": "Organization Number"
+    },
+    "presetsTitle": "Start from an arrangement",
+    "presetsHint": "Replaces the arrangement below. Nothing is saved until you save.",
+    "presets": {
+      "classic": {
+        "name": "Classic",
+        "description": "Parts and labor in separate tables, details stacked on the left"
+      },
+      "letterhead": {
+        "name": "Letterhead",
+        "description": "Contact details along the bottom, title below the addresses, one numbered list"
+      },
+      "compact": {
+        "name": "Compact",
+        "description": "No panels, one numbered list, only the essentials"
+      },
+      "itemized": {
+        "name": "Itemized",
+        "description": "One numbered item list with a unit column"
+      },
+      "detailed": {
+        "name": "Detailed",
+        "description": "Title up top, vehicle on its own, both tables and every optional block"
+      }
+    },
+    "style": {
+      "documentTitle": "Whole sheet",
+      "documentHint": "size, spacing, accent, banding",
+      "textColor": "Text",
+      "labelColor": "Heading",
+      "backgroundColor": "Fill",
+      "borderColor": "Border",
+      "fontSize": "Size",
+      "rowPadding": "Row height",
+      "margin": "Margin",
+      "accentColor": "Accent",
+      "stripeColor": "Band",
+      "stripes": "Banding",
+      "fontFamily": "Font",
+      "fontInherit": "Inherit"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Template Presets",
     "primaryColor": "Primary Color",
+    "colors": "Colors",
+    "textColor": "Text",
+    "textColorHint": "The color of headings and body text",
+    "companyTextColor": "Company name",
+    "companyTextColorHint": "The color of the name on the letterhead",
+    "frameBorderColor": "Frame line",
+    "frameBorderColorHint": "The line where the frame meets the paper",
+    "frameShadow": "Frame shadow",
+    "frameShadowOn": "Shadow",
+    "frameShadowOff": "Flat",
+    "backgroundColor": "Background",
+    "backgroundColorClear": "Clear",
+    "backgroundColorHint": "Leave empty for white paper. A light tint prints as a tinted sheet.",
     "fontAndLayout": "Font & Layout",
     "fontFamily": "Font Family",
     "helveticaDefault": "Helvetica (Default)",
@@ -630,6 +697,10 @@
     "standard": "Standard",
     "compact": "Compact",
     "modern": "Modern",
+    "framed": "Framed",
+    "letterheadMark": "Letterhead mark",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Company name",
     "preview": "{name} Preview",
     "billTo": "Bill To",
     "preparedFor": "Prepared For",
@@ -699,7 +770,11 @@
       "slate": "Slate",
       "rose": "Rose",
       "indigo": "Indigo"
-    }
+    },
+    "goToLayout": "Layout and sections",
+    "openDesigner": "Open Template Designer",
+    "designerIntro": "Pick a starting point, then open the designer to change anything.",
+    "designerHint": "Colors, fonts, spacing and every section live in the designer."
   },
   "payment": {
     "readMore": "Read More",
@@ -1635,5 +1710,178 @@
         "description": "Send and receive Telegram messages with your customers directly from Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Invoice",
+    "quote": "Quote",
+    "eyebrow": "Document designer",
+    "galleryTitle": "Start from a template",
+    "galleryHint": "Pick a starting point. You can change everything in the designer.",
+    "yourDesigns": "Your designs",
+    "templates": "Templates",
+    "inUse": "In use",
+    "savedOn": "Saved {date}",
+    "deleteDesignTitle": "Delete design",
+    "deleteDesignBody": "Delete the design \"{name}\"? The invoice and quote keep whatever is applied; only the saved copy goes.",
+    "delete": "Delete",
+    "deleteDesign": "Delete this design",
+    "continueCurrent": "Continue with my current layout",
+    "backToSettings": "Back to settings",
+    "useInvoiceDesign": "Use invoice design",
+    "copiedToQuote": "Invoice design copied to the quote",
+    "rulers": "Rulers",
+    "saveDesign": "Save design",
+    "saveDesignHint": "Keep a named copy of this design, to come back to from the template gallery",
+    "reset": "Reset",
+    "save": "Save",
+    "saving": "Saving…",
+    "saved": "Saved",
+    "couldNotSave": "Could not save",
+    "couldNotSaveDesigns": "Could not save your designs",
+    "designUpdated": "Updated \"{name}\"",
+    "designSaved": "Saved as \"{name}\"",
+    "defaultDesignName": "My {doc} design",
+    "documentStyling": "Document styling",
+    "sections": "Sections",
+    "sectionEmpty": "empty",
+    "sectionEmptyHint": "Shown, but there is nothing to print in it with the current data.",
+    "sectionShownHint": "Shown on the sheet. Click to hide it.",
+    "sectionHiddenHint": "Hidden from the sheet. Click to show it.",
+    "railHint": "Drag a section on the page: drop it between blocks to reorder, onto a card to sit beside it, or anywhere else to place it freely. The page makes room around it.",
+    "returnToFlow": "Return {section} to the flow",
+    "saveDialogTitle": "Save this design",
+    "saveDialogBody": "Keeps a named copy of the current arrangement and look. Find it again under Your designs in the template gallery.",
+    "designNamePlaceholder": "Design name",
+    "nameExists": "A design with this name exists; saving updates it.",
+    "updateDesign": "Update design",
+    "cancel": "Cancel",
+    "insertHere": "Insert here",
+    "dropLeftColumn": "Left column",
+    "dropRightColumn": "Right column",
+    "freePosition": "Free position",
+    "sectionSettings": "Section settings",
+    "panelStyle": "Panel style",
+    "variant": {
+      "accent": "Accent",
+      "panel": "Panel",
+      "outline": "Outline",
+      "lines": "Lines",
+      "hint": "Accent tints the card with the primary color. Panel matches the other detail cards. Outline draws only a border, and Lines prints without a box."
+    },
+    "placement": "Placement",
+    "visible": "Visible",
+    "columnFull": "Full",
+    "columnLeft": "Left",
+    "columnRight": "Right",
+    "drawBox": "Draw a box",
+    "showHeading": "Show heading",
+    "spacing": "Spacing",
+    "placedByHand": "This section is placed by hand, so flow spacing does not move it.",
+    "placedByHandFramed": "The framed letterhead sits on the band; drag it to move it.",
+    "placedByHandDrag": "Drag it, or return it to the flow, to use spacing.",
+    "marginTop": "Top",
+    "marginBottom": "Bottom",
+    "marginLeft": "Left",
+    "marginRight": "Right",
+    "spacingHint": "Points of empty room the section keeps around itself in the flow.",
+    "logo": "Logo",
+    "size": "Size",
+    "logoHint": "Turn the logo off under Fields to print the company name instead.",
+    "fields": "Fields",
+    "removeField": "Remove from this section",
+    "yourCustomFields": "Your custom fields",
+    "appearance": "Appearance",
+    "details": "Details",
+    "text": "Text",
+    "companyName": "Company name",
+    "heading": "Heading",
+    "fill": "Fill",
+    "border": "Border",
+    "lineWidth": "Line width",
+    "borderWidth": "Border width",
+    "outerBorder": "Outer border",
+    "rowBanding": "Row banding",
+    "likeSheet": "Like sheet",
+    "on": "On",
+    "off": "Off",
+    "font": "Font",
+    "inherit": "Inherit",
+    "auto": "auto",
+    "sectionDragHint": "Drag this section in the list, or on the page, to move it. Once placed by hand, the arrow keys nudge it a point at a time, ten with Shift, and Escape cancels a drag.",
+    "document": "Document",
+    "documentSubtitle": "Applies to the whole sheet",
+    "headerStyleTitle": "Header style",
+    "headerStyle": {
+      "standard": {
+        "name": "Standard",
+        "desc": "Name and details on white, rule beneath"
+      },
+      "compact": {
+        "name": "Compact",
+        "desc": "Tight header, dense rows"
+      },
+      "modern": {
+        "name": "Modern",
+        "desc": "Full-width colored banner"
+      },
+      "framed": {
+        "name": "Framed",
+        "desc": "Band across the top, rail down the left"
+      }
+    },
+    "colors": "Colors",
+    "primary": "Primary",
+    "background": "Background",
+    "accent": "Accent",
+    "frame": "Frame",
+    "rail": "Rail",
+    "leftEdge": "Left edge",
+    "rightEdge": "Right edge",
+    "frameLine": "Frame line",
+    "cornerRadius": "Corner radius",
+    "shadow": "Shadow",
+    "shadowThin": "Thin",
+    "shadowNormal": "Normal",
+    "shadowWide": "Wide",
+    "typography": "Typography",
+    "baseSize": "Base size",
+    "page": "Page",
+    "margins": "Margins",
+    "rowHeight": "Row height",
+    "band": "Band",
+    "sample": {
+      "itemLabor": "Front brakes replaced",
+      "itemDisc": "Brake disc, front",
+      "itemPads": "Brake pad set, front axle",
+      "itemConsumables": "Consumables",
+      "unitPcs": "pcs",
+      "unitSet": "set",
+      "unitEach": "x",
+      "findingPads": "Rear pads at 15%",
+      "findingPadsNote": "Replace within 5,000 km",
+      "findingOilWeep": "Small oil weep at valve cover",
+      "notes": "Thank you for choosing our workshop. Contact us if you have any questions.",
+      "warranty": "12 months warranty on parts and labour.",
+      "serviceTitle": "Annual service and brakes",
+      "serviceType": "Repair",
+      "companyName": "Your Workshop",
+      "slogan": "Quality service you can trust",
+      "footerNote": "Thank you for your business",
+      "value": "Sample"
+    }
+  },
+  "preview": {
+    "title": "Brake Service & Oil Change",
+    "serviceType": "Maintenance",
+    "findingPads": "Rear brake pads worn to 15%",
+    "findingPadsNote": "Recommend replacement within 5,000 miles",
+    "findingOilLeak": "Minor oil leak at valve cover gasket",
+    "invoiceNotes": "Front brake pads replaced. Oil and filter changed with synthetic 5W-30. Next service recommended at 50,000 miles.",
+    "quoteNotes": "Front brake pads replacement recommended. Oil and filter change with synthetic 5W-30.",
+    "partBrakePads": "Brake Pads (Front)",
+    "partOilFilter": "Oil Filter",
+    "partSyntheticOil": "Synthetic Oil 5W-30",
+    "laborBrakeReplacement": "Brake Replacement",
+    "laborOilChange": "Oil Change"
   }
 }

+ 10 - 2
messages/es/pdf.json

@@ -22,6 +22,9 @@
     "items": "Artículos",
     "pos": "N.º",
     "unitOfMeasure": "Unidad",
+    "invoiceNumberLabel": "N.º de factura",
+    "customerNumberLabel": "N.º de cliente",
+    "dateLabel": "Fecha",
     "total": "Total",
     "labor": "Mano de obra",
     "hours": "Horas",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Gestión profesional de talleres",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Información adicional",
+    "telegramConnect": "Chatea con nosotros en Telegram"
   },
   "quote": {
     "title": "PRESUPUESTO",
@@ -103,7 +108,10 @@
     "validityFooter30": "Este presupuesto es válido por 30 días",
     "viewPortal": "Ver su portal: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Información adicional",
+    "quoteNumberLabel": "N.º de presupuesto",
+    "validUntilLabel": "Válido hasta"
   },
   "inspection": {
     "title": "INSPECCIÓN DE VEHÍCULO",

+ 250 - 2
messages/es/settings.json

@@ -161,6 +161,9 @@
     "email": "Correo electrónico",
     "emailPlaceholder": "servicio@ejemplo.com",
     "address": "Dirección",
+    "slogan": "Eslogan",
+    "sloganPlaceholder": "Servicio y reparación para todas las marcas",
+    "sloganHint": "Una línea bajo tu nombre en facturas y presupuestos.",
     "addressPlaceholder": "Calle Principal 123\nPiso 1\n12345 Ciudad",
     "saveCompany": "Guardar datos de empresa",
     "addNewCompany": "Agregar nueva empresa",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Números asignados a {count} clientes",
     "allCustomersNumbered": "Todos los clientes tienen número.",
     "assignCustomerNumbersConfirmTitle": "¿Asignar números de cliente?",
-    "assignCustomerNumbersConfirmDescription": "Los {count} clientes sin número recibirán números secuenciales en el orden en que se crearon, continuando después del número existente más alto. Los clientes que ya tienen número no cambian. Los números se pueden editar después."
+    "assignCustomerNumbersConfirmDescription": "Los {count} clientes sin número recibirán números secuenciales en el orden en que se crearon, continuando después del número existente más alto. Los clientes que ya tienen número no cambian. Los números se pueden editar después.",
+    "layoutColorsHint": "Los colores, las fuentes y el estilo de encabezado están en Plantillas.",
+    "goToTemplates": "Abrir Plantillas"
   },
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",
     "addField": "Añadir campo",
     "custom": "personalizado",
     "full": "Completo",
+    "boxed": "Marco",
+    "plain": "Sin marco",
+    "boxedHint": "Se imprime dentro de un panel",
+    "plainHint": "Se imprime sin panel",
     "L": "I",
     "R": "D",
     "left": "izquierda",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Ancho completo — clic para medio ancho (izquierda)",
     "sections": {
       "header": "Encabezado",
+      "slogan": "Eslogan",
       "customer": "Cliente",
       "vehicle": "Vehículo",
       "service": "Servicio",
+      "document_title": "Título del documento",
       "items_table": "Tabla de artículos",
       "parts_table": "Piezas",
       "labor_table": "Mano de obra",
@@ -577,6 +588,7 @@
       "notes": "Notas",
       "diagnostic_notes": "Notas de diagnóstico",
       "findings": "Observaciones",
+      "warranty": "Garantía",
       "bank_account": "Cuenta bancaria",
       "footer": "Pie de página",
       "general": "General",
@@ -598,12 +610,54 @@
       "tech_name": "Técnico",
       "logo": "Logo",
       "company_name": "Nombre de empresa",
+      "company_slogan": "Eslogan",
       "company_address": "Dirección",
       "company_phone": "Teléfono",
       "company_email": "Correo electrónico",
       "company_org_number": "Número de organización",
       "bank_account": "Cuenta bancaria",
+      "footer_note": "Nota de pie",
       "org_number": "Número de organización"
+    },
+    "presetsTitle": "Empezar con una disposición",
+    "presetsHint": "Sustituye la disposición de abajo. No se guarda nada hasta que guardes.",
+    "presets": {
+      "classic": {
+        "name": "Clásica",
+        "description": "Piezas y mano de obra en tablas separadas, datos apilados a la izquierda"
+      },
+      "letterhead": {
+        "name": "Membrete",
+        "description": "Datos de contacto abajo, título bajo las direcciones, una lista numerada"
+      },
+      "compact": {
+        "name": "Compacta",
+        "description": "Sin paneles, una lista numerada, solo lo esencial"
+      },
+      "itemized": {
+        "name": "Detallado",
+        "description": "Una lista de posiciones numerada con columna de unidad"
+      },
+      "detailed": {
+        "name": "Detallada",
+        "description": "Título arriba, vehículo aparte, ambas tablas y todos los bloques opcionales"
+      }
+    },
+    "style": {
+      "documentTitle": "Hoja completa",
+      "documentHint": "tamaño, espaciado, acento, franjas",
+      "textColor": "Texto",
+      "labelColor": "Título",
+      "backgroundColor": "Relleno",
+      "borderColor": "Borde",
+      "fontSize": "Tamaño",
+      "rowPadding": "Alto de fila",
+      "margin": "Margen",
+      "accentColor": "Acento",
+      "stripeColor": "Franja",
+      "stripes": "Franjas",
+      "fontFamily": "Fuente",
+      "fontInherit": "Heredar"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Presets de plantilla",
     "primaryColor": "Color primario",
+    "colors": "Colores",
+    "textColor": "Texto",
+    "textColorHint": "El color de los títulos y del texto",
+    "companyTextColor": "Nombre de la empresa",
+    "companyTextColorHint": "El color del nombre en el membrete",
+    "frameBorderColor": "Línea del marco",
+    "frameBorderColorHint": "La línea donde el marco toca el papel",
+    "frameShadow": "Sombra del marco",
+    "frameShadowOn": "Con sombra",
+    "frameShadowOff": "Plano",
+    "backgroundColor": "Fondo",
+    "backgroundColorClear": "Restablecer",
+    "backgroundColorHint": "Déjalo vacío para papel blanco. Un tono claro se imprime como una hoja tintada.",
     "fontAndLayout": "Fuente y diseño",
     "fontFamily": "Familia tipográfica",
     "helveticaDefault": "Helvetica (Predeterminado)",
@@ -630,6 +697,10 @@
     "standard": "Estandar",
     "compact": "Compacto",
     "modern": "Moderno",
+    "framed": "Con marco",
+    "letterheadMark": "Membrete",
+    "letterheadLogo": "Logotipo",
+    "letterheadCompanyName": "Nombre de la empresa",
     "preview": "Vista previa de {name}",
     "billTo": "Facturar a",
     "preparedFor": "Preparado para",
@@ -699,7 +770,11 @@
     "layoutDescription": "Personaliza el orden y la visibilidad de las secciones en tus facturas y presupuestos.",
     "layoutSaved": "Disposición guardada correctamente",
     "layoutDocInvoice": "Factura",
-    "layoutDocQuote": "Presupuesto"
+    "layoutDocQuote": "Presupuesto",
+    "goToLayout": "Diseño y secciones",
+    "openDesigner": "Abrir el diseñador",
+    "designerIntro": "Elige un punto de partida y abre el diseñador para cambiar lo que quieras.",
+    "designerHint": "Los colores, las fuentes, los espacios y todas las secciones están en el diseñador."
   },
   "payment": {
     "readMore": "Leer más",
@@ -1635,5 +1710,178 @@
         "description": "Envía y recibe mensajes de Telegram con tus clientes desde Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Factura",
+    "quote": "Presupuesto",
+    "eyebrow": "Diseñador de documentos",
+    "galleryTitle": "Empieza con una plantilla",
+    "galleryHint": "Elige un punto de partida. Puedes cambiarlo todo en el diseñador.",
+    "yourDesigns": "Tus diseños",
+    "templates": "Plantillas",
+    "inUse": "En uso",
+    "savedOn": "Guardado el {date}",
+    "deleteDesignTitle": "Eliminar diseño",
+    "deleteDesignBody": "¿Eliminar el diseño «{name}»? La factura y el presupuesto conservan lo aplicado; solo desaparece la copia guardada.",
+    "delete": "Eliminar",
+    "deleteDesign": "Eliminar este diseño",
+    "continueCurrent": "Continuar con mi diseño actual",
+    "backToSettings": "Volver a ajustes",
+    "useInvoiceDesign": "Usar el diseño de la factura",
+    "copiedToQuote": "Diseño de la factura copiado al presupuesto",
+    "rulers": "Reglas",
+    "saveDesign": "Guardar diseño",
+    "saveDesignHint": "Guarda una copia con nombre de este diseño para volver a ella desde la galería de plantillas",
+    "reset": "Restablecer",
+    "save": "Guardar",
+    "saving": "Guardando…",
+    "saved": "Guardado",
+    "couldNotSave": "No se pudo guardar",
+    "couldNotSaveDesigns": "No se pudieron guardar tus diseños",
+    "designUpdated": "«{name}» actualizado",
+    "designSaved": "Guardado como «{name}»",
+    "defaultDesignName": "Mi diseño de {doc}",
+    "documentStyling": "Estilo del documento",
+    "sections": "Secciones",
+    "sectionEmpty": "vacía",
+    "sectionEmptyHint": "Se muestra, pero con los datos actuales no hay nada que imprimir en ella.",
+    "sectionShownHint": "Visible en la hoja. Haz clic para ocultarla.",
+    "sectionHiddenHint": "Oculta en la hoja. Haz clic para mostrarla.",
+    "railHint": "Arrastra una sección sobre la página: suéltala entre bloques para reordenar, sobre una tarjeta para colocarla al lado, o en cualquier otro sitio para situarla libremente. La página deja espacio a su alrededor.",
+    "returnToFlow": "Devolver {section} al flujo",
+    "saveDialogTitle": "Guardar este diseño",
+    "saveDialogBody": "Guarda una copia con nombre de la disposición y el aspecto actuales. La encontrarás en Tus diseños, dentro de la galería de plantillas.",
+    "designNamePlaceholder": "Nombre del diseño",
+    "nameExists": "Ya existe un diseño con este nombre; al guardar se actualizará.",
+    "updateDesign": "Actualizar diseño",
+    "cancel": "Cancelar",
+    "insertHere": "Insertar aquí",
+    "dropLeftColumn": "Columna izquierda",
+    "dropRightColumn": "Columna derecha",
+    "freePosition": "Posición libre",
+    "sectionSettings": "Ajustes de la sección",
+    "panelStyle": "Estilo del panel",
+    "variant": {
+      "accent": "Acento",
+      "panel": "Panel",
+      "outline": "Contorno",
+      "lines": "Líneas",
+      "hint": "Acento tiñe la tarjeta con el color principal. Panel iguala a las demás tarjetas de detalle. Contorno dibuja solo un borde, y Líneas imprime sin recuadro."
+    },
+    "placement": "Colocación",
+    "visible": "Visible",
+    "columnFull": "Completa",
+    "columnLeft": "Izquierda",
+    "columnRight": "Derecha",
+    "drawBox": "Dibujar un recuadro",
+    "showHeading": "Mostrar encabezado",
+    "spacing": "Espaciado",
+    "placedByHand": "Esta sección está colocada a mano, así que el espaciado del flujo no la mueve.",
+    "placedByHandFramed": "El membrete enmarcado se apoya en la banda; arrástralo para moverlo.",
+    "placedByHandDrag": "Arrástrala, o devuélvela al flujo, para usar el espaciado.",
+    "marginTop": "Arriba",
+    "marginBottom": "Abajo",
+    "marginLeft": "Izquierda",
+    "marginRight": "Derecha",
+    "spacingHint": "Puntos de espacio libre que la sección mantiene a su alrededor en el flujo.",
+    "logo": "Logotipo",
+    "size": "Tamaño",
+    "logoHint": "Desactiva el logotipo en Campos para imprimir el nombre de la empresa en su lugar.",
+    "fields": "Campos",
+    "removeField": "Quitar de esta sección",
+    "yourCustomFields": "Tus campos personalizados",
+    "appearance": "Apariencia",
+    "details": "Detalles",
+    "text": "Texto",
+    "companyName": "Nombre de la empresa",
+    "heading": "Encabezado",
+    "fill": "Relleno",
+    "border": "Borde",
+    "lineWidth": "Grosor de línea",
+    "borderWidth": "Grosor del borde",
+    "outerBorder": "Borde exterior",
+    "rowBanding": "Franjas de fila",
+    "likeSheet": "Como la hoja",
+    "on": "Sí",
+    "off": "No",
+    "font": "Fuente",
+    "inherit": "Heredar",
+    "auto": "auto",
+    "sectionDragHint": "Arrastra esta sección en la lista, o sobre la página, para moverla. Una vez colocada a mano, las flechas la desplazan un punto cada vez, diez con Mayús, y Escape cancela el arrastre.",
+    "document": "Documento",
+    "documentSubtitle": "Se aplica a toda la hoja",
+    "headerStyleTitle": "Estilo de encabezado",
+    "headerStyle": {
+      "standard": {
+        "name": "Estándar",
+        "desc": "Nombre y datos sobre blanco, con una línea debajo"
+      },
+      "compact": {
+        "name": "Compacto",
+        "desc": "Encabezado ajustado, filas densas"
+      },
+      "modern": {
+        "name": "Moderno",
+        "desc": "Banda de color a todo lo ancho"
+      },
+      "framed": {
+        "name": "Enmarcado",
+        "desc": "Banda arriba y franja por el lado izquierdo"
+      }
+    },
+    "colors": "Colores",
+    "primary": "Principal",
+    "background": "Fondo",
+    "accent": "Acento",
+    "frame": "Marco",
+    "rail": "Franja",
+    "leftEdge": "Borde izquierdo",
+    "rightEdge": "Borde derecho",
+    "frameLine": "Línea del marco",
+    "cornerRadius": "Radio de esquina",
+    "shadow": "Sombra",
+    "shadowThin": "Fina",
+    "shadowNormal": "Normal",
+    "shadowWide": "Ancha",
+    "typography": "Tipografía",
+    "baseSize": "Tamaño base",
+    "page": "Página",
+    "margins": "Márgenes",
+    "rowHeight": "Alto de fila",
+    "band": "Franja",
+    "sample": {
+      "itemLabor": "Frenos delanteros sustituidos",
+      "itemDisc": "Disco de freno, delantero",
+      "itemPads": "Juego de pastillas, eje delantero",
+      "itemConsumables": "Consumibles",
+      "unitPcs": "uds",
+      "unitSet": "juego",
+      "unitEach": "x",
+      "findingPads": "Pastillas traseras al 15 %",
+      "findingPadsNote": "Sustituir antes de 5.000 km",
+      "findingOilWeep": "Ligero rezume de aceite en la tapa de balancines",
+      "notes": "Gracias por elegir nuestro taller. Contacta con nosotros si tienes alguna duda.",
+      "warranty": "12 meses de garantía en piezas y mano de obra.",
+      "serviceTitle": "Revisión anual y frenos",
+      "serviceType": "Reparación",
+      "companyName": "Tu taller",
+      "slogan": "Calidad de servicio en la que puedes confiar",
+      "footerNote": "Gracias por confiar en nosotros",
+      "value": "Ejemplo"
+    }
+  },
+  "preview": {
+    "title": "Servicio de frenos y cambio de aceite",
+    "serviceType": "Mantenimiento",
+    "findingPads": "Pastillas traseras desgastadas al 15 %",
+    "findingPadsNote": "Se recomienda sustituirlas antes de 8.000 km",
+    "findingOilLeak": "Ligera fuga de aceite en la junta de la tapa de balancines",
+    "invoiceNotes": "Pastillas delanteras sustituidas. Aceite y filtro cambiados con sintético 5W-30. Próxima revisión recomendada a los 80.000 km.",
+    "quoteNotes": "Se recomienda sustituir las pastillas delanteras. Cambio de aceite y filtro con sintético 5W-30.",
+    "partBrakePads": "Pastillas de freno (delanteras)",
+    "partOilFilter": "Filtro de aceite",
+    "partSyntheticOil": "Aceite sintético 5W-30",
+    "laborBrakeReplacement": "Sustitución de frenos",
+    "laborOilChange": "Cambio de aceite"
   }
 }

+ 10 - 2
messages/fr/pdf.json

@@ -22,6 +22,9 @@
     "items": "Articles",
     "pos": "N°",
     "unitOfMeasure": "Unité",
+    "invoiceNumberLabel": "N° de facture",
+    "customerNumberLabel": "N° de client",
+    "dateLabel": "Date",
     "total": "Total",
     "labor": "Main-d'œuvre",
     "hours": "Heures",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Gestion professionnelle d'atelier",
     "tel": "Tél. : {phone}",
     "org": "Org. : {org}",
-    "qtyOrHours": "Unité / Heures"
+    "qtyOrHours": "Unité / Heures",
+    "customFieldsTitle": "Informations complémentaires",
+    "telegramConnect": "Discutez avec nous sur Telegram"
   },
   "quote": {
     "title": "DEVIS",
@@ -103,7 +108,10 @@
     "validityFooter30": "Ce devis est valable 30 jours",
     "viewPortal": "Consultez votre portail : {url}",
     "tel": "Tél. : {phone}",
-    "qtyOrHours": "Unité / Heures"
+    "qtyOrHours": "Unité / Heures",
+    "customFieldsTitle": "Informations complémentaires",
+    "quoteNumberLabel": "N° de devis",
+    "validUntilLabel": "Valable jusqu'au"
   },
   "inspection": {
     "title": "INSPECTION DU VÉHICULE",

+ 250 - 2
messages/fr/settings.json

@@ -161,6 +161,9 @@
     "email": "E-mail",
     "emailPlaceholder": "service@exemple.com",
     "address": "Adresse",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Entretien et réparation toutes marques",
+    "sloganHint": "Une ligne sous votre nom sur les factures et les devis.",
     "addressPlaceholder": "123 Rue Principale\nEtage 1\n75001 Paris",
     "saveCompany": "Enregistrer les détails",
     "addNewCompany": "Ajouter une entreprise",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Numéros attribués à {count} clients",
     "allCustomersNumbered": "Tous les clients ont un numéro.",
     "assignCustomerNumbersConfirmTitle": "Attribuer des numéros clients ?",
-    "assignCustomerNumbersConfirmDescription": "Les {count} clients sans numéro recevront des numéros séquentiels dans leur ordre de création, à la suite du numéro existant le plus élevé. Les clients ayant déjà un numéro ne sont pas modifiés. Les numéros restent modifiables ensuite."
+    "assignCustomerNumbersConfirmDescription": "Les {count} clients sans numéro recevront des numéros séquentiels dans leur ordre de création, à la suite du numéro existant le plus élevé. Les clients ayant déjà un numéro ne sont pas modifiés. Les numéros restent modifiables ensuite.",
+    "layoutColorsHint": "Les couleurs, les polices et le style d’en-tête sont dans Modèles.",
+    "goToTemplates": "Ouvrir Modèles"
   },
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",
     "addField": "Ajouter un champ",
     "custom": "personnalisé",
     "full": "Plein",
+    "boxed": "Cadre",
+    "plain": "Sans",
+    "boxedHint": "Imprimé dans un cadre",
+    "plainHint": "Imprimé sans cadre",
     "L": "G",
     "R": "D",
     "left": "gauche",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Pleine largeur — cliquer pour demi-largeur (gauche)",
     "sections": {
       "header": "En-tête",
+      "slogan": "Slogan",
       "customer": "Client",
       "vehicle": "Véhicule",
       "service": "Service",
+      "document_title": "Titre du document",
       "items_table": "Tableau des articles",
       "parts_table": "Pièces",
       "labor_table": "Main-d'œuvre",
@@ -577,6 +588,7 @@
       "notes": "Notes",
       "diagnostic_notes": "Notes de diagnostic",
       "findings": "Observations",
+      "warranty": "Garantie",
       "bank_account": "Compte bancaire",
       "footer": "Pied de page",
       "general": "Général",
@@ -598,12 +610,54 @@
       "tech_name": "Technicien",
       "logo": "Logo",
       "company_name": "Nom de l'entreprise",
+      "company_slogan": "Slogan",
       "company_address": "Adresse",
       "company_phone": "Téléphone",
       "company_email": "E-mail",
       "company_org_number": "Numéro d'organisation",
       "bank_account": "Compte bancaire",
+      "footer_note": "Note de bas de page",
       "org_number": "Numéro d'organisation"
+    },
+    "presetsTitle": "Partir d’une disposition",
+    "presetsHint": "Remplace la disposition ci-dessous. Rien n’est enregistré avant votre validation.",
+    "presets": {
+      "classic": {
+        "name": "Classique",
+        "description": "Pièces et main-d’œuvre en tableaux séparés, informations empilées à gauche"
+      },
+      "letterhead": {
+        "name": "En-tête",
+        "description": "Coordonnées en bas, titre sous les adresses, une liste numérotée"
+      },
+      "compact": {
+        "name": "Compacte",
+        "description": "Sans cadres, une liste numérotée, l’essentiel seulement"
+      },
+      "itemized": {
+        "name": "Détaillé",
+        "description": "Une liste de postes numérotée avec une colonne d'unité"
+      },
+      "detailed": {
+        "name": "Détaillée",
+        "description": "Titre en haut, véhicule seul, les deux tableaux et tous les blocs optionnels"
+      }
+    },
+    "style": {
+      "documentTitle": "Feuille entière",
+      "documentHint": "taille, espacement, accent, bandes",
+      "textColor": "Texte",
+      "labelColor": "Titre",
+      "backgroundColor": "Fond",
+      "borderColor": "Bordure",
+      "fontSize": "Taille",
+      "rowPadding": "Hauteur de ligne",
+      "margin": "Marge",
+      "accentColor": "Accent",
+      "stripeColor": "Bande",
+      "stripes": "Bandes",
+      "fontFamily": "Police",
+      "fontInherit": "Hériter"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Presets de modèle",
     "primaryColor": "Couleur primaire",
+    "colors": "Couleurs",
+    "textColor": "Texte",
+    "textColorHint": "La couleur des titres et du corps de texte",
+    "companyTextColor": "Nom de la société",
+    "companyTextColorHint": "La couleur du nom sur l’en-tête",
+    "frameBorderColor": "Filet du cadre",
+    "frameBorderColorHint": "Le filet où le cadre rencontre le papier",
+    "frameShadow": "Ombre du cadre",
+    "frameShadowOn": "Ombre",
+    "frameShadowOff": "Plat",
+    "backgroundColor": "Arrière-plan",
+    "backgroundColorClear": "Réinitialiser",
+    "backgroundColorHint": "Laissez vide pour du papier blanc. Une teinte claire imprime une feuille teintée.",
     "fontAndLayout": "Police et mise en page",
     "fontFamily": "Famille de police",
     "helveticaDefault": "Helvetica (Par défaut)",
@@ -630,6 +697,10 @@
     "standard": "Standard",
     "compact": "Compact",
     "modern": "Moderne",
+    "framed": "Encadré",
+    "letterheadMark": "En-tête",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Nom de la société",
     "preview": "Aperçu {name}",
     "billTo": "Facturer à",
     "preparedFor": "Préparé pour",
@@ -699,7 +770,11 @@
     "layoutDescription": "Personnalisez l'ordre et la visibilité des sections dans vos factures et devis.",
     "layoutSaved": "Mise en page enregistrée",
     "layoutDocInvoice": "Facture",
-    "layoutDocQuote": "Devis"
+    "layoutDocQuote": "Devis",
+    "goToLayout": "Disposition et sections",
+    "openDesigner": "Ouvrir le concepteur",
+    "designerIntro": "Choisissez un point de départ, puis ouvrez le concepteur pour tout modifier.",
+    "designerHint": "Les couleurs, les polices, les espacements et toutes les sections sont dans le concepteur."
   },
   "payment": {
     "readMore": "En savoir plus",
@@ -1635,5 +1710,178 @@
         "description": "Envoyez et recevez des messages Telegram avec vos clients depuis Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Facture",
+    "quote": "Devis",
+    "eyebrow": "Concepteur de documents",
+    "galleryTitle": "Partir d'un modèle",
+    "galleryHint": "Choisissez un point de départ. Vous pourrez tout modifier dans le concepteur.",
+    "yourDesigns": "Vos maquettes",
+    "templates": "Modèles",
+    "inUse": "Utilisée",
+    "savedOn": "Enregistrée le {date}",
+    "deleteDesignTitle": "Supprimer la maquette",
+    "deleteDesignBody": "Supprimer la maquette « {name} » ? La facture et le devis conservent ce qui est appliqué ; seule la copie enregistrée disparaît.",
+    "delete": "Supprimer",
+    "deleteDesign": "Supprimer cette maquette",
+    "continueCurrent": "Continuer avec ma mise en page actuelle",
+    "backToSettings": "Retour aux paramètres",
+    "useInvoiceDesign": "Reprendre la maquette de la facture",
+    "copiedToQuote": "Maquette de la facture copiée vers le devis",
+    "rulers": "Règles",
+    "saveDesign": "Enregistrer la maquette",
+    "saveDesignHint": "Conservez une copie nommée de cette maquette pour la retrouver dans la galerie de modèles",
+    "reset": "Réinitialiser",
+    "save": "Enregistrer",
+    "saving": "Enregistrement…",
+    "saved": "Enregistré",
+    "couldNotSave": "Enregistrement impossible",
+    "couldNotSaveDesigns": "Impossible d'enregistrer vos maquettes",
+    "designUpdated": "« {name} » mise à jour",
+    "designSaved": "Enregistrée sous « {name} »",
+    "defaultDesignName": "Ma maquette de {doc}",
+    "documentStyling": "Style du document",
+    "sections": "Sections",
+    "sectionEmpty": "vide",
+    "sectionEmptyHint": "Affichée, mais avec les données actuelles elle n'a rien à imprimer.",
+    "sectionShownHint": "Visible sur la feuille. Cliquez pour la masquer.",
+    "sectionHiddenHint": "Masquée sur la feuille. Cliquez pour l'afficher.",
+    "railHint": "Faites glisser une section sur la page : déposez-la entre deux blocs pour la réordonner, sur une carte pour la placer à côté, ou ailleurs pour la positionner librement. La page fait de la place autour d'elle.",
+    "returnToFlow": "Remettre {section} dans le flux",
+    "saveDialogTitle": "Enregistrer cette maquette",
+    "saveDialogBody": "Conserve une copie nommée de la disposition et de l'apparence actuelles. Vous la retrouverez sous Vos maquettes, dans la galerie de modèles.",
+    "designNamePlaceholder": "Nom de la maquette",
+    "nameExists": "Une maquette porte déjà ce nom ; l'enregistrement la mettra à jour.",
+    "updateDesign": "Mettre à jour la maquette",
+    "cancel": "Annuler",
+    "insertHere": "Insérer ici",
+    "dropLeftColumn": "Colonne de gauche",
+    "dropRightColumn": "Colonne de droite",
+    "freePosition": "Position libre",
+    "sectionSettings": "Réglages de la section",
+    "panelStyle": "Style du panneau",
+    "variant": {
+      "accent": "Accent",
+      "panel": "Panneau",
+      "outline": "Contour",
+      "lines": "Lignes",
+      "hint": "Accent teinte la carte avec la couleur principale. Panneau s'aligne sur les autres cartes de détail. Contour ne trace qu'une bordure, et Lignes imprime sans encadré."
+    },
+    "placement": "Placement",
+    "visible": "Visible",
+    "columnFull": "Pleine",
+    "columnLeft": "Gauche",
+    "columnRight": "Droite",
+    "drawBox": "Tracer un encadré",
+    "showHeading": "Afficher le titre",
+    "spacing": "Espacement",
+    "placedByHand": "Cette section est placée à la main : l'espacement du flux ne la déplace pas.",
+    "placedByHandFramed": "L'en-tête encadré repose sur le bandeau ; faites-le glisser pour le déplacer.",
+    "placedByHandDrag": "Faites-la glisser, ou remettez-la dans le flux, pour utiliser l'espacement.",
+    "marginTop": "Haut",
+    "marginBottom": "Bas",
+    "marginLeft": "Gauche",
+    "marginRight": "Droite",
+    "spacingHint": "Points d'espace vide que la section garde autour d'elle dans le flux.",
+    "logo": "Logo",
+    "size": "Taille",
+    "logoHint": "Désactivez le logo sous Champs pour imprimer le nom de l'entreprise à la place.",
+    "fields": "Champs",
+    "removeField": "Retirer de cette section",
+    "yourCustomFields": "Vos champs personnalisés",
+    "appearance": "Apparence",
+    "details": "Détails",
+    "text": "Texte",
+    "companyName": "Nom de l'entreprise",
+    "heading": "Titre",
+    "fill": "Remplissage",
+    "border": "Bordure",
+    "lineWidth": "Épaisseur de trait",
+    "borderWidth": "Épaisseur de bordure",
+    "outerBorder": "Bordure extérieure",
+    "rowBanding": "Alternance de lignes",
+    "likeSheet": "Comme la feuille",
+    "on": "Activé",
+    "off": "Désactivé",
+    "font": "Police",
+    "inherit": "Hériter",
+    "auto": "auto",
+    "sectionDragHint": "Faites glisser cette section dans la liste, ou sur la page, pour la déplacer. Une fois placée à la main, les flèches la décalent d'un point, de dix avec Maj, et Échap annule un déplacement.",
+    "document": "Document",
+    "documentSubtitle": "S'applique à toute la feuille",
+    "headerStyleTitle": "Style d'en-tête",
+    "headerStyle": {
+      "standard": {
+        "name": "Standard",
+        "desc": "Nom et coordonnées sur blanc, filet en dessous"
+      },
+      "compact": {
+        "name": "Compact",
+        "desc": "En-tête resserré, lignes denses"
+      },
+      "modern": {
+        "name": "Moderne",
+        "desc": "Bandeau coloré pleine largeur"
+      },
+      "framed": {
+        "name": "Encadré",
+        "desc": "Bandeau en haut, filet sur le bord gauche"
+      }
+    },
+    "colors": "Couleurs",
+    "primary": "Principale",
+    "background": "Fond",
+    "accent": "Accent",
+    "frame": "Cadre",
+    "rail": "Filet",
+    "leftEdge": "Bord gauche",
+    "rightEdge": "Bord droit",
+    "frameLine": "Trait du cadre",
+    "cornerRadius": "Rayon des angles",
+    "shadow": "Ombre",
+    "shadowThin": "Fine",
+    "shadowNormal": "Normale",
+    "shadowWide": "Large",
+    "typography": "Typographie",
+    "baseSize": "Taille de base",
+    "page": "Page",
+    "margins": "Marges",
+    "rowHeight": "Hauteur de ligne",
+    "band": "Bande",
+    "sample": {
+      "itemLabor": "Freins avant remplacés",
+      "itemDisc": "Disque de frein, avant",
+      "itemPads": "Jeu de plaquettes, essieu avant",
+      "itemConsumables": "Consommables",
+      "unitPcs": "pcs",
+      "unitSet": "jeu",
+      "unitEach": "x",
+      "findingPads": "Plaquettes arrière à 15 %",
+      "findingPadsNote": "À remplacer sous 5 000 km",
+      "findingOilWeep": "Léger suintement d'huile au cache-culbuteurs",
+      "notes": "Merci d'avoir choisi notre atelier. Contactez-nous pour toute question.",
+      "warranty": "12 mois de garantie sur les pièces et la main-d'œuvre.",
+      "serviceTitle": "Révision annuelle et freins",
+      "serviceType": "Réparation",
+      "companyName": "Votre atelier",
+      "slogan": "Un service de qualité en toute confiance",
+      "footerNote": "Merci de votre confiance",
+      "value": "Exemple"
+    }
+  },
+  "preview": {
+    "title": "Révision des freins et vidange",
+    "serviceType": "Entretien",
+    "findingPads": "Plaquettes arrière usées à 15 %",
+    "findingPadsNote": "Remplacement recommandé sous 8 000 km",
+    "findingOilLeak": "Léger suintement d'huile au joint de cache-culbuteurs",
+    "invoiceNotes": "Plaquettes avant remplacées. Huile et filtre changés avec de la synthèse 5W-30. Prochaine révision conseillée à 80 000 km.",
+    "quoteNotes": "Remplacement des plaquettes avant recommandé. Vidange et changement de filtre avec de la synthèse 5W-30.",
+    "partBrakePads": "Plaquettes de frein (avant)",
+    "partOilFilter": "Filtre à huile",
+    "partSyntheticOil": "Huile synthétique 5W-30",
+    "laborBrakeReplacement": "Remplacement des freins",
+    "laborOilChange": "Vidange"
   }
 }

+ 10 - 2
messages/it/pdf.json

@@ -22,6 +22,9 @@
     "items": "Voci",
     "pos": "N.",
     "unitOfMeasure": "Unità",
+    "invoiceNumberLabel": "N. fattura",
+    "customerNumberLabel": "N. cliente",
+    "dateLabel": "Data",
     "total": "Totale",
     "labor": "Manodopera",
     "hours": "Ore",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Gestione Officina Professionale",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informazioni aggiuntive",
+    "telegramConnect": "Scrivici su Telegram"
   },
   "quote": {
     "title": "PREVENTIVO",
@@ -103,7 +108,10 @@
     "validityFooter30": "Questo preventivo è valido per 30 giorni",
     "viewPortal": "Visualizza il tuo portale: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informazioni aggiuntive",
+    "quoteNumberLabel": "N. preventivo",
+    "validUntilLabel": "Valido fino al"
   },
   "inspection": {
     "title": "ISPEZIONE VEICOLO",

+ 250 - 2
messages/it/settings.json

@@ -161,6 +161,9 @@
     "email": "Email",
     "emailPlaceholder": "servizio@esempio.it",
     "address": "Indirizzo",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Assistenza e riparazione per tutte le marche",
+    "sloganHint": "Una riga sotto il nome su fatture e preventivi.",
     "addressPlaceholder": "Via Roma 123\nInterno 100\n00100 Roma",
     "saveCompany": "Salva dettagli aziendali",
     "addNewCompany": "Aggiungi nuova azienda",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Numeri assegnati a {count} clienti",
     "allCustomersNumbered": "Tutti i clienti hanno un numero.",
     "assignCustomerNumbersConfirmTitle": "Assegnare i numeri cliente?",
-    "assignCustomerNumbersConfirmDescription": "I {count} clienti senza numero riceveranno numeri sequenziali nell'ordine di creazione, proseguendo dal numero esistente più alto. I clienti che hanno già un numero non vengono modificati. I numeri restano modificabili."
+    "assignCustomerNumbersConfirmDescription": "I {count} clienti senza numero riceveranno numeri sequenziali nell'ordine di creazione, proseguendo dal numero esistente più alto. I clienti che hanno già un numero non vengono modificati. I numeri restano modificabili.",
+    "layoutColorsHint": "Colori, caratteri e stile dell’intestazione sono in Modelli.",
+    "goToTemplates": "Apri Modelli"
   },
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",
     "addField": "Aggiungi campo",
     "custom": "personalizzato",
     "full": "Intero",
+    "boxed": "Riquadro",
+    "plain": "Senza",
+    "boxedHint": "Stampato dentro un riquadro",
+    "plainHint": "Stampato senza riquadro",
     "L": "S",
     "R": "D",
     "left": "sinistra",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Larghezza intera — clicca per mezza larghezza (sinistra)",
     "sections": {
       "header": "Intestazione",
+      "slogan": "Slogan",
       "customer": "Cliente",
       "vehicle": "Veicolo",
       "service": "Servizio",
+      "document_title": "Titolo del documento",
       "items_table": "Tabella voci",
       "parts_table": "Ricambi",
       "labor_table": "Manodopera",
@@ -577,6 +588,7 @@
       "notes": "Note",
       "diagnostic_notes": "Note diagnostiche",
       "findings": "Osservazioni",
+      "warranty": "Garanzia",
       "bank_account": "Conto bancario",
       "footer": "Piè di pagina",
       "general": "Generale",
@@ -598,12 +610,54 @@
       "tech_name": "Tecnico",
       "logo": "Logo",
       "company_name": "Nome azienda",
+      "company_slogan": "Slogan",
       "company_address": "Indirizzo",
       "company_phone": "Telefono",
       "company_email": "E-mail",
       "company_org_number": "Numero organizzazione",
       "bank_account": "Conto bancario",
+      "footer_note": "Nota a piè di pagina",
       "org_number": "Numero organizzazione"
+    },
+    "presetsTitle": "Partire da una disposizione",
+    "presetsHint": "Sostituisce la disposizione qui sotto. Nulla viene salvato finché non salvi.",
+    "presets": {
+      "classic": {
+        "name": "Classica",
+        "description": "Ricambi e manodopera in tabelle separate, dati incolonnati a sinistra"
+      },
+      "letterhead": {
+        "name": "Carta intestata",
+        "description": "Contatti in fondo, titolo sotto gli indirizzi, un elenco numerato"
+      },
+      "compact": {
+        "name": "Compatta",
+        "description": "Senza riquadri, un elenco numerato, solo l’essenziale"
+      },
+      "itemized": {
+        "name": "Dettagliato",
+        "description": "Un unico elenco numerato di voci con la colonna unità"
+      },
+      "detailed": {
+        "name": "Dettagliata",
+        "description": "Titolo in alto, veicolo da solo, entrambe le tabelle e ogni blocco opzionale"
+      }
+    },
+    "style": {
+      "documentTitle": "Intero foglio",
+      "documentHint": "dimensione, spaziatura, accento, bande",
+      "textColor": "Testo",
+      "labelColor": "Titolo",
+      "backgroundColor": "Sfondo",
+      "borderColor": "Bordo",
+      "fontSize": "Dimensione",
+      "rowPadding": "Altezza riga",
+      "margin": "Margine",
+      "accentColor": "Accento",
+      "stripeColor": "Banda",
+      "stripes": "Bande",
+      "fontFamily": "Carattere",
+      "fontInherit": "Eredita"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Preset modello",
     "primaryColor": "Colore primario",
+    "colors": "Colori",
+    "textColor": "Testo",
+    "textColorHint": "Il colore di titoli e testo",
+    "companyTextColor": "Nome azienda",
+    "companyTextColorHint": "Il colore del nome sull’intestazione",
+    "frameBorderColor": "Filetto della cornice",
+    "frameBorderColorHint": "Il filetto dove la cornice incontra la carta",
+    "frameShadow": "Ombra della cornice",
+    "frameShadowOn": "Ombra",
+    "frameShadowOff": "Piatto",
+    "backgroundColor": "Sfondo",
+    "backgroundColorClear": "Reimposta",
+    "backgroundColorHint": "Lascia vuoto per la carta bianca. Una tinta chiara stampa un foglio colorato.",
     "fontAndLayout": "Font e Layout",
     "fontFamily": "Famiglia font",
     "helveticaDefault": "Helvetica (Predefinito)",
@@ -630,6 +697,10 @@
     "standard": "Standard",
     "compact": "Compatto",
     "modern": "Moderno",
+    "framed": "Con cornice",
+    "letterheadMark": "Intestazione",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Nome azienda",
     "preview": "Anteprima {name}",
     "billTo": "Fatturare a",
     "preparedFor": "Preparato per",
@@ -699,7 +770,11 @@
     "layoutDescription": "Personalizza l'ordine e la visibilita delle sezioni nelle fatture e nei preventivi.",
     "layoutSaved": "Layout salvato con successo",
     "layoutDocInvoice": "Fattura",
-    "layoutDocQuote": "Preventivo"
+    "layoutDocQuote": "Preventivo",
+    "goToLayout": "Disposizione e sezioni",
+    "openDesigner": "Apri il designer",
+    "designerIntro": "Scegli un punto di partenza, poi apri il designer per cambiare tutto.",
+    "designerHint": "Colori, caratteri, spaziature e tutte le sezioni sono nel designer."
   },
   "payment": {
     "readMore": "Scopri di più",
@@ -1635,5 +1710,178 @@
         "description": "Invia e ricevi messaggi Telegram con i tuoi clienti da Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Fattura",
+    "quote": "Preventivo",
+    "eyebrow": "Designer di documenti",
+    "galleryTitle": "Parti da un modello",
+    "galleryHint": "Scegli un punto di partenza. Nel designer puoi cambiare tutto.",
+    "yourDesigns": "I tuoi design",
+    "templates": "Modelli",
+    "inUse": "In uso",
+    "savedOn": "Salvato il {date}",
+    "deleteDesignTitle": "Elimina design",
+    "deleteDesignBody": "Eliminare il design «{name}»? La fattura e il preventivo mantengono ciò che è applicato; sparisce solo la copia salvata.",
+    "delete": "Elimina",
+    "deleteDesign": "Elimina questo design",
+    "continueCurrent": "Continua con il mio layout attuale",
+    "backToSettings": "Torna alle impostazioni",
+    "useInvoiceDesign": "Usa il design della fattura",
+    "copiedToQuote": "Design della fattura copiato nel preventivo",
+    "rulers": "Righelli",
+    "saveDesign": "Salva design",
+    "saveDesignHint": "Conserva una copia con un nome di questo design, per ritrovarla nella galleria dei modelli",
+    "reset": "Reimposta",
+    "save": "Salva",
+    "saving": "Salvataggio…",
+    "saved": "Salvato",
+    "couldNotSave": "Impossibile salvare",
+    "couldNotSaveDesigns": "Impossibile salvare i tuoi design",
+    "designUpdated": "«{name}» aggiornato",
+    "designSaved": "Salvato come «{name}»",
+    "defaultDesignName": "Il mio design di {doc}",
+    "documentStyling": "Stile del documento",
+    "sections": "Sezioni",
+    "sectionEmpty": "vuota",
+    "sectionEmptyHint": "Viene mostrata, ma con i dati attuali non ha nulla da stampare.",
+    "sectionShownHint": "Visibile sul foglio. Fai clic per nasconderla.",
+    "sectionHiddenHint": "Nascosta dal foglio. Fai clic per mostrarla.",
+    "railHint": "Trascina una sezione sulla pagina: rilasciala tra due blocchi per riordinare, su una scheda per affiancarla, oppure altrove per posizionarla liberamente. La pagina le fa spazio intorno.",
+    "returnToFlow": "Riporta {section} nel flusso",
+    "saveDialogTitle": "Salva questo design",
+    "saveDialogBody": "Conserva una copia con un nome della disposizione e dell'aspetto attuali. La ritrovi in I tuoi design, nella galleria dei modelli.",
+    "designNamePlaceholder": "Nome del design",
+    "nameExists": "Esiste già un design con questo nome; salvando lo aggiorni.",
+    "updateDesign": "Aggiorna design",
+    "cancel": "Annulla",
+    "insertHere": "Inserisci qui",
+    "dropLeftColumn": "Colonna sinistra",
+    "dropRightColumn": "Colonna destra",
+    "freePosition": "Posizione libera",
+    "sectionSettings": "Impostazioni della sezione",
+    "panelStyle": "Stile del pannello",
+    "variant": {
+      "accent": "Accento",
+      "panel": "Pannello",
+      "outline": "Contorno",
+      "lines": "Linee",
+      "hint": "Accento colora la scheda con il colore principale. Pannello la allinea alle altre schede di dettaglio. Contorno disegna solo un bordo, e Linee stampa senza riquadro."
+    },
+    "placement": "Posizione",
+    "visible": "Visibile",
+    "columnFull": "Intera",
+    "columnLeft": "Sinistra",
+    "columnRight": "Destra",
+    "drawBox": "Disegna un riquadro",
+    "showHeading": "Mostra intestazione",
+    "spacing": "Spaziatura",
+    "placedByHand": "Questa sezione è posizionata a mano, quindi la spaziatura del flusso non la sposta.",
+    "placedByHandFramed": "La carta intestata incorniciata poggia sulla fascia; trascinala per spostarla.",
+    "placedByHandDrag": "Trascinala, oppure riportala nel flusso, per usare la spaziatura.",
+    "marginTop": "Alto",
+    "marginBottom": "Basso",
+    "marginLeft": "Sinistra",
+    "marginRight": "Destra",
+    "spacingHint": "Punti di spazio vuoto che la sezione mantiene attorno a sé nel flusso.",
+    "logo": "Logo",
+    "size": "Dimensione",
+    "logoHint": "Disattiva il logo in Campi per stampare al suo posto il nome dell'azienda.",
+    "fields": "Campi",
+    "removeField": "Rimuovi da questa sezione",
+    "yourCustomFields": "I tuoi campi personalizzati",
+    "appearance": "Aspetto",
+    "details": "Dettagli",
+    "text": "Testo",
+    "companyName": "Nome azienda",
+    "heading": "Intestazione",
+    "fill": "Riempimento",
+    "border": "Bordo",
+    "lineWidth": "Spessore linea",
+    "borderWidth": "Spessore bordo",
+    "outerBorder": "Bordo esterno",
+    "rowBanding": "Righe alternate",
+    "likeSheet": "Come il foglio",
+    "on": "Sì",
+    "off": "No",
+    "font": "Carattere",
+    "inherit": "Eredita",
+    "auto": "auto",
+    "sectionDragHint": "Trascina questa sezione nell'elenco, o sulla pagina, per spostarla. Una volta posizionata a mano, le frecce la spostano di un punto per volta, dieci con Maiusc, ed Esc annulla il trascinamento.",
+    "document": "Documento",
+    "documentSubtitle": "Vale per l'intero foglio",
+    "headerStyleTitle": "Stile intestazione",
+    "headerStyle": {
+      "standard": {
+        "name": "Standard",
+        "desc": "Nome e recapiti su bianco, con un filetto sotto"
+      },
+      "compact": {
+        "name": "Compatto",
+        "desc": "Intestazione stretta, righe fitte"
+      },
+      "modern": {
+        "name": "Moderno",
+        "desc": "Fascia colorata a tutta larghezza"
+      },
+      "framed": {
+        "name": "Incorniciato",
+        "desc": "Fascia in alto e filetto sul lato sinistro"
+      }
+    },
+    "colors": "Colori",
+    "primary": "Principale",
+    "background": "Sfondo",
+    "accent": "Accento",
+    "frame": "Cornice",
+    "rail": "Filetto",
+    "leftEdge": "Bordo sinistro",
+    "rightEdge": "Bordo destro",
+    "frameLine": "Linea della cornice",
+    "cornerRadius": "Raggio degli angoli",
+    "shadow": "Ombra",
+    "shadowThin": "Sottile",
+    "shadowNormal": "Normale",
+    "shadowWide": "Ampia",
+    "typography": "Tipografia",
+    "baseSize": "Dimensione base",
+    "page": "Pagina",
+    "margins": "Margini",
+    "rowHeight": "Altezza riga",
+    "band": "Fascia",
+    "sample": {
+      "itemLabor": "Freni anteriori sostituiti",
+      "itemDisc": "Disco freno, anteriore",
+      "itemPads": "Kit pastiglie, asse anteriore",
+      "itemConsumables": "Materiali di consumo",
+      "unitPcs": "pz",
+      "unitSet": "kit",
+      "unitEach": "x",
+      "findingPads": "Pastiglie posteriori al 15%",
+      "findingPadsNote": "Sostituire entro 5.000 km",
+      "findingOilWeep": "Lieve trafilamento d'olio dal coperchio punterie",
+      "notes": "Grazie per aver scelto la nostra officina. Contattaci per qualsiasi domanda.",
+      "warranty": "12 mesi di garanzia su ricambi e manodopera.",
+      "serviceTitle": "Tagliando annuale e freni",
+      "serviceType": "Riparazione",
+      "companyName": "La tua officina",
+      "slogan": "Un servizio di qualità di cui fidarsi",
+      "footerNote": "Grazie per averci scelto",
+      "value": "Esempio"
+    }
+  },
+  "preview": {
+    "title": "Servizio freni e cambio olio",
+    "serviceType": "Manutenzione",
+    "findingPads": "Pastiglie posteriori usurate al 15%",
+    "findingPadsNote": "Se ne consiglia la sostituzione entro 8.000 km",
+    "findingOilLeak": "Lieve perdita d'olio dalla guarnizione del coperchio punterie",
+    "invoiceNotes": "Pastiglie anteriori sostituite. Olio e filtro cambiati con sintetico 5W-30. Prossimo tagliando consigliato a 80.000 km.",
+    "quoteNotes": "Si consiglia la sostituzione delle pastiglie anteriori. Cambio olio e filtro con sintetico 5W-30.",
+    "partBrakePads": "Pastiglie freno (anteriori)",
+    "partOilFilter": "Filtro olio",
+    "partSyntheticOil": "Olio sintetico 5W-30",
+    "laborBrakeReplacement": "Sostituzione freni",
+    "laborOilChange": "Cambio olio"
   }
 }

+ 10 - 2
messages/lt/pdf.json

@@ -22,6 +22,9 @@
     "items": "Pozicijos",
     "pos": "Nr.",
     "unitOfMeasure": "Mato vnt.",
+    "invoiceNumberLabel": "Sąskaitos Nr.",
+    "customerNumberLabel": "Kliento Nr.",
+    "dateLabel": "Data",
     "total": "Suma",
     "labor": "Darbai ir paslaugos",
     "hours": "Valandos",
@@ -67,7 +70,9 @@
     "viewPortal": "Peržiūrėkite savo portalą: {url}",
     "professionalWorkshop": "Profesionalus dirbtuvių valdymas",
     "tel": "Tel.: {phone}",
-    "org": "Įm. kodas: {org}"
+    "org": "Įm. kodas: {org}",
+    "customFieldsTitle": "Papildoma informacija",
+    "telegramConnect": "Susisiekite su mumis per Telegram"
   },
   "quote": {
     "title": "PASIŪLYMAS",
@@ -103,7 +108,10 @@
     "validityFooterUntil": "Šis pasiūlymas galioja iki {date}",
     "validityFooter30": "Šis pasiūlymas galioja 30 dienų",
     "viewPortal": "Peržiūrėkite savo portalą: {url}",
-    "tel": "Tel.: {phone}"
+    "tel": "Tel.: {phone}",
+    "customFieldsTitle": "Papildoma informacija",
+    "quoteNumberLabel": "Pasiūlymo nr.",
+    "validUntilLabel": "Galioja iki"
   },
   "inspection": {
     "title": "TRANSPORTO PRIEMONĖS APŽIŪRA",

+ 250 - 2
messages/lt/settings.json

@@ -161,6 +161,9 @@
     "email": "El. paštas",
     "emailPlaceholder": "servisas@example.com",
     "address": "Adresas",
+    "slogan": "Šūkis",
+    "sloganPlaceholder": "Visų markių aptarnavimas ir remontas",
+    "sloganHint": "Viena eilutė po pavadinimu sąskaitose ir pasiūlymuose.",
     "addressPlaceholder": "Gatvės g. 1\nKab. 100\nMiestas, LT-12345",
     "saveCompany": "Išsaugoti įmonės informaciją",
     "addNewCompany": "Pridėti naują įmonę",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Numeriai priskirti {count} klientams",
     "allCustomersNumbered": "Visi klientai turi numerį.",
     "assignCustomerNumbersConfirmTitle": "Priskirti klientų numerius?",
-    "assignCustomerNumbersConfirmDescription": "{count} klientams be numerio bus priskirti eilės numeriai sukūrimo tvarka, tęsiant nuo didžiausio esamo numerio. Klientai, jau turintys numerį, nekeičiami. Numerius galima redaguoti vėliau."
+    "assignCustomerNumbersConfirmDescription": "{count} klientams be numerio bus priskirti eilės numeriai sukūrimo tvarka, tęsiant nuo didžiausio esamo numerio. Klientai, jau turintys numerį, nekeičiami. Numerius galima redaguoti vėliau.",
+    "layoutColorsHint": "Spalvos, šriftai ir antraštės stilius yra skiltyje Šablonai.",
+    "goToTemplates": "Atverti šablonus"
   },
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",
     "addField": "Pridėti lauką",
     "custom": "pasirinktinis",
     "full": "Pilnas",
+    "boxed": "Rėmelis",
+    "plain": "Be rėmelio",
+    "boxedHint": "Spausdinama rėmelyje",
+    "plainHint": "Spausdinama be rėmelio",
     "L": "K",
     "R": "D",
     "left": "kairė",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Pilnas plotis — spustelėkite, kad padarytumėte pusės pločio (kairė)",
     "sections": {
       "header": "Antraštė",
+      "slogan": "Šūkis",
       "customer": "Klientas",
       "vehicle": "Transporto priemonė",
       "service": "Paslauga",
+      "document_title": "Dokumento antraštė",
       "items_table": "Pozicijų lentelė",
       "parts_table": "Dalių lentelė",
       "labor_table": "Darbų lentelė",
@@ -577,6 +588,7 @@
       "notes": "Pastabos",
       "diagnostic_notes": "Diagnostikos pastabos",
       "findings": "Pastebėjimai",
+      "warranty": "Garantija",
       "bank_account": "Banko sąskaita",
       "footer": "Poraštė",
       "general": "Bendra",
@@ -598,12 +610,54 @@
       "tech_name": "Technikas",
       "logo": "Logotipas",
       "company_name": "Įmonės pavadinimas",
+      "company_slogan": "Šūkis",
       "company_address": "Adresas",
       "company_phone": "Telefonas",
       "company_email": "El. paštas",
       "company_org_number": "Įmonės kodas",
       "bank_account": "Banko sąskaita",
+      "footer_note": "Poraštės pastaba",
       "org_number": "Įmonės kodas"
+    },
+    "presetsTitle": "Pradėti nuo išdėstymo",
+    "presetsHint": "Pakeičia žemiau esantį išdėstymą. Niekas neįrašoma, kol neišsaugote.",
+    "presets": {
+      "classic": {
+        "name": "Klasikinis",
+        "description": "Dalys ir darbas atskirose lentelėse, duomenys kairėje"
+      },
+      "letterhead": {
+        "name": "Firminis blankas",
+        "description": "Kontaktai apačioje, antraštė po adresais, vienas numeruotas sąrašas"
+      },
+      "compact": {
+        "name": "Kompaktiškas",
+        "description": "Be rėmelių, vienas numeruotas sąrašas, tik būtiniausia"
+      },
+      "itemized": {
+        "name": "Detalizuotas",
+        "description": "Vienas numeruotas eilučių sąrašas su mato vieneto stulpeliu"
+      },
+      "detailed": {
+        "name": "Išsamus",
+        "description": "Antraštė viršuje, transporto priemonė atskirai, abi lentelės ir visi blokai"
+      }
+    },
+    "style": {
+      "documentTitle": "Visas lapas",
+      "documentHint": "dydis, tarpai, akcentas, juostos",
+      "textColor": "Tekstas",
+      "labelColor": "Antraštė",
+      "backgroundColor": "Užpildas",
+      "borderColor": "Rėmelis",
+      "fontSize": "Dydis",
+      "rowPadding": "Eilutės aukštis",
+      "margin": "Paraštė",
+      "accentColor": "Akcentas",
+      "stripeColor": "Juosta",
+      "stripes": "Juostos",
+      "fontFamily": "Šriftas",
+      "fontInherit": "Paveldėti"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Šablonų presetai",
     "primaryColor": "Pagrindinė spalva",
+    "colors": "Spalvos",
+    "textColor": "Tekstas",
+    "textColorHint": "Antraščių ir teksto spalva",
+    "companyTextColor": "Įmonės pavadinimas",
+    "companyTextColorHint": "Pavadinimo spalva firminiame blanke",
+    "frameBorderColor": "Rėmelio linija",
+    "frameBorderColorHint": "Linija ten, kur rėmelis liečia popierių",
+    "frameShadow": "Rėmelio šešėlis",
+    "frameShadowOn": "Su šešėliu",
+    "frameShadowOff": "Plokščias",
+    "backgroundColor": "Fonas",
+    "backgroundColorClear": "Išvalyti",
+    "backgroundColorHint": "Palikite tuščią baltam popieriui. Šviesus atspalvis spausdinamas kaip tonuotas lapas.",
     "fontAndLayout": "Šriftas ir išdėstymas",
     "fontFamily": "Šrifto šeima",
     "helveticaDefault": "Helvetica (numatytasis)",
@@ -630,6 +697,10 @@
     "standard": "Standartinis",
     "compact": "Kompaktiškas",
     "modern": "Modernus",
+    "framed": "Rėmelis",
+    "letterheadMark": "Firminis ženklas",
+    "letterheadLogo": "Logotipas",
+    "letterheadCompanyName": "Įmonės pavadinimas",
     "preview": "{name} peržiūra",
     "billTo": "Gavėjas",
     "preparedFor": "Paruošta",
@@ -699,7 +770,11 @@
       "slate": "Šiferinė",
       "rose": "Rožinė",
       "indigo": "Indigo"
-    }
+    },
+    "goToLayout": "Išdėstymas ir skiltys",
+    "openDesigner": "Atverti dizainerį",
+    "designerIntro": "Pasirinkite pradžios tašką ir atverkite dizainerį, kad pakeistumėte viską.",
+    "designerHint": "Spalvos, šriftai, tarpai ir visos skiltys yra dizaineryje."
   },
   "payment": {
     "readMore": "Skaityti daugiau",
@@ -1635,5 +1710,178 @@
         "description": "Siųskite ir gaukite „Telegram“ žinutes su klientais tiesiai iš „Torqvoice“."
       }
     }
+  },
+  "designer": {
+    "invoice": "Sąskaita",
+    "quote": "Pasiūlymas",
+    "eyebrow": "Dokumentų kūrimo įrankis",
+    "galleryTitle": "Pradėkite nuo šablono",
+    "galleryHint": "Pasirinkite atspirties tašką. Kūrimo įrankyje galėsite pakeisti viską.",
+    "yourDesigns": "Jūsų dizainai",
+    "templates": "Šablonai",
+    "inUse": "Naudojamas",
+    "savedOn": "Išsaugota {date}",
+    "deleteDesignTitle": "Ištrinti dizainą",
+    "deleteDesignBody": "Ištrinti dizainą „{name}“? Sąskaita ir pasiūlymas išlaikys tai, kas pritaikyta; dings tik išsaugota kopija.",
+    "delete": "Ištrinti",
+    "deleteDesign": "Ištrinti šį dizainą",
+    "continueCurrent": "Tęsti su dabartiniu išdėstymu",
+    "backToSettings": "Grįžti į nustatymus",
+    "useInvoiceDesign": "Naudoti sąskaitos dizainą",
+    "copiedToQuote": "Sąskaitos dizainas nukopijuotas į pasiūlymą",
+    "rulers": "Liniuotės",
+    "saveDesign": "Išsaugoti dizainą",
+    "saveDesignHint": "Išsaugokite pavadintą šio dizaino kopiją, kad grįžtumėte prie jos iš šablonų galerijos",
+    "reset": "Atstatyti",
+    "save": "Išsaugoti",
+    "saving": "Išsaugoma…",
+    "saved": "Išsaugota",
+    "couldNotSave": "Nepavyko išsaugoti",
+    "couldNotSaveDesigns": "Nepavyko išsaugoti jūsų dizainų",
+    "designUpdated": "Atnaujinta „{name}“",
+    "designSaved": "Išsaugota kaip „{name}“",
+    "defaultDesignName": "Mano {doc} dizainas",
+    "documentStyling": "Dokumento stilius",
+    "sections": "Sekcijos",
+    "sectionEmpty": "tuščia",
+    "sectionEmptyHint": "Rodoma, bet su dabartiniais duomenimis joje nėra ko spausdinti.",
+    "sectionShownHint": "Matoma lape. Spustelėkite, kad paslėptumėte.",
+    "sectionHiddenHint": "Paslėpta lape. Spustelėkite, kad parodytumėte.",
+    "railHint": "Vilkite sekciją ant puslapio: paleiskite tarp blokų, kad pakeistumėte eiliškumą, ant kortelės, kad atsidurtų šalia jos, arba bet kur kitur, kad padėtumėte laisvai. Puslapis paliks vietos aplink ją.",
+    "returnToFlow": "Grąžinti {section} į srautą",
+    "saveDialogTitle": "Išsaugoti šį dizainą",
+    "saveDialogBody": "Išsaugo pavadintą dabartinio išdėstymo ir išvaizdos kopiją. Rasite ją skiltyje Jūsų dizainai, šablonų galerijoje.",
+    "designNamePlaceholder": "Dizaino pavadinimas",
+    "nameExists": "Dizainas tokiu pavadinimu jau yra; išsaugojus jis bus atnaujintas.",
+    "updateDesign": "Atnaujinti dizainą",
+    "cancel": "Atšaukti",
+    "insertHere": "Įterpti čia",
+    "dropLeftColumn": "Kairysis stulpelis",
+    "dropRightColumn": "Dešinysis stulpelis",
+    "freePosition": "Laisva vieta",
+    "sectionSettings": "Sekcijos nustatymai",
+    "panelStyle": "Skydelio stilius",
+    "variant": {
+      "accent": "Akcentas",
+      "panel": "Skydelis",
+      "outline": "Kontūras",
+      "lines": "Linijos",
+      "hint": "Akcentas nuspalvina kortelę pagrindine spalva. Skydelis suvienodina ją su kitomis detalių kortelėmis. Kontūras piešia tik rėmelį, o Linijos spausdina be rėmelio."
+    },
+    "placement": "Išdėstymas",
+    "visible": "Matoma",
+    "columnFull": "Visas plotis",
+    "columnLeft": "Kairė",
+    "columnRight": "Dešinė",
+    "drawBox": "Piešti rėmelį",
+    "showHeading": "Rodyti antraštę",
+    "spacing": "Tarpai",
+    "placedByHand": "Ši sekcija padėta ranka, todėl srauto tarpai jos nepajudina.",
+    "placedByHandFramed": "Įrėmintas firminis blankas guli ant juostos; vilkite jį, kad pastumtumėte.",
+    "placedByHandDrag": "Vilkite ją arba grąžinkite į srautą, kad galėtumėte naudoti tarpus.",
+    "marginTop": "Viršus",
+    "marginBottom": "Apačia",
+    "marginLeft": "Kairė",
+    "marginRight": "Dešinė",
+    "spacingHint": "Tuščios vietos punktai, kuriuos sekcija išlaiko aplink save sraute.",
+    "logo": "Logotipas",
+    "size": "Dydis",
+    "logoHint": "Išjunkite logotipą skiltyje Laukai, kad vietoj jo būtų spausdinamas įmonės pavadinimas.",
+    "fields": "Laukai",
+    "removeField": "Pašalinti iš šios sekcijos",
+    "yourCustomFields": "Jūsų pasirinktiniai laukai",
+    "appearance": "Išvaizda",
+    "details": "Detalės",
+    "text": "Tekstas",
+    "companyName": "Įmonės pavadinimas",
+    "heading": "Antraštė",
+    "fill": "Užpildas",
+    "border": "Rėmelis",
+    "lineWidth": "Linijos storis",
+    "borderWidth": "Rėmelio storis",
+    "outerBorder": "Išorinis rėmelis",
+    "rowBanding": "Eilučių juostos",
+    "likeSheet": "Kaip lape",
+    "on": "Įjungta",
+    "off": "Išjungta",
+    "font": "Šriftas",
+    "inherit": "Paveldėti",
+    "auto": "auto",
+    "sectionDragHint": "Vilkite šią sekciją sąraše arba ant puslapio, kad ją perkeltumėte. Padėjus ranka, rodyklių klavišai pastumia ją vienu punktu, su Shift dešimčia, o Escape atšaukia vilkimą.",
+    "document": "Dokumentas",
+    "documentSubtitle": "Taikoma visam lapui",
+    "headerStyleTitle": "Antraštės stilius",
+    "headerStyle": {
+      "standard": {
+        "name": "Standartinis",
+        "desc": "Pavadinimas ir duomenys ant balto, brūkšnys apačioje"
+      },
+      "compact": {
+        "name": "Kompaktiškas",
+        "desc": "Siaura antraštė, tankios eilutės"
+      },
+      "modern": {
+        "name": "Modernus",
+        "desc": "Spalvota juosta per visą plotį"
+      },
+      "framed": {
+        "name": "Įrėmintas",
+        "desc": "Juosta viršuje, juostelė kairiajame krašte"
+      }
+    },
+    "colors": "Spalvos",
+    "primary": "Pagrindinė",
+    "background": "Fonas",
+    "accent": "Akcentas",
+    "frame": "Rėmelis",
+    "rail": "Juostelė",
+    "leftEdge": "Kairysis kraštas",
+    "rightEdge": "Dešinysis kraštas",
+    "frameLine": "Rėmelio linija",
+    "cornerRadius": "Kampų apvalinimas",
+    "shadow": "Šešėlis",
+    "shadowThin": "Plonas",
+    "shadowNormal": "Įprastas",
+    "shadowWide": "Platus",
+    "typography": "Tipografija",
+    "baseSize": "Bazinis dydis",
+    "page": "Puslapis",
+    "margins": "Paraštės",
+    "rowHeight": "Eilutės aukštis",
+    "band": "Juosta",
+    "sample": {
+      "itemLabor": "Pakeisti priekiniai stabdžiai",
+      "itemDisc": "Stabdžių diskas, priekinis",
+      "itemPads": "Kaladėlių komplektas, priekinė ašis",
+      "itemConsumables": "Eksploatacinės medžiagos",
+      "unitPcs": "vnt.",
+      "unitSet": "kompl.",
+      "unitEach": "x",
+      "findingPads": "Galinės kaladėlės 15 %",
+      "findingPadsNote": "Pakeisti per 5 000 km",
+      "findingOilWeep": "Nedidelis alyvos sunkimasis ties vožtuvų dangteliu",
+      "notes": "Ačiū, kad pasirinkote mūsų servisą. Kilus klausimų, susisiekite su mumis.",
+      "warranty": "12 mėnesių garantija dalims ir darbui.",
+      "serviceTitle": "Metinė apžiūra ir stabdžiai",
+      "serviceType": "Remontas",
+      "companyName": "Jūsų servisas",
+      "slogan": "Kokybiškas aptarnavimas, kuriuo galite pasitikėti",
+      "footerNote": "Ačiū, kad renkatės mus",
+      "value": "Pavyzdys"
+    }
+  },
+  "preview": {
+    "title": "Stabdžių aptarnavimas ir tepalų keitimas",
+    "serviceType": "Techninė priežiūra",
+    "findingPads": "Galinės stabdžių kaladėlės nusidėvėjusios iki 15 %",
+    "findingPadsNote": "Rekomenduojama pakeisti per 8 000 km",
+    "findingOilLeak": "Nedidelis alyvos sunkimasis ties vožtuvų dangtelio tarpine",
+    "invoiceNotes": "Pakeistos priekinės stabdžių kaladėlės. Alyva ir filtras pakeisti sintetine 5W-30. Kitą apžiūrą rekomenduojame ties 80 000 km.",
+    "quoteNotes": "Rekomenduojame pakeisti priekines stabdžių kaladėles. Alyvos ir filtro keitimas sintetine 5W-30.",
+    "partBrakePads": "Stabdžių kaladėlės (priekinės)",
+    "partOilFilter": "Alyvos filtras",
+    "partSyntheticOil": "Sintetinė alyva 5W-30",
+    "laborBrakeReplacement": "Stabdžių keitimas",
+    "laborOilChange": "Alyvos keitimas"
   }
 }

+ 10 - 2
messages/nb/pdf.json

@@ -22,6 +22,9 @@
     "items": "Poster",
     "pos": "Nr.",
     "unitOfMeasure": "Enhet",
+    "invoiceNumberLabel": "Fakturanr.",
+    "customerNumberLabel": "Kundenr.",
+    "dateLabel": "Dato",
     "total": "Totalt",
     "labor": "Arbeid",
     "hours": "Timer",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Profesjonell verkstedadministrasjon",
     "tel": "Tlf: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Tilleggsinformasjon",
+    "telegramConnect": "Chat med oss på Telegram"
   },
   "quote": {
     "title": "TILBUD",
@@ -103,7 +108,10 @@
     "validityFooter30": "Dette tilbudet er gyldig i 30 dager",
     "viewPortal": "Se portalen din: {url}",
     "tel": "Tlf: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Tilleggsinformasjon",
+    "quoteNumberLabel": "Tilbudsnr.",
+    "validUntilLabel": "Gyldig til"
   },
   "inspection": {
     "title": "KJØRETØYKONTROLL",

+ 250 - 2
messages/nb/settings.json

@@ -161,6 +161,9 @@
     "email": "E-post",
     "emailPlaceholder": "service@eksempel.no",
     "address": "Adresse",
+    "slogan": "Slagord",
+    "sloganPlaceholder": "Service og reparasjon for alle merker",
+    "sloganHint": "Én linje under navnet på fakturaer og tilbud.",
     "addressPlaceholder": "Hovedgaten 123\n0001 Oslo",
     "saveCompany": "Lagre bedriftsdetaljer",
     "addNewCompany": "Legg til ny bedrift",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Tildelte numre til {count} kunder",
     "allCustomersNumbered": "Alle kunder har et nummer.",
     "assignCustomerNumbersConfirmTitle": "Tildele kundenumre?",
-    "assignCustomerNumbersConfirmDescription": "De {count} kundene uten nummer får fortløpende numre i opprettelsesrekkefølge, fortsettende etter det høyeste eksisterende nummeret. Kunder som allerede har nummer endres ikke. Numrene kan redigeres etterpå."
+    "assignCustomerNumbersConfirmDescription": "De {count} kundene uten nummer får fortløpende numre i opprettelsesrekkefølge, fortsettende etter det høyeste eksisterende nummeret. Kunder som allerede har nummer endres ikke. Numrene kan redigeres etterpå.",
+    "layoutColorsHint": "Farger, skrifter og brevhodestilen ligger under Maler.",
+    "goToTemplates": "Åpne Maler"
   },
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",
     "addField": "Legg til felt",
     "custom": "egendefinert",
     "full": "Full",
+    "boxed": "Ramme",
+    "plain": "Uten",
+    "boxedHint": "Skrives ut i en ramme",
+    "plainHint": "Skrives ut uten ramme",
     "L": "V",
     "R": "H",
     "left": "venstre",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Full bredde — klikk for halvbredde (venstre)",
     "sections": {
       "header": "Topptekst",
+      "slogan": "Slagord",
       "customer": "Kunde",
       "vehicle": "Kjøretøy",
       "service": "Tjeneste",
+      "document_title": "Dokumenttittel",
       "items_table": "Posttabell",
       "parts_table": "Deler",
       "labor_table": "Arbeid",
@@ -577,6 +588,7 @@
       "notes": "Notater",
       "diagnostic_notes": "Diagnostiske notater",
       "findings": "Observasjoner",
+      "warranty": "Garanti",
       "bank_account": "Bankkonto",
       "footer": "Bunntekst",
       "general": "Generelt",
@@ -598,12 +610,54 @@
       "tech_name": "Tekniker",
       "logo": "Logo",
       "company_name": "Firmanavn",
+      "company_slogan": "Slagord",
       "company_address": "Adresse",
       "company_phone": "Telefon",
       "company_email": "E-post",
       "company_org_number": "Organisasjonsnummer",
       "bank_account": "Bankkonto",
+      "footer_note": "Bunntekstnotat",
       "org_number": "Organisasjonsnummer"
+    },
+    "presetsTitle": "Start fra et oppsett",
+    "presetsHint": "Erstatter oppsettet under. Ingenting lagres før du lagrer.",
+    "presets": {
+      "classic": {
+        "name": "Klassisk",
+        "description": "Deler og arbeid i hver sin tabell, opplysninger stablet til venstre"
+      },
+      "letterhead": {
+        "name": "Brevhode",
+        "description": "Kontaktinfo nederst, tittel under adressene, én nummerert liste"
+      },
+      "compact": {
+        "name": "Kompakt",
+        "description": "Uten rammer, én nummerert liste, bare det nødvendige"
+      },
+      "itemized": {
+        "name": "Spesifisert",
+        "description": "Én nummerert liste med egen kolonne for enhet"
+      },
+      "detailed": {
+        "name": "Detaljert",
+        "description": "Tittel øverst, kjøretøyet for seg, begge tabeller og alle valgfrie blokker"
+      }
+    },
+    "style": {
+      "documentTitle": "Hele arket",
+      "documentHint": "størrelse, avstand, aksent, striper",
+      "textColor": "Tekst",
+      "labelColor": "Overskrift",
+      "backgroundColor": "Fyll",
+      "borderColor": "Ramme",
+      "fontSize": "Størrelse",
+      "rowPadding": "Radhøyde",
+      "margin": "Marg",
+      "accentColor": "Aksent",
+      "stripeColor": "Stripe",
+      "stripes": "Striper",
+      "fontFamily": "Skrift",
+      "fontInherit": "Arv"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Malforhåndsvisninger",
     "primaryColor": "Primærfarge",
+    "colors": "Farger",
+    "textColor": "Tekst",
+    "textColorHint": "Fargen på overskrifter og brødtekst",
+    "companyTextColor": "Firmanavn",
+    "companyTextColorHint": "Fargen på navnet i brevhodet",
+    "frameBorderColor": "Rammelinje",
+    "frameBorderColorHint": "Linjen der rammen møter papiret",
+    "frameShadow": "Rammeskygge",
+    "frameShadowOn": "Skygge",
+    "frameShadowOff": "Flat",
+    "backgroundColor": "Bakgrunn",
+    "backgroundColorClear": "Nullstill",
+    "backgroundColorHint": "La stå tomt for hvitt papir. En lys tone skrives ut som et tonet ark.",
     "fontAndLayout": "Skrift og layout",
     "fontFamily": "Skriftfamilie",
     "helveticaDefault": "Helvetica (Standard)",
@@ -630,6 +697,10 @@
     "standard": "Standard",
     "compact": "Kompakt",
     "modern": "Moderne",
+    "framed": "Innrammet",
+    "letterheadMark": "Brevhode",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Firmanavn",
     "preview": "{name} forhåndsvisning",
     "billTo": "Faktureres til",
     "preparedFor": "Utarbeidet for",
@@ -699,7 +770,11 @@
       "slate": "Skifer",
       "rose": "Rosa",
       "indigo": "Indigo"
-    }
+    },
+    "goToLayout": "Oppsett og seksjoner",
+    "openDesigner": "Åpne designeren",
+    "designerIntro": "Velg et utgangspunkt, og åpne designeren for å endre alt.",
+    "designerHint": "Farger, skrifter, avstander og alle seksjoner ligger i designeren."
   },
   "payment": {
     "readMore": "Les mer",
@@ -1635,5 +1710,178 @@
         "description": "Send og motta Telegram-meldinger med kundene dine rett fra Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Faktura",
+    "quote": "Tilbud",
+    "eyebrow": "Dokumentdesigner",
+    "galleryTitle": "Start fra en mal",
+    "galleryHint": "Velg et utgangspunkt. Du kan endre alt i designeren.",
+    "yourDesigns": "Dine design",
+    "templates": "Maler",
+    "inUse": "I bruk",
+    "savedOn": "Lagret {date}",
+    "deleteDesignTitle": "Slett design",
+    "deleteDesignBody": "Slette designet «{name}»? Fakturaen og tilbudet beholder det som er tatt i bruk; bare den lagrede kopien forsvinner.",
+    "delete": "Slett",
+    "deleteDesign": "Slett dette designet",
+    "continueCurrent": "Fortsett med oppsettet mitt",
+    "backToSettings": "Tilbake til innstillinger",
+    "useInvoiceDesign": "Bruk fakturadesignet",
+    "copiedToQuote": "Fakturadesignet er kopiert til tilbudet",
+    "rulers": "Linjaler",
+    "saveDesign": "Lagre design",
+    "saveDesignHint": "Ta vare på en navngitt kopi av dette designet, slik at du finner det igjen i malgalleriet",
+    "reset": "Nullstill",
+    "save": "Lagre",
+    "saving": "Lagrer…",
+    "saved": "Lagret",
+    "couldNotSave": "Kunne ikke lagre",
+    "couldNotSaveDesigns": "Kunne ikke lagre designene dine",
+    "designUpdated": "Oppdaterte «{name}»",
+    "designSaved": "Lagret som «{name}»",
+    "defaultDesignName": "Mitt {doc}design",
+    "documentStyling": "Dokumentstil",
+    "sections": "Seksjoner",
+    "sectionEmpty": "tom",
+    "sectionEmptyHint": "Vises, men det er ingenting å skrive ut i den med dagens data.",
+    "sectionShownHint": "Vises på arket. Klikk for å skjule den.",
+    "sectionHiddenHint": "Skjult fra arket. Klikk for å vise den.",
+    "railHint": "Dra en seksjon på siden: slipp den mellom blokker for å endre rekkefølge, på et kort for å ligge ved siden av det, eller hvor som helst ellers for å plassere den fritt. Siden gir plass rundt den.",
+    "returnToFlow": "Sett {section} tilbake i flyten",
+    "saveDialogTitle": "Lagre dette designet",
+    "saveDialogBody": "Tar vare på en navngitt kopi av dagens oppsett og utseende. Du finner den igjen under Dine design i malgalleriet.",
+    "designNamePlaceholder": "Navn på design",
+    "nameExists": "Et design med dette navnet finnes; lagring oppdaterer det.",
+    "updateDesign": "Oppdater design",
+    "cancel": "Avbryt",
+    "insertHere": "Sett inn her",
+    "dropLeftColumn": "Venstre kolonne",
+    "dropRightColumn": "Høyre kolonne",
+    "freePosition": "Fri plassering",
+    "sectionSettings": "Seksjonsinnstillinger",
+    "panelStyle": "Panelstil",
+    "variant": {
+      "accent": "Aksent",
+      "panel": "Panel",
+      "outline": "Kontur",
+      "lines": "Linjer",
+      "hint": "Aksent toner kortet med primærfargen. Panel matcher de andre detaljkortene. Kontur tegner bare en ramme, og Linjer skriver ut uten boks."
+    },
+    "placement": "Plassering",
+    "visible": "Synlig",
+    "columnFull": "Full",
+    "columnLeft": "Venstre",
+    "columnRight": "Høyre",
+    "drawBox": "Tegn en boks",
+    "showHeading": "Vis overskrift",
+    "spacing": "Avstand",
+    "placedByHand": "Denne seksjonen er plassert for hånd, så avstand i flyten flytter den ikke.",
+    "placedByHandFramed": "Den innrammede brevhodet ligger på båndet; dra det for å flytte det.",
+    "placedByHandDrag": "Dra den, eller sett den tilbake i flyten, for å bruke avstand.",
+    "marginTop": "Topp",
+    "marginBottom": "Bunn",
+    "marginLeft": "Venstre",
+    "marginRight": "Høyre",
+    "spacingHint": "Punkter med tomrom seksjonen holder rundt seg i flyten.",
+    "logo": "Logo",
+    "size": "Størrelse",
+    "logoHint": "Slå av logoen under Felter for å skrive ut firmanavnet i stedet.",
+    "fields": "Felter",
+    "removeField": "Fjern fra denne seksjonen",
+    "yourCustomFields": "Dine egendefinerte felter",
+    "appearance": "Utseende",
+    "details": "Detaljer",
+    "text": "Tekst",
+    "companyName": "Firmanavn",
+    "heading": "Overskrift",
+    "fill": "Fyll",
+    "border": "Ramme",
+    "lineWidth": "Linjebredde",
+    "borderWidth": "Rammebredde",
+    "outerBorder": "Ytre ramme",
+    "rowBanding": "Radstriper",
+    "likeSheet": "Som arket",
+    "on": "På",
+    "off": "Av",
+    "font": "Skrift",
+    "inherit": "Arv",
+    "auto": "auto",
+    "sectionDragHint": "Dra denne seksjonen i listen, eller på siden, for å flytte den. Når den er plassert for hånd, flytter piltastene den ett punkt om gangen, ti med Shift, og Escape avbryter et dra.",
+    "document": "Dokument",
+    "documentSubtitle": "Gjelder hele arket",
+    "headerStyleTitle": "Topptekststil",
+    "headerStyle": {
+      "standard": {
+        "name": "Standard",
+        "desc": "Navn og detaljer på hvitt, med strek under"
+      },
+      "compact": {
+        "name": "Kompakt",
+        "desc": "Stram topp, tette rader"
+      },
+      "modern": {
+        "name": "Moderne",
+        "desc": "Farget banner i full bredde"
+      },
+      "framed": {
+        "name": "Innrammet",
+        "desc": "Bånd over toppen, stripe ned venstre side"
+      }
+    },
+    "colors": "Farger",
+    "primary": "Primær",
+    "background": "Bakgrunn",
+    "accent": "Aksent",
+    "frame": "Ramme",
+    "rail": "Stripe",
+    "leftEdge": "Venstre kant",
+    "rightEdge": "Høyre kant",
+    "frameLine": "Rammelinje",
+    "cornerRadius": "Hjørneradius",
+    "shadow": "Skygge",
+    "shadowThin": "Tynn",
+    "shadowNormal": "Normal",
+    "shadowWide": "Bred",
+    "typography": "Typografi",
+    "baseSize": "Grunnstørrelse",
+    "page": "Side",
+    "margins": "Marger",
+    "rowHeight": "Radhøyde",
+    "band": "Stripe",
+    "sample": {
+      "itemLabor": "Bremser foran skiftet",
+      "itemDisc": "Bremseskive, foran",
+      "itemPads": "Bremseklosser, foraksel",
+      "itemConsumables": "Forbruksmateriell",
+      "unitPcs": "stk",
+      "unitSet": "sett",
+      "unitEach": "x",
+      "findingPads": "Bakre klosser på 15 %",
+      "findingPadsNote": "Skiftes innen 5 000 km",
+      "findingOilWeep": "Liten oljelekkasje ved ventildeksel",
+      "notes": "Takk for at du valgte verkstedet vårt. Ta kontakt hvis du har spørsmål.",
+      "warranty": "12 måneders garanti på deler og arbeid.",
+      "serviceTitle": "Årsservice og bremser",
+      "serviceType": "Reparasjon",
+      "companyName": "Ditt verksted",
+      "slogan": "Kvalitetsservice du kan stole på",
+      "footerNote": "Takk for handelen",
+      "value": "Eksempel"
+    }
+  },
+  "preview": {
+    "title": "Bremseservice og oljeskift",
+    "serviceType": "Vedlikehold",
+    "findingPads": "Bakre bremseklosser slitt til 15 %",
+    "findingPadsNote": "Anbefaler bytte innen 8 000 km",
+    "findingOilLeak": "Liten oljelekkasje ved ventildekselpakning",
+    "invoiceNotes": "Fremre bremseklosser er skiftet. Olje og filter byttet med syntetisk 5W-30. Neste service anbefales ved 80 000 km.",
+    "quoteNotes": "Bytte av fremre bremseklosser anbefales. Olje- og filterskift med syntetisk 5W-30.",
+    "partBrakePads": "Bremseklosser (foran)",
+    "partOilFilter": "Oljefilter",
+    "partSyntheticOil": "Syntetisk olje 5W-30",
+    "laborBrakeReplacement": "Bremseskift",
+    "laborOilChange": "Oljeskift"
   }
 }

+ 10 - 2
messages/nl/pdf.json

@@ -22,6 +22,9 @@
     "items": "Artikelen",
     "pos": "Nr.",
     "unitOfMeasure": "Eenheid",
+    "invoiceNumberLabel": "Factuurnr.",
+    "customerNumberLabel": "Klantnr.",
+    "dateLabel": "Datum",
     "total": "Totaal",
     "labor": "Arbeid",
     "hours": "Uren",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Professioneel werkplaatsbeheer",
     "tel": "Tel: {phone}",
     "org": "KvK: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Aanvullende informatie",
+    "telegramConnect": "Chat met ons op Telegram"
   },
   "quote": {
     "title": "OFFERTE",
@@ -103,7 +108,10 @@
     "validityFooter30": "Deze offerte is 30 dagen geldig",
     "viewPortal": "Bekijk uw portaal: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Aanvullende informatie",
+    "quoteNumberLabel": "Offertenr.",
+    "validUntilLabel": "Geldig tot"
   },
   "inspection": {
     "title": "VOERTUIGINSPECTIE",

+ 250 - 2
messages/nl/settings.json

@@ -161,6 +161,9 @@
     "email": "E-mail",
     "emailPlaceholder": "service@voorbeeld.nl",
     "address": "Adres",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Onderhoud en reparatie voor alle merken",
+    "sloganHint": "Eén regel onder uw naam op facturen en offertes.",
     "addressPlaceholder": "Hoofdstraat 123\n1000 AB Amsterdam",
     "saveCompany": "Bedrijfsgegevens opslaan",
     "addNewCompany": "Nieuw bedrijf toevoegen",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Nummers toegewezen aan {count} klanten",
     "allCustomersNumbered": "Alle klanten hebben een nummer.",
     "assignCustomerNumbersConfirmTitle": "Klantnummers toewijzen?",
-    "assignCustomerNumbersConfirmDescription": "De {count} klanten zonder nummer krijgen opeenvolgende nummers in volgorde van aanmaak, doorlopend na het hoogste bestaande nummer. Klanten met een nummer worden niet gewijzigd. Nummers zijn achteraf te bewerken."
+    "assignCustomerNumbersConfirmDescription": "De {count} klanten zonder nummer krijgen opeenvolgende nummers in volgorde van aanmaak, doorlopend na het hoogste bestaande nummer. Klanten met een nummer worden niet gewijzigd. Nummers zijn achteraf te bewerken.",
+    "layoutColorsHint": "Kleuren, lettertypen en de briefhoofdstijl staan bij Sjablonen.",
+    "goToTemplates": "Sjablonen openen"
   },
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",
     "addField": "Veld toevoegen",
     "custom": "aangepast",
     "full": "Volledig",
+    "boxed": "Kader",
+    "plain": "Zonder",
+    "boxedHint": "Wordt in een kader afgedrukt",
+    "plainHint": "Wordt zonder kader afgedrukt",
     "L": "L",
     "R": "R",
     "left": "links",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Volledige breedte — klik voor halve breedte (links)",
     "sections": {
       "header": "Koptekst",
+      "slogan": "Slogan",
       "customer": "Klant",
       "vehicle": "Voertuig",
       "service": "Service",
+      "document_title": "Documenttitel",
       "items_table": "Artikeltabel",
       "parts_table": "Onderdelen",
       "labor_table": "Arbeid",
@@ -577,6 +588,7 @@
       "notes": "Notities",
       "diagnostic_notes": "Diagnostische notities",
       "findings": "Observaties",
+      "warranty": "Garantie",
       "bank_account": "Bankrekening",
       "footer": "Voettekst",
       "general": "Algemeen",
@@ -598,12 +610,54 @@
       "tech_name": "Monteur",
       "logo": "Logo",
       "company_name": "Bedrijfsnaam",
+      "company_slogan": "Slogan",
       "company_address": "Adres",
       "company_phone": "Telefoon",
       "company_email": "E-mail",
       "company_org_number": "Organisatienummer",
       "bank_account": "Bankrekening",
+      "footer_note": "Voettekstnotitie",
       "org_number": "Organisatienummer"
+    },
+    "presetsTitle": "Begin met een indeling",
+    "presetsHint": "Vervangt de indeling hieronder. Er wordt niets opgeslagen tot u opslaat.",
+    "presets": {
+      "classic": {
+        "name": "Klassiek",
+        "description": "Onderdelen en arbeid in aparte tabellen, gegevens links onder elkaar"
+      },
+      "letterhead": {
+        "name": "Briefhoofd",
+        "description": "Contactgegevens onderaan, titel onder de adressen, één genummerde lijst"
+      },
+      "compact": {
+        "name": "Compact",
+        "description": "Zonder kaders, één genummerde lijst, alleen het nodige"
+      },
+      "itemized": {
+        "name": "Gespecificeerd",
+        "description": "Eén genummerde regellijst met een eigen eenheidskolom"
+      },
+      "detailed": {
+        "name": "Uitgebreid",
+        "description": "Titel bovenaan, voertuig apart, beide tabellen en alle optionele blokken"
+      }
+    },
+    "style": {
+      "documentTitle": "Hele vel",
+      "documentHint": "grootte, ruimte, accent, banen",
+      "textColor": "Tekst",
+      "labelColor": "Kop",
+      "backgroundColor": "Vulling",
+      "borderColor": "Rand",
+      "fontSize": "Grootte",
+      "rowPadding": "Rijhoogte",
+      "margin": "Marge",
+      "accentColor": "Accent",
+      "stripeColor": "Baan",
+      "stripes": "Banen",
+      "fontFamily": "Lettertype",
+      "fontInherit": "Overnemen"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Sjabloonpresets",
     "primaryColor": "Primaire kleur",
+    "colors": "Kleuren",
+    "textColor": "Tekst",
+    "textColorHint": "De kleur van koppen en lopende tekst",
+    "companyTextColor": "Bedrijfsnaam",
+    "companyTextColorHint": "De kleur van de naam op het briefhoofd",
+    "frameBorderColor": "Kaderlijn",
+    "frameBorderColorHint": "De lijn waar het kader het papier raakt",
+    "frameShadow": "Kaderschaduw",
+    "frameShadowOn": "Schaduw",
+    "frameShadowOff": "Vlak",
+    "backgroundColor": "Achtergrond",
+    "backgroundColorClear": "Wissen",
+    "backgroundColorHint": "Laat leeg voor wit papier. Een lichte tint drukt af als een getint vel.",
     "fontAndLayout": "Lettertype en indeling",
     "fontFamily": "Letterfamilie",
     "helveticaDefault": "Helvetica (Standaard)",
@@ -630,6 +697,10 @@
     "standard": "Standaard",
     "compact": "Compact",
     "modern": "Modern",
+    "framed": "Omkaderd",
+    "letterheadMark": "Briefhoofd",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Bedrijfsnaam",
     "preview": "{name} voorbeeld",
     "billTo": "Factuuradres",
     "preparedFor": "Opgesteld voor",
@@ -699,7 +770,11 @@
     "layoutDescription": "Pas de volgorde en zichtbaarheid van secties in uw facturen en offertes aan.",
     "layoutSaved": "Indeling succesvol opgeslagen",
     "layoutDocInvoice": "Factuur",
-    "layoutDocQuote": "Offerte"
+    "layoutDocQuote": "Offerte",
+    "goToLayout": "Indeling en secties",
+    "openDesigner": "Ontwerper openen",
+    "designerIntro": "Kies een startpunt en open de ontwerper om alles aan te passen.",
+    "designerHint": "Kleuren, lettertypen, ruimte en alle secties staan in de ontwerper."
   },
   "payment": {
     "readMore": "Lees meer",
@@ -1635,5 +1710,178 @@
         "description": "Verstuur en ontvang Telegram-berichten met je klanten vanuit Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Factuur",
+    "quote": "Offerte",
+    "eyebrow": "Documentontwerper",
+    "galleryTitle": "Begin met een sjabloon",
+    "galleryHint": "Kies een startpunt. In de ontwerper kunt u alles wijzigen.",
+    "yourDesigns": "Uw ontwerpen",
+    "templates": "Sjablonen",
+    "inUse": "In gebruik",
+    "savedOn": "Opgeslagen {date}",
+    "deleteDesignTitle": "Ontwerp verwijderen",
+    "deleteDesignBody": "Het ontwerp \"{name}\" verwijderen? Factuur en offerte houden wat is toegepast; alleen de opgeslagen kopie verdwijnt.",
+    "delete": "Verwijderen",
+    "deleteDesign": "Dit ontwerp verwijderen",
+    "continueCurrent": "Doorgaan met mijn huidige indeling",
+    "backToSettings": "Terug naar instellingen",
+    "useInvoiceDesign": "Factuurontwerp gebruiken",
+    "copiedToQuote": "Factuurontwerp naar de offerte gekopieerd",
+    "rulers": "Linialen",
+    "saveDesign": "Ontwerp opslaan",
+    "saveDesignHint": "Bewaar een kopie van dit ontwerp onder een naam, zodat u het terugvindt in de sjabloongalerij",
+    "reset": "Herstellen",
+    "save": "Opslaan",
+    "saving": "Opslaan…",
+    "saved": "Opgeslagen",
+    "couldNotSave": "Opslaan mislukt",
+    "couldNotSaveDesigns": "Uw ontwerpen konden niet worden opgeslagen",
+    "designUpdated": "\"{name}\" bijgewerkt",
+    "designSaved": "Opgeslagen als \"{name}\"",
+    "defaultDesignName": "Mijn {doc}ontwerp",
+    "documentStyling": "Documentopmaak",
+    "sections": "Secties",
+    "sectionEmpty": "leeg",
+    "sectionEmptyHint": "Wordt getoond, maar er valt met de huidige gegevens niets in af te drukken.",
+    "sectionShownHint": "Zichtbaar op het blad. Klik om te verbergen.",
+    "sectionHiddenHint": "Verborgen op het blad. Klik om te tonen.",
+    "railHint": "Sleep een sectie op de pagina: laat hem tussen blokken los om de volgorde te wijzigen, op een kaart om ernaast te staan, of ergens anders om hem vrij te plaatsen. De pagina maakt eromheen ruimte.",
+    "returnToFlow": "{section} terug in de stroom zetten",
+    "saveDialogTitle": "Dit ontwerp opslaan",
+    "saveDialogBody": "Bewaart een kopie van de huidige indeling en vormgeving onder een naam. U vindt hem terug onder Uw ontwerpen in de sjabloongalerij.",
+    "designNamePlaceholder": "Naam van het ontwerp",
+    "nameExists": "Er bestaat al een ontwerp met deze naam; opslaan werkt het bij.",
+    "updateDesign": "Ontwerp bijwerken",
+    "cancel": "Annuleren",
+    "insertHere": "Hier invoegen",
+    "dropLeftColumn": "Linkerkolom",
+    "dropRightColumn": "Rechterkolom",
+    "freePosition": "Vrije positie",
+    "sectionSettings": "Sectie-instellingen",
+    "panelStyle": "Paneelstijl",
+    "variant": {
+      "accent": "Accent",
+      "panel": "Paneel",
+      "outline": "Omlijning",
+      "lines": "Lijnen",
+      "hint": "Accent kleurt de kaart met de primaire kleur. Paneel sluit aan bij de andere detailkaarten. Omlijning tekent alleen een rand, en Lijnen drukt af zonder kader."
+    },
+    "placement": "Plaatsing",
+    "visible": "Zichtbaar",
+    "columnFull": "Vol",
+    "columnLeft": "Links",
+    "columnRight": "Rechts",
+    "drawBox": "Kader tekenen",
+    "showHeading": "Kop tonen",
+    "spacing": "Ruimte",
+    "placedByHand": "Deze sectie is met de hand geplaatst, dus ruimte in de stroom verplaatst hem niet.",
+    "placedByHandFramed": "Het omlijste briefhoofd ligt op de band; sleep het om het te verplaatsen.",
+    "placedByHandDrag": "Sleep hem, of zet hem terug in de stroom, om ruimte te gebruiken.",
+    "marginTop": "Boven",
+    "marginBottom": "Onder",
+    "marginLeft": "Links",
+    "marginRight": "Rechts",
+    "spacingHint": "Punten lege ruimte die de sectie in de stroom om zich heen houdt.",
+    "logo": "Logo",
+    "size": "Grootte",
+    "logoHint": "Zet het logo uit onder Velden om in plaats daarvan de bedrijfsnaam af te drukken.",
+    "fields": "Velden",
+    "removeField": "Uit deze sectie verwijderen",
+    "yourCustomFields": "Uw eigen velden",
+    "appearance": "Weergave",
+    "details": "Details",
+    "text": "Tekst",
+    "companyName": "Bedrijfsnaam",
+    "heading": "Kop",
+    "fill": "Vulling",
+    "border": "Rand",
+    "lineWidth": "Lijndikte",
+    "borderWidth": "Randdikte",
+    "outerBorder": "Buitenrand",
+    "rowBanding": "Rijarcering",
+    "likeSheet": "Als het blad",
+    "on": "Aan",
+    "off": "Uit",
+    "font": "Lettertype",
+    "inherit": "Overnemen",
+    "auto": "auto",
+    "sectionDragHint": "Sleep deze sectie in de lijst of op de pagina om hem te verplaatsen. Eenmaal met de hand geplaatst verschuiven de pijltoetsen hem een punt per keer, tien met Shift, en Escape breekt een sleep af.",
+    "document": "Document",
+    "documentSubtitle": "Geldt voor het hele blad",
+    "headerStyleTitle": "Briefhoofdstijl",
+    "headerStyle": {
+      "standard": {
+        "name": "Standaard",
+        "desc": "Naam en gegevens op wit, streep eronder"
+      },
+      "compact": {
+        "name": "Compact",
+        "desc": "Strak briefhoofd, dichte rijen"
+      },
+      "modern": {
+        "name": "Modern",
+        "desc": "Gekleurde banner over de volle breedte"
+      },
+      "framed": {
+        "name": "Omlijst",
+        "desc": "Band over de bovenkant, balk langs de linkerzijde"
+      }
+    },
+    "colors": "Kleuren",
+    "primary": "Primair",
+    "background": "Achtergrond",
+    "accent": "Accent",
+    "frame": "Kader",
+    "rail": "Balk",
+    "leftEdge": "Linkerrand",
+    "rightEdge": "Rechterrand",
+    "frameLine": "Kaderlijn",
+    "cornerRadius": "Hoekradius",
+    "shadow": "Schaduw",
+    "shadowThin": "Dun",
+    "shadowNormal": "Normaal",
+    "shadowWide": "Breed",
+    "typography": "Typografie",
+    "baseSize": "Basisgrootte",
+    "page": "Pagina",
+    "margins": "Marges",
+    "rowHeight": "Rijhoogte",
+    "band": "Arcering",
+    "sample": {
+      "itemLabor": "Voorremmen vervangen",
+      "itemDisc": "Remschijf, voor",
+      "itemPads": "Remblokset, vooras",
+      "itemConsumables": "Verbruiksmateriaal",
+      "unitPcs": "st",
+      "unitSet": "set",
+      "unitEach": "x",
+      "findingPads": "Achterblokken op 15%",
+      "findingPadsNote": "Binnen 5.000 km vervangen",
+      "findingOilWeep": "Lichte olielekkage bij klepdeksel",
+      "notes": "Bedankt dat u voor onze werkplaats hebt gekozen. Neem gerust contact op bij vragen.",
+      "warranty": "12 maanden garantie op onderdelen en arbeid.",
+      "serviceTitle": "Jaarlijkse beurt en remmen",
+      "serviceType": "Reparatie",
+      "companyName": "Uw werkplaats",
+      "slogan": "Kwaliteit waar u op kunt bouwen",
+      "footerNote": "Bedankt voor uw opdracht",
+      "value": "Voorbeeld"
+    }
+  },
+  "preview": {
+    "title": "Remservice en oliewissel",
+    "serviceType": "Onderhoud",
+    "findingPads": "Achterste remblokken tot 15% versleten",
+    "findingPadsNote": "Vervanging aanbevolen binnen 8.000 km",
+    "findingOilLeak": "Kleine olielekkage bij klepdekselpakking",
+    "invoiceNotes": "Voorste remblokken vervangen. Olie en filter verwisseld met synthetische 5W-30. Volgende beurt aanbevolen bij 80.000 km.",
+    "quoteNotes": "Vervanging van de voorste remblokken aanbevolen. Olie- en filterwissel met synthetische 5W-30.",
+    "partBrakePads": "Remblokken (voor)",
+    "partOilFilter": "Oliefilter",
+    "partSyntheticOil": "Synthetische olie 5W-30",
+    "laborBrakeReplacement": "Remmen vervangen",
+    "laborOilChange": "Oliewissel"
   }
 }

+ 10 - 2
messages/pl/pdf.json

@@ -22,6 +22,9 @@
     "items": "Pozycje",
     "pos": "Lp.",
     "unitOfMeasure": "Jedn.",
+    "invoiceNumberLabel": "Nr faktury",
+    "customerNumberLabel": "Nr klienta",
+    "dateLabel": "Data",
     "total": "Suma",
     "labor": "Robocizna",
     "hours": "Godziny",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Profesjonalne zarządzanie warsztatem",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informacje dodatkowe",
+    "telegramConnect": "Napisz do nas na Telegramie"
   },
   "quote": {
     "title": "WYCENA",
@@ -103,7 +108,10 @@
     "validityFooter30": "Ta wycena jest ważna przez 30 dni",
     "viewPortal": "Zobacz swój portal: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informacje dodatkowe",
+    "quoteNumberLabel": "Nr oferty",
+    "validUntilLabel": "Ważne do"
   },
   "inspection": {
     "title": "INSPEKCJA POJAZDU",

+ 250 - 2
messages/pl/settings.json

@@ -161,6 +161,9 @@
     "email": "E-mail",
     "emailPlaceholder": "serwis@przyklad.pl",
     "address": "Adres",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Serwis i naprawa wszystkich marek",
+    "sloganHint": "Jedna linia pod nazwą na fakturach i ofertach.",
     "addressPlaceholder": "ul. Glowna 123\n00-001 Warszawa",
     "saveCompany": "Zapisz dane firmy",
     "addNewCompany": "Dodaj nowa firmę",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Przydzielono numery {count} klientom",
     "allCustomersNumbered": "Wszyscy klienci mają numer.",
     "assignCustomerNumbersConfirmTitle": "Przydzielić numery klientów?",
-    "assignCustomerNumbersConfirmDescription": "{count} klientów bez numeru otrzyma kolejne numery w kolejności utworzenia, kontynuując od najwyższego istniejącego numeru. Klienci z numerem nie zostaną zmienieni. Numery można później edytować."
+    "assignCustomerNumbersConfirmDescription": "{count} klientów bez numeru otrzyma kolejne numery w kolejności utworzenia, kontynuując od najwyższego istniejącego numeru. Klienci z numerem nie zostaną zmienieni. Numery można później edytować.",
+    "layoutColorsHint": "Kolory, czcionki i styl nagłówka są w Szablonach.",
+    "goToTemplates": "Otwórz Szablony"
   },
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",
     "addField": "Dodaj pole",
     "custom": "niestandardowe",
     "full": "Pełna",
+    "boxed": "Ramka",
+    "plain": "Bez ramki",
+    "boxedHint": "Drukowane w ramce",
+    "plainHint": "Drukowane bez ramki",
     "L": "L",
     "R": "P",
     "left": "lewa",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Pełna szerokość — kliknij dla połowy szerokości (lewa)",
     "sections": {
       "header": "Nagłówek",
+      "slogan": "Slogan",
       "customer": "Klient",
       "vehicle": "Pojazd",
       "service": "Usługa",
+      "document_title": "Tytuł dokumentu",
       "items_table": "Tabela pozycji",
       "parts_table": "Części",
       "labor_table": "Robocizna",
@@ -577,6 +588,7 @@
       "notes": "Notatki",
       "diagnostic_notes": "Notatki diagnostyczne",
       "findings": "Obserwacje",
+      "warranty": "Gwarancja",
       "bank_account": "Konto bankowe",
       "footer": "Stopka",
       "general": "Ogólne",
@@ -598,12 +610,54 @@
       "tech_name": "Technik",
       "logo": "Logo",
       "company_name": "Nazwa firmy",
+      "company_slogan": "Slogan",
       "company_address": "Adres",
       "company_phone": "Telefon",
       "company_email": "E-mail",
       "company_org_number": "Numer organizacji",
       "bank_account": "Konto bankowe",
+      "footer_note": "Notatka w stopce",
       "org_number": "Numer organizacji"
+    },
+    "presetsTitle": "Zacznij od układu",
+    "presetsHint": "Zastępuje układ poniżej. Nic nie zostanie zapisane, dopóki nie zapiszesz.",
+    "presets": {
+      "classic": {
+        "name": "Klasyczny",
+        "description": "Części i robocizna w osobnych tabelach, dane jedno pod drugim po lewej"
+      },
+      "letterhead": {
+        "name": "Papier firmowy",
+        "description": "Kontakt na dole, tytuł pod adresami, jedna numerowana lista"
+      },
+      "compact": {
+        "name": "Kompaktowy",
+        "description": "Bez ramek, jedna numerowana lista, tylko to, co konieczne"
+      },
+      "itemized": {
+        "name": "Pozycjami",
+        "description": "Jedna numerowana lista pozycji z kolumną jednostki"
+      },
+      "detailed": {
+        "name": "Szczegółowy",
+        "description": "Tytuł na górze, pojazd osobno, obie tabele i wszystkie bloki opcjonalne"
+      }
+    },
+    "style": {
+      "documentTitle": "Cały arkusz",
+      "documentHint": "rozmiar, odstępy, akcent, pasy",
+      "textColor": "Tekst",
+      "labelColor": "Nagłówek",
+      "backgroundColor": "Wypełnienie",
+      "borderColor": "Ramka",
+      "fontSize": "Rozmiar",
+      "rowPadding": "Wysokość wiersza",
+      "margin": "Margines",
+      "accentColor": "Akcent",
+      "stripeColor": "Pas",
+      "stripes": "Pasy",
+      "fontFamily": "Czcionka",
+      "fontInherit": "Dziedzicz"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Gotowe szablony",
     "primaryColor": "Kolor główny",
+    "colors": "Kolory",
+    "textColor": "Tekst",
+    "textColorHint": "Kolor nagłówków i tekstu",
+    "companyTextColor": "Nazwa firmy",
+    "companyTextColorHint": "Kolor nazwy w nagłówku firmowym",
+    "frameBorderColor": "Linia ramki",
+    "frameBorderColorHint": "Linia w miejscu styku ramki z papierem",
+    "frameShadow": "Cień ramki",
+    "frameShadowOn": "Z cieniem",
+    "frameShadowOff": "Płaski",
+    "backgroundColor": "Tło",
+    "backgroundColorClear": "Wyczyść",
+    "backgroundColorHint": "Pozostaw puste dla białego papieru. Jasny odcień drukuje się jako tonowany arkusz.",
     "fontAndLayout": "Czcionka i uklad",
     "fontFamily": "Rodzina czcionek",
     "helveticaDefault": "Helvetica (Domyślna)",
@@ -630,6 +697,10 @@
     "standard": "Standardowy",
     "compact": "Kompaktowy",
     "modern": "Nowoczesny",
+    "framed": "W ramce",
+    "letterheadMark": "Nagłówek firmowy",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Nazwa firmy",
     "preview": "Podgląd {name}",
     "billTo": "Faktura dla",
     "preparedFor": "Przygotowano dla",
@@ -699,7 +770,11 @@
     "layoutDescription": "Dostosuj kolejność i widoczność sekcji w fakturach i wycenach.",
     "layoutSaved": "Układ zapisany pomyślnie",
     "layoutDocInvoice": "Faktura",
-    "layoutDocQuote": "Wycena"
+    "layoutDocQuote": "Wycena",
+    "goToLayout": "Układ i sekcje",
+    "openDesigner": "Otwórz kreator",
+    "designerIntro": "Wybierz punkt wyjścia, a potem otwórz kreator, aby zmienić wszystko.",
+    "designerHint": "Kolory, czcionki, odstępy i wszystkie sekcje są w kreatorze."
   },
   "payment": {
     "readMore": "Czytaj więcej",
@@ -1635,5 +1710,178 @@
         "description": "Wysyłaj i odbieraj wiadomości Telegram z klientami bezpośrednio w Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Faktura",
+    "quote": "Oferta",
+    "eyebrow": "Kreator dokumentów",
+    "galleryTitle": "Zacznij od szablonu",
+    "galleryHint": "Wybierz punkt wyjścia. W kreatorze zmienisz wszystko.",
+    "yourDesigns": "Twoje projekty",
+    "templates": "Szablony",
+    "inUse": "W użyciu",
+    "savedOn": "Zapisano {date}",
+    "deleteDesignTitle": "Usuń projekt",
+    "deleteDesignBody": "Usunąć projekt „{name}”? Faktura i oferta zachowują to, co zastosowano; znika tylko zapisana kopia.",
+    "delete": "Usuń",
+    "deleteDesign": "Usuń ten projekt",
+    "continueCurrent": "Kontynuuj z moim obecnym układem",
+    "backToSettings": "Wróć do ustawień",
+    "useInvoiceDesign": "Użyj projektu faktury",
+    "copiedToQuote": "Projekt faktury skopiowano do oferty",
+    "rulers": "Linijki",
+    "saveDesign": "Zapisz projekt",
+    "saveDesignHint": "Zachowaj nazwaną kopię tego projektu, aby wrócić do niej z galerii szablonów",
+    "reset": "Resetuj",
+    "save": "Zapisz",
+    "saving": "Zapisywanie…",
+    "saved": "Zapisano",
+    "couldNotSave": "Nie udało się zapisać",
+    "couldNotSaveDesigns": "Nie udało się zapisać Twoich projektów",
+    "designUpdated": "Zaktualizowano „{name}”",
+    "designSaved": "Zapisano jako „{name}”",
+    "defaultDesignName": "Mój projekt: {doc}",
+    "documentStyling": "Styl dokumentu",
+    "sections": "Sekcje",
+    "sectionEmpty": "pusta",
+    "sectionEmptyHint": "Jest pokazywana, ale przy obecnych danych nie ma w niej nic do wydrukowania.",
+    "sectionShownHint": "Widoczna na arkuszu. Kliknij, aby ją ukryć.",
+    "sectionHiddenHint": "Ukryta na arkuszu. Kliknij, aby ją pokazać.",
+    "railHint": "Przeciągnij sekcję na stronę: upuść ją między blokami, aby zmienić kolejność, na kartę, aby stanęła obok niej, albo gdziekolwiek indziej, aby umieścić ją swobodnie. Strona zrobi wokół niej miejsce.",
+    "returnToFlow": "Przywróć {section} do układu",
+    "saveDialogTitle": "Zapisz ten projekt",
+    "saveDialogBody": "Zachowuje nazwaną kopię obecnego układu i wyglądu. Znajdziesz ją w sekcji Twoje projekty w galerii szablonów.",
+    "designNamePlaceholder": "Nazwa projektu",
+    "nameExists": "Projekt o tej nazwie już istnieje; zapis go zaktualizuje.",
+    "updateDesign": "Zaktualizuj projekt",
+    "cancel": "Anuluj",
+    "insertHere": "Wstaw tutaj",
+    "dropLeftColumn": "Lewa kolumna",
+    "dropRightColumn": "Prawa kolumna",
+    "freePosition": "Dowolne miejsce",
+    "sectionSettings": "Ustawienia sekcji",
+    "panelStyle": "Styl panelu",
+    "variant": {
+      "accent": "Akcent",
+      "panel": "Panel",
+      "outline": "Obrys",
+      "lines": "Linie",
+      "hint": "Akcent barwi kartę kolorem głównym. Panel dopasowuje ją do pozostałych kart ze szczegółami. Obrys rysuje samą ramkę, a Linie drukują bez ramki."
+    },
+    "placement": "Rozmieszczenie",
+    "visible": "Widoczna",
+    "columnFull": "Pełna",
+    "columnLeft": "Lewa",
+    "columnRight": "Prawa",
+    "drawBox": "Rysuj ramkę",
+    "showHeading": "Pokaż nagłówek",
+    "spacing": "Odstępy",
+    "placedByHand": "Ta sekcja jest ustawiona ręcznie, więc odstępy w układzie jej nie przesuwają.",
+    "placedByHandFramed": "Papier firmowy w ramce leży na pasie; przeciągnij go, aby go przesunąć.",
+    "placedByHandDrag": "Przeciągnij ją albo przywróć do układu, aby korzystać z odstępów.",
+    "marginTop": "Góra",
+    "marginBottom": "Dół",
+    "marginLeft": "Lewo",
+    "marginRight": "Prawo",
+    "spacingHint": "Punkty pustego miejsca, które sekcja utrzymuje wokół siebie w układzie.",
+    "logo": "Logo",
+    "size": "Rozmiar",
+    "logoHint": "Wyłącz logo w sekcji Pola, aby zamiast niego drukować nazwę firmy.",
+    "fields": "Pola",
+    "removeField": "Usuń z tej sekcji",
+    "yourCustomFields": "Twoje pola własne",
+    "appearance": "Wygląd",
+    "details": "Szczegóły",
+    "text": "Tekst",
+    "companyName": "Nazwa firmy",
+    "heading": "Nagłówek",
+    "fill": "Wypełnienie",
+    "border": "Ramka",
+    "lineWidth": "Grubość linii",
+    "borderWidth": "Grubość ramki",
+    "outerBorder": "Ramka zewnętrzna",
+    "rowBanding": "Naprzemienne wiersze",
+    "likeSheet": "Jak arkusz",
+    "on": "Wł.",
+    "off": "Wył.",
+    "font": "Czcionka",
+    "inherit": "Dziedzicz",
+    "auto": "auto",
+    "sectionDragHint": "Przeciągnij tę sekcję na liście albo na stronie, aby ją przesunąć. Po ręcznym ustawieniu strzałki przesuwają ją o jeden punkt, z Shift o dziesięć, a Escape anuluje przeciąganie.",
+    "document": "Dokument",
+    "documentSubtitle": "Dotyczy całego arkusza",
+    "headerStyleTitle": "Styl nagłówka",
+    "headerStyle": {
+      "standard": {
+        "name": "Standardowy",
+        "desc": "Nazwa i dane na białym tle, kreska pod spodem"
+      },
+      "compact": {
+        "name": "Kompaktowy",
+        "desc": "Wąski nagłówek, gęste wiersze"
+      },
+      "modern": {
+        "name": "Nowoczesny",
+        "desc": "Kolorowy pas na całą szerokość"
+      },
+      "framed": {
+        "name": "W ramce",
+        "desc": "Pas u góry, listwa przy lewej krawędzi"
+      }
+    },
+    "colors": "Kolory",
+    "primary": "Główny",
+    "background": "Tło",
+    "accent": "Akcent",
+    "frame": "Ramka",
+    "rail": "Listwa",
+    "leftEdge": "Lewa krawędź",
+    "rightEdge": "Prawa krawędź",
+    "frameLine": "Linia ramki",
+    "cornerRadius": "Zaokrąglenie rogów",
+    "shadow": "Cień",
+    "shadowThin": "Cienki",
+    "shadowNormal": "Zwykły",
+    "shadowWide": "Szeroki",
+    "typography": "Typografia",
+    "baseSize": "Rozmiar bazowy",
+    "page": "Strona",
+    "margins": "Marginesy",
+    "rowHeight": "Wysokość wiersza",
+    "band": "Pas",
+    "sample": {
+      "itemLabor": "Wymiana hamulców przednich",
+      "itemDisc": "Tarcza hamulcowa, przód",
+      "itemPads": "Komplet klocków, oś przednia",
+      "itemConsumables": "Materiały eksploatacyjne",
+      "unitPcs": "szt.",
+      "unitSet": "kpl.",
+      "unitEach": "x",
+      "findingPads": "Tylne klocki na poziomie 15%",
+      "findingPadsNote": "Wymienić w ciągu 5000 km",
+      "findingOilWeep": "Lekki wyciek oleju przy pokrywie zaworów",
+      "notes": "Dziękujemy za wybór naszego warsztatu. W razie pytań prosimy o kontakt.",
+      "warranty": "12 miesięcy gwarancji na części i robociznę.",
+      "serviceTitle": "Przegląd roczny i hamulce",
+      "serviceType": "Naprawa",
+      "companyName": "Twój warsztat",
+      "slogan": "Jakość, której możesz zaufać",
+      "footerNote": "Dziękujemy za współpracę",
+      "value": "Przykład"
+    }
+  },
+  "preview": {
+    "title": "Serwis hamulców i wymiana oleju",
+    "serviceType": "Konserwacja",
+    "findingPads": "Tylne klocki hamulcowe zużyte do 15%",
+    "findingPadsNote": "Zalecana wymiana w ciągu 8000 km",
+    "findingOilLeak": "Niewielki wyciek oleju przy uszczelce pokrywy zaworów",
+    "invoiceNotes": "Wymieniono przednie klocki hamulcowe. Olej i filtr wymienione na syntetyk 5W-30. Kolejny przegląd zalecany przy 80 000 km.",
+    "quoteNotes": "Zalecana wymiana przednich klocków hamulcowych. Wymiana oleju i filtra na syntetyk 5W-30.",
+    "partBrakePads": "Klocki hamulcowe (przód)",
+    "partOilFilter": "Filtr oleju",
+    "partSyntheticOil": "Olej syntetyczny 5W-30",
+    "laborBrakeReplacement": "Wymiana hamulców",
+    "laborOilChange": "Wymiana oleju"
   }
 }

+ 10 - 2
messages/pt-BR/pdf.json

@@ -22,6 +22,9 @@
     "items": "Itens",
     "pos": "Nº",
     "unitOfMeasure": "Unidade",
+    "invoiceNumberLabel": "Nº da fatura",
+    "customerNumberLabel": "Nº do cliente",
+    "dateLabel": "Data",
     "total": "Total",
     "labor": "Mão de Obra",
     "hours": "Horas",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Gestão Profissional de Oficina",
     "tel": "Tel: {phone}",
     "org": "CNPJ: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informações adicionais",
+    "telegramConnect": "Fale conosco no Telegram"
   },
   "quote": {
     "title": "ORÇAMENTO",
@@ -103,7 +108,10 @@
     "validityFooter30": "Este orçamento é válido por 30 dias",
     "viewPortal": "Acesse seu portal: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Informações adicionais",
+    "quoteNumberLabel": "N.º do orçamento",
+    "validUntilLabel": "Válido até"
   },
   "inspection": {
     "title": "INSPEÇÃO VEICULAR",

+ 250 - 2
messages/pt-BR/settings.json

@@ -161,6 +161,9 @@
     "email": "E-mail",
     "emailPlaceholder": "serviço@exemplo.com.br",
     "address": "Endereço",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Serviço e reparo para todas as marcas",
+    "sloganHint": "Uma linha abaixo do nome em faturas e orçamentos.",
     "addressPlaceholder": "Rua Principal 123\nSala 100\n01234-567 Sao Paulo",
     "saveCompany": "Salvar detalhes da empresa",
     "addNewCompany": "Adicionar nova empresa",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Números atribuídos a {count} clientes",
     "allCustomersNumbered": "Todos os clientes têm número.",
     "assignCustomerNumbersConfirmTitle": "Atribuir números de cliente?",
-    "assignCustomerNumbersConfirmDescription": "Os {count} clientes sem número receberão números sequenciais na ordem de criação, continuando após o maior número existente. Clientes que já têm número não são alterados. Os números podem ser editados depois."
+    "assignCustomerNumbersConfirmDescription": "Os {count} clientes sem número receberão números sequenciais na ordem de criação, continuando após o maior número existente. Clientes que já têm número não são alterados. Os números podem ser editados depois.",
+    "layoutColorsHint": "Cores, fontes e o estilo do timbre ficam em Modelos.",
+    "goToTemplates": "Abrir Modelos"
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",
     "addField": "Adicionar campo",
     "custom": "personalizado",
     "full": "Inteiro",
+    "boxed": "Moldura",
+    "plain": "Sem moldura",
+    "boxedHint": "Impresso dentro de um painel",
+    "plainHint": "Impresso sem painel",
     "L": "E",
     "R": "D",
     "left": "esquerda",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Largura inteira — clique para meia largura (esquerda)",
     "sections": {
       "header": "Cabeçalho",
+      "slogan": "Slogan",
       "customer": "Cliente",
       "vehicle": "Veículo",
       "service": "Serviço",
+      "document_title": "Título do documento",
       "items_table": "Tabela de itens",
       "parts_table": "Peças",
       "labor_table": "Mão de obra",
@@ -577,6 +588,7 @@
       "notes": "Notas",
       "diagnostic_notes": "Notas de diagnóstico",
       "findings": "Observações",
+      "warranty": "Garantia",
       "bank_account": "Conta bancária",
       "footer": "Rodapé",
       "general": "Geral",
@@ -598,12 +610,54 @@
       "tech_name": "Técnico",
       "logo": "Logo",
       "company_name": "Nome da empresa",
+      "company_slogan": "Slogan",
       "company_address": "Endereço",
       "company_phone": "Telefone",
       "company_email": "E-mail",
       "company_org_number": "Número da organização",
       "bank_account": "Conta bancária",
+      "footer_note": "Nota de rodapé",
       "org_number": "Número da organização"
+    },
+    "presetsTitle": "Começar por uma disposição",
+    "presetsHint": "Substitui a disposição abaixo. Nada é salvo até você salvar.",
+    "presets": {
+      "classic": {
+        "name": "Clássica",
+        "description": "Peças e mão de obra em tabelas separadas, dados empilhados à esquerda"
+      },
+      "letterhead": {
+        "name": "Timbre",
+        "description": "Contatos no rodapé, título abaixo dos endereços, uma lista numerada"
+      },
+      "compact": {
+        "name": "Compacta",
+        "description": "Sem painéis, uma lista numerada, só o essencial"
+      },
+      "itemized": {
+        "name": "Discriminado",
+        "description": "Uma lista de itens numerada com coluna de unidade"
+      },
+      "detailed": {
+        "name": "Detalhada",
+        "description": "Título no topo, veículo sozinho, as duas tabelas e todos os blocos opcionais"
+      }
+    },
+    "style": {
+      "documentTitle": "Folha inteira",
+      "documentHint": "tamanho, espaçamento, acento, faixas",
+      "textColor": "Texto",
+      "labelColor": "Título",
+      "backgroundColor": "Preenchimento",
+      "borderColor": "Borda",
+      "fontSize": "Tamanho",
+      "rowPadding": "Altura da linha",
+      "margin": "Margem",
+      "accentColor": "Acento",
+      "stripeColor": "Faixa",
+      "stripes": "Faixas",
+      "fontFamily": "Fonte",
+      "fontInherit": "Herdar"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Presets de modelo",
     "primaryColor": "Cor primária",
+    "colors": "Cores",
+    "textColor": "Texto",
+    "textColorHint": "A cor dos títulos e do texto",
+    "companyTextColor": "Nome da empresa",
+    "companyTextColorHint": "A cor do nome no timbre",
+    "frameBorderColor": "Linha da moldura",
+    "frameBorderColorHint": "A linha onde a moldura encontra o papel",
+    "frameShadow": "Sombra da moldura",
+    "frameShadowOn": "Com sombra",
+    "frameShadowOff": "Plano",
+    "backgroundColor": "Fundo",
+    "backgroundColorClear": "Limpar",
+    "backgroundColorHint": "Deixe vazio para papel branco. Um tom claro imprime como folha tingida.",
     "fontAndLayout": "Fonte e layout",
     "fontFamily": "Família de fontes",
     "helveticaDefault": "Helvetica (Padrão)",
@@ -630,6 +697,10 @@
     "standard": "Padrão",
     "compact": "Compacto",
     "modern": "Moderno",
+    "framed": "Com moldura",
+    "letterheadMark": "Timbre",
+    "letterheadLogo": "Logotipo",
+    "letterheadCompanyName": "Nome da empresa",
     "preview": "Visualização de {name}",
     "billTo": "Faturar para",
     "preparedFor": "Preparado para",
@@ -699,7 +770,11 @@
     "layoutDescription": "Personalize a ordem e visibilidade das seções nas suas faturas e orçamentos.",
     "layoutSaved": "Layout salvo com sucesso",
     "layoutDocInvoice": "Fatura",
-    "layoutDocQuote": "Orçamento"
+    "layoutDocQuote": "Orçamento",
+    "goToLayout": "Disposição e seções",
+    "openDesigner": "Abrir o designer",
+    "designerIntro": "Escolha um ponto de partida e abra o designer para mudar tudo.",
+    "designerHint": "Cores, fontes, espaçamento e todas as seções ficam no designer."
   },
   "payment": {
     "readMore": "Saiba mais",
@@ -1635,5 +1710,178 @@
         "description": "Envie e receba mensagens do Telegram com seus clientes direto do Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Fatura",
+    "quote": "Orçamento",
+    "eyebrow": "Designer de documentos",
+    "galleryTitle": "Comece por um modelo",
+    "galleryHint": "Escolha um ponto de partida. Você pode mudar tudo no designer.",
+    "yourDesigns": "Seus designs",
+    "templates": "Modelos",
+    "inUse": "Em uso",
+    "savedOn": "Salvo em {date}",
+    "deleteDesignTitle": "Excluir design",
+    "deleteDesignBody": "Excluir o design \"{name}\"? A fatura e o orçamento mantêm o que está aplicado; só a cópia salva some.",
+    "delete": "Excluir",
+    "deleteDesign": "Excluir este design",
+    "continueCurrent": "Continuar com o meu layout atual",
+    "backToSettings": "Voltar às configurações",
+    "useInvoiceDesign": "Usar o design da fatura",
+    "copiedToQuote": "Design da fatura copiado para o orçamento",
+    "rulers": "Réguas",
+    "saveDesign": "Salvar design",
+    "saveDesignHint": "Guarde uma cópia nomeada deste design para voltar a ela pela galeria de modelos",
+    "reset": "Redefinir",
+    "save": "Salvar",
+    "saving": "Salvando…",
+    "saved": "Salvo",
+    "couldNotSave": "Não foi possível salvar",
+    "couldNotSaveDesigns": "Não foi possível salvar seus designs",
+    "designUpdated": "\"{name}\" atualizado",
+    "designSaved": "Salvo como \"{name}\"",
+    "defaultDesignName": "Meu design de {doc}",
+    "documentStyling": "Estilo do documento",
+    "sections": "Seções",
+    "sectionEmpty": "vazia",
+    "sectionEmptyHint": "Aparece, mas com os dados atuais não há nada a imprimir nela.",
+    "sectionShownHint": "Visível na folha. Clique para ocultar.",
+    "sectionHiddenHint": "Oculta na folha. Clique para mostrar.",
+    "railHint": "Arraste uma seção sobre a página: solte-a entre blocos para reordenar, sobre um cartão para ficar ao lado dele, ou em qualquer outro lugar para posicioná-la livremente. A página abre espaço ao redor dela.",
+    "returnToFlow": "Devolver {section} ao fluxo",
+    "saveDialogTitle": "Salvar este design",
+    "saveDialogBody": "Guarda uma cópia nomeada do arranjo e da aparência atuais. Você a encontra em Seus designs, na galeria de modelos.",
+    "designNamePlaceholder": "Nome do design",
+    "nameExists": "Já existe um design com este nome; salvar vai atualizá-lo.",
+    "updateDesign": "Atualizar design",
+    "cancel": "Cancelar",
+    "insertHere": "Inserir aqui",
+    "dropLeftColumn": "Coluna esquerda",
+    "dropRightColumn": "Coluna direita",
+    "freePosition": "Posição livre",
+    "sectionSettings": "Configurações da seção",
+    "panelStyle": "Estilo do painel",
+    "variant": {
+      "accent": "Destaque",
+      "panel": "Painel",
+      "outline": "Contorno",
+      "lines": "Linhas",
+      "hint": "Destaque tinge o cartão com a cor principal. Painel combina com os outros cartões de detalhe. Contorno desenha apenas uma borda, e Linhas imprime sem caixa."
+    },
+    "placement": "Posicionamento",
+    "visible": "Visível",
+    "columnFull": "Inteira",
+    "columnLeft": "Esquerda",
+    "columnRight": "Direita",
+    "drawBox": "Desenhar uma caixa",
+    "showHeading": "Mostrar título",
+    "spacing": "Espaçamento",
+    "placedByHand": "Esta seção foi posicionada à mão, então o espaçamento do fluxo não a move.",
+    "placedByHandFramed": "O timbre emoldurado fica sobre a faixa; arraste-o para movê-lo.",
+    "placedByHandDrag": "Arraste-a, ou devolva-a ao fluxo, para usar o espaçamento.",
+    "marginTop": "Topo",
+    "marginBottom": "Base",
+    "marginLeft": "Esquerda",
+    "marginRight": "Direita",
+    "spacingHint": "Pontos de espaço livre que a seção mantém ao redor de si no fluxo.",
+    "logo": "Logotipo",
+    "size": "Tamanho",
+    "logoHint": "Desligue o logotipo em Campos para imprimir o nome da empresa no lugar dele.",
+    "fields": "Campos",
+    "removeField": "Remover desta seção",
+    "yourCustomFields": "Seus campos personalizados",
+    "appearance": "Aparência",
+    "details": "Detalhes",
+    "text": "Texto",
+    "companyName": "Nome da empresa",
+    "heading": "Título",
+    "fill": "Preenchimento",
+    "border": "Borda",
+    "lineWidth": "Espessura da linha",
+    "borderWidth": "Espessura da borda",
+    "outerBorder": "Borda externa",
+    "rowBanding": "Faixas nas linhas",
+    "likeSheet": "Como a folha",
+    "on": "Sim",
+    "off": "Não",
+    "font": "Fonte",
+    "inherit": "Herdar",
+    "auto": "auto",
+    "sectionDragHint": "Arraste esta seção na lista, ou sobre a página, para movê-la. Uma vez posicionada à mão, as setas a deslocam um ponto por vez, dez com Shift, e Esc cancela o arraste.",
+    "document": "Documento",
+    "documentSubtitle": "Vale para a folha inteira",
+    "headerStyleTitle": "Estilo do cabeçalho",
+    "headerStyle": {
+      "standard": {
+        "name": "Padrão",
+        "desc": "Nome e dados sobre branco, com traço abaixo"
+      },
+      "compact": {
+        "name": "Compacto",
+        "desc": "Cabeçalho enxuto, linhas densas"
+      },
+      "modern": {
+        "name": "Moderno",
+        "desc": "Faixa colorida em toda a largura"
+      },
+      "framed": {
+        "name": "Emoldurado",
+        "desc": "Faixa no topo e barra pela lateral esquerda"
+      }
+    },
+    "colors": "Cores",
+    "primary": "Principal",
+    "background": "Fundo",
+    "accent": "Destaque",
+    "frame": "Moldura",
+    "rail": "Barra",
+    "leftEdge": "Borda esquerda",
+    "rightEdge": "Borda direita",
+    "frameLine": "Linha da moldura",
+    "cornerRadius": "Raio do canto",
+    "shadow": "Sombra",
+    "shadowThin": "Fina",
+    "shadowNormal": "Normal",
+    "shadowWide": "Larga",
+    "typography": "Tipografia",
+    "baseSize": "Tamanho base",
+    "page": "Página",
+    "margins": "Margens",
+    "rowHeight": "Altura da linha",
+    "band": "Faixa",
+    "sample": {
+      "itemLabor": "Freios dianteiros substituídos",
+      "itemDisc": "Disco de freio, dianteiro",
+      "itemPads": "Jogo de pastilhas, eixo dianteiro",
+      "itemConsumables": "Materiais de consumo",
+      "unitPcs": "un",
+      "unitSet": "jogo",
+      "unitEach": "x",
+      "findingPads": "Pastilhas traseiras em 15%",
+      "findingPadsNote": "Substituir em até 5.000 km",
+      "findingOilWeep": "Leve suor de óleo na tampa de válvulas",
+      "notes": "Obrigado por escolher nossa oficina. Fale conosco se tiver qualquer dúvida.",
+      "warranty": "12 meses de garantia em peças e mão de obra.",
+      "serviceTitle": "Revisão anual e freios",
+      "serviceType": "Reparo",
+      "companyName": "Sua oficina",
+      "slogan": "Serviço de qualidade em que você pode confiar",
+      "footerNote": "Obrigado pela preferência",
+      "value": "Exemplo"
+    }
+  },
+  "preview": {
+    "title": "Serviço de freios e troca de óleo",
+    "serviceType": "Manutenção",
+    "findingPads": "Pastilhas traseiras desgastadas em 15%",
+    "findingPadsNote": "Recomenda-se a troca em até 8.000 km",
+    "findingOilLeak": "Leve vazamento de óleo na junta da tampa de válvulas",
+    "invoiceNotes": "Pastilhas dianteiras substituídas. Óleo e filtro trocados com sintético 5W-30. Próxima revisão recomendada aos 80.000 km.",
+    "quoteNotes": "Recomenda-se a troca das pastilhas dianteiras. Troca de óleo e filtro com sintético 5W-30.",
+    "partBrakePads": "Pastilhas de freio (dianteiras)",
+    "partOilFilter": "Filtro de óleo",
+    "partSyntheticOil": "Óleo sintético 5W-30",
+    "laborBrakeReplacement": "Substituição de freios",
+    "laborOilChange": "Troca de óleo"
   }
 }

+ 10 - 2
messages/ru/pdf.json

@@ -22,6 +22,9 @@
     "items": "Позиции",
     "pos": "№",
     "unitOfMeasure": "Ед.",
+    "invoiceNumberLabel": "№ счёта",
+    "customerNumberLabel": "№ клиента",
+    "dateLabel": "Дата",
     "total": "Итого",
     "labor": "Работа",
     "hours": "Часы",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Профессиональное управление мастерской",
     "tel": "Тел: {phone}",
     "org": "Орг: {org}",
-    "qtyOrHours": "Услуга / Часы"
+    "qtyOrHours": "Услуга / Часы",
+    "customFieldsTitle": "Дополнительная информация",
+    "telegramConnect": "Напишите нам в Telegram"
   },
   "quote": {
     "title": "КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ",
@@ -103,7 +108,10 @@
     "validityFooter30": "Это предложение действительно 30 дней",
     "viewPortal": "Ваш портал: {url}",
     "tel": "Тел: {phone}",
-    "qtyOrHours": "Услуга / Часы"
+    "qtyOrHours": "Услуга / Часы",
+    "customFieldsTitle": "Дополнительная информация",
+    "quoteNumberLabel": "№ предложения",
+    "validUntilLabel": "Действительно до"
   },
   "inspection": {
     "title": "ОСМОТР ТРАНСПОРТНОГО СРЕДСТВА",

+ 250 - 2
messages/ru/settings.json

@@ -161,6 +161,9 @@
     "email": "Эл. почта",
     "emailPlaceholder": "service@example.com",
     "address": "Адрес",
+    "slogan": "Слоган",
+    "sloganPlaceholder": "Обслуживание и ремонт любых марок",
+    "sloganHint": "Одна строка под названием на счетах и предложениях.",
     "addressPlaceholder": "ул. Главная, 123\nОфис 100\nМосква, 123456",
     "saveCompany": "Сохранить данные компании",
     "addNewCompany": "Добавить новую компанию",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "Номера присвоены {count} клиентам",
     "allCustomersNumbered": "У всех клиентов есть номер.",
     "assignCustomerNumbersConfirmTitle": "Присвоить номера клиентам?",
-    "assignCustomerNumbersConfirmDescription": "{count} клиентов без номера получат последовательные номера в порядке создания, продолжая после наибольшего существующего номера. Клиенты с номером не изменяются. Номера можно редактировать позже."
+    "assignCustomerNumbersConfirmDescription": "{count} клиентов без номера получат последовательные номера в порядке создания, продолжая после наибольшего существующего номера. Клиенты с номером не изменяются. Номера можно редактировать позже.",
+    "layoutColorsHint": "Цвета, шрифты и стиль бланка находятся в Шаблонах.",
+    "goToTemplates": "Открыть шаблоны"
   },
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",
     "addField": "Добавить поле",
     "custom": "пользоват.",
     "full": "Полная",
+    "boxed": "Рамка",
+    "plain": "Без рамки",
+    "boxedHint": "Печатается в рамке",
+    "plainHint": "Печатается без рамки",
     "L": "Л",
     "R": "П",
     "left": "левая",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Полная ширина — нажмите для половины ширины (левая)",
     "sections": {
       "header": "Заголовок",
+      "slogan": "Слоган",
       "customer": "Клиент",
       "vehicle": "Транспортное средство",
       "service": "Обслуживание",
+      "document_title": "Заголовок документа",
       "items_table": "Таблица позиций",
       "parts_table": "Таблица запчастей",
       "labor_table": "Таблица работ",
@@ -577,6 +588,7 @@
       "notes": "Заметки",
       "diagnostic_notes": "Диагностические заметки",
       "findings": "Наблюдения",
+      "warranty": "Гарантия",
       "bank_account": "Банковский счёт",
       "footer": "Подвал",
       "general": "Общие",
@@ -598,12 +610,54 @@
       "tech_name": "Механик",
       "logo": "Логотип",
       "company_name": "Название компании",
+      "company_slogan": "Слоган",
       "company_address": "Адрес",
       "company_phone": "Телефон",
       "company_email": "Эл. почта",
       "company_org_number": "Номер организации",
       "bank_account": "Банковский счёт",
+      "footer_note": "Примечание в подвале",
       "org_number": "Номер организации"
+    },
+    "presetsTitle": "Начать с расположения",
+    "presetsHint": "Заменяет расположение ниже. Ничего не сохранится, пока вы не сохраните.",
+    "presets": {
+      "classic": {
+        "name": "Классическое",
+        "description": "Запчасти и работы в отдельных таблицах, данные слева друг под другом"
+      },
+      "letterhead": {
+        "name": "Фирменный бланк",
+        "description": "Контакты внизу, заголовок под адресами, один нумерованный список"
+      },
+      "compact": {
+        "name": "Компактное",
+        "description": "Без рамок, один нумерованный список, только необходимое"
+      },
+      "itemized": {
+        "name": "По позициям",
+        "description": "Один нумерованный список позиций со столбцом единицы"
+      },
+      "detailed": {
+        "name": "Подробное",
+        "description": "Заголовок сверху, автомобиль отдельно, обе таблицы и все блоки"
+      }
+    },
+    "style": {
+      "documentTitle": "Весь лист",
+      "documentHint": "размер, отступы, акцент, полосы",
+      "textColor": "Текст",
+      "labelColor": "Заголовок",
+      "backgroundColor": "Заливка",
+      "borderColor": "Рамка",
+      "fontSize": "Размер",
+      "rowPadding": "Высота строки",
+      "margin": "Поля",
+      "accentColor": "Акцент",
+      "stripeColor": "Полоса",
+      "stripes": "Полосы",
+      "fontFamily": "Шрифт",
+      "fontInherit": "Наследовать"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Предустановки шаблонов",
     "primaryColor": "Основной цвет",
+    "colors": "Цвета",
+    "textColor": "Текст",
+    "textColorHint": "Цвет заголовков и основного текста",
+    "companyTextColor": "Название компании",
+    "companyTextColorHint": "Цвет названия на фирменном бланке",
+    "frameBorderColor": "Линия рамки",
+    "frameBorderColorHint": "Линия там, где рамка встречается с бумагой",
+    "frameShadow": "Тень рамки",
+    "frameShadowOn": "С тенью",
+    "frameShadowOff": "Без тени",
+    "backgroundColor": "Фон",
+    "backgroundColorClear": "Сбросить",
+    "backgroundColorHint": "Оставьте пустым для белой бумаги. Светлый оттенок печатается как тонированный лист.",
     "fontAndLayout": "Шрифт и макет",
     "fontFamily": "Семейство шрифтов",
     "helveticaDefault": "Helvetica (по умолчанию)",
@@ -630,6 +697,10 @@
     "standard": "Стандартный",
     "compact": "Компактный",
     "modern": "Современный",
+    "framed": "В рамке",
+    "letterheadMark": "Фирменный бланк",
+    "letterheadLogo": "Логотип",
+    "letterheadCompanyName": "Название компании",
     "preview": "Предпросмотр {name}",
     "billTo": "Плательщик",
     "preparedFor": "Подготовлено для",
@@ -699,7 +770,11 @@
       "slate": "Серый",
       "rose": "Розовый",
       "indigo": "Индиго"
-    }
+    },
+    "goToLayout": "Расположение и разделы",
+    "openDesigner": "Открыть дизайнер",
+    "designerIntro": "Выберите отправную точку и откройте дизайнер, чтобы изменить всё.",
+    "designerHint": "Цвета, шрифты, отступы и все разделы находятся в дизайнере."
   },
   "payment": {
     "readMore": "Подробнее",
@@ -1635,5 +1710,178 @@
         "description": "Отправляйте и получайте сообщения Telegram от клиентов прямо в Torqvoice."
       }
     }
+  },
+  "designer": {
+    "invoice": "Счёт",
+    "quote": "Предложение",
+    "eyebrow": "Конструктор документов",
+    "galleryTitle": "Начните с шаблона",
+    "galleryHint": "Выберите отправную точку. В конструкторе можно изменить всё.",
+    "yourDesigns": "Ваши макеты",
+    "templates": "Шаблоны",
+    "inUse": "Используется",
+    "savedOn": "Сохранено {date}",
+    "deleteDesignTitle": "Удалить макет",
+    "deleteDesignBody": "Удалить макет «{name}»? Счёт и предложение сохранят применённое оформление; исчезнет только сохранённая копия.",
+    "delete": "Удалить",
+    "deleteDesign": "Удалить этот макет",
+    "continueCurrent": "Продолжить с текущей компоновкой",
+    "backToSettings": "Назад к настройкам",
+    "useInvoiceDesign": "Взять макет счёта",
+    "copiedToQuote": "Макет счёта скопирован в предложение",
+    "rulers": "Линейки",
+    "saveDesign": "Сохранить макет",
+    "saveDesignHint": "Сохраните именованную копию этого макета, чтобы вернуться к ней из галереи шаблонов",
+    "reset": "Сбросить",
+    "save": "Сохранить",
+    "saving": "Сохранение…",
+    "saved": "Сохранено",
+    "couldNotSave": "Не удалось сохранить",
+    "couldNotSaveDesigns": "Не удалось сохранить ваши макеты",
+    "designUpdated": "Макет «{name}» обновлён",
+    "designSaved": "Сохранено как «{name}»",
+    "defaultDesignName": "Мой макет: {doc}",
+    "documentStyling": "Оформление документа",
+    "sections": "Разделы",
+    "sectionEmpty": "пусто",
+    "sectionEmptyHint": "Показывается, но с текущими данными печатать в нём нечего.",
+    "sectionShownHint": "Виден на листе. Нажмите, чтобы скрыть.",
+    "sectionHiddenHint": "Скрыт с листа. Нажмите, чтобы показать.",
+    "railHint": "Перетащите раздел на страницу: отпустите между блоками, чтобы изменить порядок, на карточку, чтобы встать рядом с ней, или в любое другое место, чтобы разместить свободно. Страница освободит место вокруг него.",
+    "returnToFlow": "Вернуть раздел «{section}» в поток",
+    "saveDialogTitle": "Сохранить этот макет",
+    "saveDialogBody": "Сохраняет именованную копию текущей компоновки и оформления. Вы найдёте её в разделе «Ваши макеты» галереи шаблонов.",
+    "designNamePlaceholder": "Название макета",
+    "nameExists": "Макет с таким названием уже есть; сохранение обновит его.",
+    "updateDesign": "Обновить макет",
+    "cancel": "Отмена",
+    "insertHere": "Вставить здесь",
+    "dropLeftColumn": "Левая колонка",
+    "dropRightColumn": "Правая колонка",
+    "freePosition": "Свободное место",
+    "sectionSettings": "Настройки раздела",
+    "panelStyle": "Стиль панели",
+    "variant": {
+      "accent": "Акцент",
+      "panel": "Панель",
+      "outline": "Контур",
+      "lines": "Линии",
+      "hint": "Акцент подкрашивает карточку основным цветом. Панель приводит её к виду остальных карточек с деталями. Контур рисует только рамку, а Линии печатают без рамки."
+    },
+    "placement": "Размещение",
+    "visible": "Виден",
+    "columnFull": "Вся ширина",
+    "columnLeft": "Слева",
+    "columnRight": "Справа",
+    "drawBox": "Рисовать рамку",
+    "showHeading": "Показывать заголовок",
+    "spacing": "Отступы",
+    "placedByHand": "Этот раздел размещён вручную, поэтому отступы потока его не двигают.",
+    "placedByHandFramed": "Бланк в рамке лежит на полосе; перетащите его, чтобы сдвинуть.",
+    "placedByHandDrag": "Перетащите его или верните в поток, чтобы отступы заработали.",
+    "marginTop": "Сверху",
+    "marginBottom": "Снизу",
+    "marginLeft": "Слева",
+    "marginRight": "Справа",
+    "spacingHint": "Пункты пустого места, которое раздел держит вокруг себя в потоке.",
+    "logo": "Логотип",
+    "size": "Размер",
+    "logoHint": "Отключите логотип в разделе «Поля», чтобы вместо него печаталось название компании.",
+    "fields": "Поля",
+    "removeField": "Убрать из этого раздела",
+    "yourCustomFields": "Ваши свои поля",
+    "appearance": "Внешний вид",
+    "details": "Реквизиты",
+    "text": "Текст",
+    "companyName": "Название компании",
+    "heading": "Заголовок",
+    "fill": "Заливка",
+    "border": "Рамка",
+    "lineWidth": "Толщина линии",
+    "borderWidth": "Толщина рамки",
+    "outerBorder": "Внешняя рамка",
+    "rowBanding": "Чередование строк",
+    "likeSheet": "Как на листе",
+    "on": "Вкл.",
+    "off": "Выкл.",
+    "font": "Шрифт",
+    "inherit": "Наследовать",
+    "auto": "авто",
+    "sectionDragHint": "Перетащите этот раздел в списке или на странице, чтобы сдвинуть его. После размещения вручную стрелки двигают его на один пункт, с Shift на десять, а Escape отменяет перетаскивание.",
+    "document": "Документ",
+    "documentSubtitle": "Применяется ко всему листу",
+    "headerStyleTitle": "Стиль шапки",
+    "headerStyle": {
+      "standard": {
+        "name": "Обычная",
+        "desc": "Название и реквизиты на белом, линия снизу"
+      },
+      "compact": {
+        "name": "Компактная",
+        "desc": "Узкая шапка, плотные строки"
+      },
+      "modern": {
+        "name": "Современная",
+        "desc": "Цветная полоса во всю ширину"
+      },
+      "framed": {
+        "name": "В рамке",
+        "desc": "Полоса сверху и планка по левому краю"
+      }
+    },
+    "colors": "Цвета",
+    "primary": "Основной",
+    "background": "Фон",
+    "accent": "Акцент",
+    "frame": "Рамка",
+    "rail": "Планка",
+    "leftEdge": "Левый край",
+    "rightEdge": "Правый край",
+    "frameLine": "Линия рамки",
+    "cornerRadius": "Скругление углов",
+    "shadow": "Тень",
+    "shadowThin": "Тонкая",
+    "shadowNormal": "Обычная",
+    "shadowWide": "Широкая",
+    "typography": "Типографика",
+    "baseSize": "Базовый размер",
+    "page": "Страница",
+    "margins": "Поля страницы",
+    "rowHeight": "Высота строки",
+    "band": "Полоса",
+    "sample": {
+      "itemLabor": "Замена передних тормозов",
+      "itemDisc": "Тормозной диск, передний",
+      "itemPads": "Комплект колодок, передняя ось",
+      "itemConsumables": "Расходные материалы",
+      "unitPcs": "шт.",
+      "unitSet": "компл.",
+      "unitEach": "x",
+      "findingPads": "Задние колодки на 15 %",
+      "findingPadsNote": "Заменить в течение 5 000 км",
+      "findingOilWeep": "Небольшое запотевание масла у клапанной крышки",
+      "notes": "Спасибо, что выбрали нашу мастерскую. Если появятся вопросы, свяжитесь с нами.",
+      "warranty": "Гарантия 12 месяцев на детали и работу.",
+      "serviceTitle": "Годовое ТО и тормоза",
+      "serviceType": "Ремонт",
+      "companyName": "Ваша мастерская",
+      "slogan": "Качественный сервис, которому можно доверять",
+      "footerNote": "Благодарим за обращение",
+      "value": "Пример"
+    }
+  },
+  "preview": {
+    "title": "Обслуживание тормозов и замена масла",
+    "serviceType": "Техобслуживание",
+    "findingPads": "Задние тормозные колодки изношены до 15 %",
+    "findingPadsNote": "Рекомендуем заменить в течение 8 000 км",
+    "findingOilLeak": "Небольшая течь масла по прокладке клапанной крышки",
+    "invoiceNotes": "Передние тормозные колодки заменены. Масло и фильтр заменены на синтетику 5W-30. Следующее ТО рекомендуем на 80 000 км.",
+    "quoteNotes": "Рекомендуем заменить передние тормозные колодки. Замена масла и фильтра на синтетику 5W-30.",
+    "partBrakePads": "Тормозные колодки (передние)",
+    "partOilFilter": "Масляный фильтр",
+    "partSyntheticOil": "Синтетическое масло 5W-30",
+    "laborBrakeReplacement": "Замена тормозов",
+    "laborOilChange": "Замена масла"
   }
 }

+ 10 - 2
messages/tr/pdf.json

@@ -22,6 +22,9 @@
     "items": "Kalemler",
     "pos": "No",
     "unitOfMeasure": "Birim",
+    "invoiceNumberLabel": "Fatura No",
+    "customerNumberLabel": "Müşteri No",
+    "dateLabel": "Tarih",
     "total": "Toplam",
     "labor": "İşçilik",
     "hours": "Saat",
@@ -67,7 +70,9 @@
     "professionalWorkshop": "Profesyonel Atölye Yönetimi",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Ek bilgiler",
+    "telegramConnect": "Telegram'dan bize yazın"
   },
   "quote": {
     "title": "TEKLİF",
@@ -103,7 +108,10 @@
     "validityFooter30": "Bu teklif 30 gün süreyle geçerlidir",
     "viewPortal": "Portalınızı görüntüleyin: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours"
+    "qtyOrHours": "Unit / Hours",
+    "customFieldsTitle": "Ek bilgiler",
+    "quoteNumberLabel": "Teklif No.",
+    "validUntilLabel": "Geçerlilik tarihi"
   },
   "inspection": {
     "title": "ARAÇ MUAYENESİ",

+ 250 - 2
messages/tr/settings.json

@@ -161,6 +161,9 @@
     "email": "E-posta",
     "emailPlaceholder": "servis@ornek.com",
     "address": "Adres",
+    "slogan": "Slogan",
+    "sloganPlaceholder": "Tüm markalara servis ve onarım",
+    "sloganHint": "Faturalarda ve tekliflerde adınızın altındaki tek satır.",
     "addressPlaceholder": "Atatürk Cad. No: 123\nKat: 1\n34000 İstanbul",
     "saveCompany": "Şirket Bilgilerini Kaydet",
     "addNewCompany": "Yeni Şirket Ekle",
@@ -552,13 +555,19 @@
     "customerNumbersAssigned": "{count} müşteriye numara atandı",
     "allCustomersNumbered": "Tüm müşterilerin numarası var.",
     "assignCustomerNumbersConfirmTitle": "Müşteri numaraları atansın mı?",
-    "assignCustomerNumbersConfirmDescription": "Numarası olmayan {count} müşteriye, oluşturulma sırasına göre ve mevcut en yüksek numaradan devam eden sıralı numaralar verilecek. Numarası olan müşteriler değişmez. Numaralar sonradan düzenlenebilir."
+    "assignCustomerNumbersConfirmDescription": "Numarası olmayan {count} müşteriye, oluşturulma sırasına göre ve mevcut en yüksek numaradan devam eden sıralı numaralar verilecek. Numarası olan müşteriler değişmez. Numaralar sonradan düzenlenebilir.",
+    "layoutColorsHint": "Renkler, yazı tipleri ve antet stili Şablonlar’da bulunur.",
+    "goToTemplates": "Şablonları aç"
   },
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",
     "addField": "Alan ekle",
     "custom": "özel",
     "full": "Tam",
+    "boxed": "Çerçeve",
+    "plain": "Çerçevesiz",
+    "boxedHint": "Bir çerçeve içinde basılır",
+    "plainHint": "Çerçevesiz basılır",
     "L": "S",
     "R": "S",
     "left": "sol",
@@ -567,9 +576,11 @@
     "fullWidthHint": "Tam genişlik — yarım genişlik için tıklayın (sol)",
     "sections": {
       "header": "Başlık",
+      "slogan": "Slogan",
       "customer": "Müşteri",
       "vehicle": "Araç",
       "service": "Servis",
+      "document_title": "Belge başlığı",
       "items_table": "Kalem tablosu",
       "parts_table": "Parçalar",
       "labor_table": "İşçilik",
@@ -577,6 +588,7 @@
       "notes": "Notlar",
       "diagnostic_notes": "Teşhis notları",
       "findings": "Gözlemler",
+      "warranty": "Garanti",
       "bank_account": "Banka hesabı",
       "footer": "Alt bilgi",
       "general": "Genel",
@@ -598,12 +610,54 @@
       "tech_name": "Teknisyen",
       "logo": "Logo",
       "company_name": "Şirket adı",
+      "company_slogan": "Slogan",
       "company_address": "Adres",
       "company_phone": "Telefon",
       "company_email": "E-posta",
       "company_org_number": "Kuruluş numarası",
       "bank_account": "Banka hesabı",
+      "footer_note": "Alt bilgi notu",
       "org_number": "Kuruluş numarası"
+    },
+    "presetsTitle": "Bir düzenle başlayın",
+    "presetsHint": "Aşağıdaki düzeni değiştirir. Siz kaydetmeden hiçbir şey kaydedilmez.",
+    "presets": {
+      "classic": {
+        "name": "Klasik",
+        "description": "Parça ve işçilik ayrı tablolarda, bilgiler solda alt alta"
+      },
+      "letterhead": {
+        "name": "Antet",
+        "description": "İletişim bilgileri altta, başlık adreslerin altında, tek numaralı liste"
+      },
+      "compact": {
+        "name": "Kompakt",
+        "description": "Çerçevesiz, tek numaralı liste, yalnızca gerekli olanlar"
+      },
+      "itemized": {
+        "name": "Kalem kalem",
+        "description": "Birim sütunlu tek numaralı kalem listesi"
+      },
+      "detailed": {
+        "name": "Ayrıntılı",
+        "description": "Başlık üstte, araç tek başına, iki tablo ve tüm isteğe bağlı bloklar"
+      }
+    },
+    "style": {
+      "documentTitle": "Tüm sayfa",
+      "documentHint": "boyut, boşluk, vurgu, şeritler",
+      "textColor": "Metin",
+      "labelColor": "Başlık",
+      "backgroundColor": "Dolgu",
+      "borderColor": "Kenarlık",
+      "fontSize": "Boyut",
+      "rowPadding": "Satır yüksekliği",
+      "margin": "Kenar boşluğu",
+      "accentColor": "Vurgu",
+      "stripeColor": "Şerit",
+      "stripes": "Şeritler",
+      "fontFamily": "Yazı tipi",
+      "fontInherit": "Devral"
     }
   },
   "templates": {
@@ -620,6 +674,19 @@
     },
     "presets": "Şablon Hazır Ayarları",
     "primaryColor": "Ana Renk",
+    "colors": "Renkler",
+    "textColor": "Metin",
+    "textColorHint": "Başlıkların ve metnin rengi",
+    "companyTextColor": "Firma adı",
+    "companyTextColorHint": "Antetteki adın rengi",
+    "frameBorderColor": "Çerçeve çizgisi",
+    "frameBorderColorHint": "Çerçevenin kâğıtla buluştuğu çizgi",
+    "frameShadow": "Çerçeve gölgesi",
+    "frameShadowOn": "Gölgeli",
+    "frameShadowOff": "Düz",
+    "backgroundColor": "Arka plan",
+    "backgroundColorClear": "Temizle",
+    "backgroundColorHint": "Beyaz kâğıt için boş bırakın. Açık bir ton, renkli sayfa olarak basılır.",
     "fontAndLayout": "Yazı Tipi & Düzen",
     "fontFamily": "Yazı Tipi Ailesi",
     "helveticaDefault": "Helvetica (Varsayılan)",
@@ -630,6 +697,10 @@
     "standard": "Standart",
     "compact": "Kompakt",
     "modern": "Modern",
+    "framed": "Çerçeveli",
+    "letterheadMark": "Antet",
+    "letterheadLogo": "Logo",
+    "letterheadCompanyName": "Firma adı",
     "preview": "{name} Önizleme",
     "billTo": "Fatura Adresi",
     "preparedFor": "Hazırlanan",
@@ -699,7 +770,11 @@
     "layoutDescription": "Fatura ve tekliflerinizdeki bölümlerin sırasını ve görünürlüğünü özelleştirin.",
     "layoutSaved": "Düzen başarıyla kaydedildi",
     "layoutDocInvoice": "Fatura",
-    "layoutDocQuote": "Teklif"
+    "layoutDocQuote": "Teklif",
+    "goToLayout": "Düzen ve bölümler",
+    "openDesigner": "Tasarımcıyı aç",
+    "designerIntro": "Bir başlangıç noktası seçin, sonra her şeyi değiştirmek için tasarımcıyı açın.",
+    "designerHint": "Renkler, yazı tipleri, boşluklar ve tüm bölümler tasarımcıda."
   },
   "payment": {
     "readMore": "Devamını oku",
@@ -1635,5 +1710,178 @@
         "description": "Müşterilerinizle Telegram mesajlarını doğrudan Torqvoice üzerinden gönderip alın."
       }
     }
+  },
+  "designer": {
+    "invoice": "Fatura",
+    "quote": "Teklif",
+    "eyebrow": "Belge tasarımcısı",
+    "galleryTitle": "Bir şablonla başlayın",
+    "galleryHint": "Bir başlangıç noktası seçin. Tasarımcıda her şeyi değiştirebilirsiniz.",
+    "yourDesigns": "Tasarımlarınız",
+    "templates": "Şablonlar",
+    "inUse": "Kullanımda",
+    "savedOn": "{date} tarihinde kaydedildi",
+    "deleteDesignTitle": "Tasarımı sil",
+    "deleteDesignBody": "\"{name}\" tasarımı silinsin mi? Fatura ve teklif uygulanmış görünümü korur; yalnızca kayıtlı kopya gider.",
+    "delete": "Sil",
+    "deleteDesign": "Bu tasarımı sil",
+    "continueCurrent": "Mevcut düzenimle devam et",
+    "backToSettings": "Ayarlara dön",
+    "useInvoiceDesign": "Fatura tasarımını kullan",
+    "copiedToQuote": "Fatura tasarımı teklife kopyalandı",
+    "rulers": "Cetveller",
+    "saveDesign": "Tasarımı kaydet",
+    "saveDesignHint": "Bu tasarımın adlandırılmış bir kopyasını saklayın, şablon galerisinden ona dönebilirsiniz",
+    "reset": "Sıfırla",
+    "save": "Kaydet",
+    "saving": "Kaydediliyor…",
+    "saved": "Kaydedildi",
+    "couldNotSave": "Kaydedilemedi",
+    "couldNotSaveDesigns": "Tasarımlarınız kaydedilemedi",
+    "designUpdated": "\"{name}\" güncellendi",
+    "designSaved": "\"{name}\" olarak kaydedildi",
+    "defaultDesignName": "{doc} tasarımım",
+    "documentStyling": "Belge biçimi",
+    "sections": "Bölümler",
+    "sectionEmpty": "boş",
+    "sectionEmptyHint": "Gösteriliyor, ancak mevcut verilerle içinde yazdırılacak bir şey yok.",
+    "sectionShownHint": "Sayfada görünüyor. Gizlemek için tıklayın.",
+    "sectionHiddenHint": "Sayfada gizli. Göstermek için tıklayın.",
+    "railHint": "Bir bölümü sayfaya sürükleyin: sırayı değiştirmek için bloklar arasına, yanına oturması için bir kartın üzerine, serbestçe yerleştirmek için başka bir yere bırakın. Sayfa çevresinde yer açar.",
+    "returnToFlow": "{section} bölümünü akışa geri koy",
+    "saveDialogTitle": "Bu tasarımı kaydet",
+    "saveDialogBody": "Mevcut düzenin ve görünümün adlandırılmış bir kopyasını saklar. Şablon galerisindeki Tasarımlarınız bölümünde yeniden bulabilirsiniz.",
+    "designNamePlaceholder": "Tasarım adı",
+    "nameExists": "Bu adda bir tasarım var; kaydetmek onu günceller.",
+    "updateDesign": "Tasarımı güncelle",
+    "cancel": "İptal",
+    "insertHere": "Buraya ekle",
+    "dropLeftColumn": "Sol sütun",
+    "dropRightColumn": "Sağ sütun",
+    "freePosition": "Serbest konum",
+    "sectionSettings": "Bölüm ayarları",
+    "panelStyle": "Panel biçimi",
+    "variant": {
+      "accent": "Vurgu",
+      "panel": "Panel",
+      "outline": "Çerçeve",
+      "lines": "Çizgiler",
+      "hint": "Vurgu, kartı ana renkle tonlar. Panel, diğer ayrıntı kartlarıyla aynı görünür. Çerçeve yalnızca bir kenarlık çizer, Çizgiler ise kutusuz yazdırır."
+    },
+    "placement": "Yerleşim",
+    "visible": "Görünür",
+    "columnFull": "Tam",
+    "columnLeft": "Sol",
+    "columnRight": "Sağ",
+    "drawBox": "Kutu çiz",
+    "showHeading": "Başlığı göster",
+    "spacing": "Boşluk",
+    "placedByHand": "Bu bölüm elle yerleştirildi, bu yüzden akış boşlukları onu hareket ettirmez.",
+    "placedByHandFramed": "Çerçeveli antet şeridin üzerinde durur; taşımak için sürükleyin.",
+    "placedByHandDrag": "Boşlukları kullanmak için sürükleyin ya da akışa geri koyun.",
+    "marginTop": "Üst",
+    "marginBottom": "Alt",
+    "marginLeft": "Sol",
+    "marginRight": "Sağ",
+    "spacingHint": "Bölümün akışta çevresinde tuttuğu boş alan, punto cinsinden.",
+    "logo": "Logo",
+    "size": "Boyut",
+    "logoHint": "Yerine şirket adının yazdırılması için Alanlar altında logoyu kapatın.",
+    "fields": "Alanlar",
+    "removeField": "Bu bölümden kaldır",
+    "yourCustomFields": "Özel alanlarınız",
+    "appearance": "Görünüm",
+    "details": "Ayrıntılar",
+    "text": "Metin",
+    "companyName": "Şirket adı",
+    "heading": "Başlık",
+    "fill": "Dolgu",
+    "border": "Kenarlık",
+    "lineWidth": "Çizgi kalınlığı",
+    "borderWidth": "Kenarlık kalınlığı",
+    "outerBorder": "Dış kenarlık",
+    "rowBanding": "Satır şeritleri",
+    "likeSheet": "Sayfa gibi",
+    "on": "Açık",
+    "off": "Kapalı",
+    "font": "Yazı tipi",
+    "inherit": "Devral",
+    "auto": "oto",
+    "sectionDragHint": "Taşımak için bu bölümü listede ya da sayfada sürükleyin. Elle yerleştirildikten sonra yön tuşları onu birer punto, Shift ile onar punto kaydırır, Escape ise sürüklemeyi iptal eder.",
+    "document": "Belge",
+    "documentSubtitle": "Tüm sayfa için geçerlidir",
+    "headerStyleTitle": "Antet biçimi",
+    "headerStyle": {
+      "standard": {
+        "name": "Standart",
+        "desc": "Beyaz üzerinde ad ve bilgiler, altında çizgi"
+      },
+      "compact": {
+        "name": "Sıkışık",
+        "desc": "Dar antet, sık satırlar"
+      },
+      "modern": {
+        "name": "Modern",
+        "desc": "Tam genişlikte renkli şerit"
+      },
+      "framed": {
+        "name": "Çerçeveli",
+        "desc": "Üstte şerit, sol kenarda çubuk"
+      }
+    },
+    "colors": "Renkler",
+    "primary": "Ana renk",
+    "background": "Arka plan",
+    "accent": "Vurgu",
+    "frame": "Çerçeve",
+    "rail": "Çubuk",
+    "leftEdge": "Sol kenar",
+    "rightEdge": "Sağ kenar",
+    "frameLine": "Çerçeve çizgisi",
+    "cornerRadius": "Köşe yuvarlaklığı",
+    "shadow": "Gölge",
+    "shadowThin": "İnce",
+    "shadowNormal": "Normal",
+    "shadowWide": "Geniş",
+    "typography": "Tipografi",
+    "baseSize": "Temel boyut",
+    "page": "Sayfa",
+    "margins": "Kenar boşlukları",
+    "rowHeight": "Satır yüksekliği",
+    "band": "Şerit",
+    "sample": {
+      "itemLabor": "Ön frenler değiştirildi",
+      "itemDisc": "Fren diski, ön",
+      "itemPads": "Fren balata takımı, ön aks",
+      "itemConsumables": "Sarf malzemeleri",
+      "unitPcs": "adet",
+      "unitSet": "takım",
+      "unitEach": "x",
+      "findingPads": "Arka balatalar %15 seviyesinde",
+      "findingPadsNote": "5.000 km içinde değiştirin",
+      "findingOilWeep": "Külbütör kapağında hafif yağ sızıntısı",
+      "notes": "Servisimizi seçtiğiniz için teşekkür ederiz. Sorunuz olursa bize ulaşın.",
+      "warranty": "Parça ve işçilikte 12 ay garanti.",
+      "serviceTitle": "Yıllık bakım ve frenler",
+      "serviceType": "Onarım",
+      "companyName": "Servisiniz",
+      "slogan": "Güvenebileceğiniz kaliteli hizmet",
+      "footerNote": "İş birliğiniz için teşekkürler",
+      "value": "Örnek"
+    }
+  },
+  "preview": {
+    "title": "Fren bakımı ve yağ değişimi",
+    "serviceType": "Bakım",
+    "findingPads": "Arka fren balataları %15 seviyesine inmiş",
+    "findingPadsNote": "8.000 km içinde değiştirilmesi önerilir",
+    "findingOilLeak": "Külbütör kapağı contasında hafif yağ sızıntısı",
+    "invoiceNotes": "Ön fren balataları değiştirildi. Yağ ve filtre sentetik 5W-30 ile yenilendi. Bir sonraki bakım 80.000 km'de önerilir.",
+    "quoteNotes": "Ön fren balatalarının değiştirilmesi önerilir. Sentetik 5W-30 ile yağ ve filtre değişimi.",
+    "partBrakePads": "Fren balatası (ön)",
+    "partOilFilter": "Yağ filtresi",
+    "partSyntheticOil": "Sentetik yağ 5W-30",
+    "laborBrakeReplacement": "Fren değişimi",
+    "laborOilChange": "Yağ değişimi"
   }
 }

BIN
public/fonts/Lato-Bold.ttf


BIN
public/fonts/Lato-Regular.ttf


BIN
public/fonts/Montserrat-Bold.ttf


BIN
public/fonts/Montserrat-Regular.ttf


BIN
public/fonts/NotoSansMono-Bold.ttf


BIN
public/fonts/NotoSansMono-Regular.ttf


BIN
public/fonts/NotoSerif-Bold.ttf


BIN
public/fonts/NotoSerif-Regular.ttf


BIN
public/fonts/OpenSans-Bold.ttf


BIN
public/fonts/OpenSans-Regular.ttf


BIN
public/fonts/PTSans-Bold.ttf


BIN
public/fonts/PTSans-Regular.ttf


+ 62 - 0
scripts/generate-font-widths.mjs

@@ -0,0 +1,62 @@
+/**
+ * Regenerates src/features/invoice-designer/Pdf/fontWidths.ts from the TTFs
+ * the PDFs embed, so text can be wrapped identically on server and client
+ * without loading font files at runtime. Run: node scripts/generate-font-widths.mjs
+ */
+import { createRequire } from 'node:module'
+import { readFileSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+
+const require = createRequire(import.meta.url)
+const fontkit = require('fontkit')
+
+const FONTS = [
+  ['Helvetica', 'Roboto-Regular.ttf', 'Roboto-Bold.ttf'],
+  ['Times-Roman', 'NotoSerif-Regular.ttf', 'NotoSerif-Bold.ttf'],
+  ['Courier', 'NotoSansMono-Regular.ttf', 'NotoSansMono-Bold.ttf'],
+  ['Open Sans', 'OpenSans-Regular.ttf', 'OpenSans-Bold.ttf'],
+  ['Lato', 'Lato-Regular.ttf', 'Lato-Bold.ttf'],
+  ['Montserrat', 'Montserrat-Regular.ttf', 'Montserrat-Bold.ttf'],
+  ['PT Sans', 'PTSans-Regular.ttf', 'PTSans-Bold.ttf'],
+]
+
+// ASCII plus the Latin-1/Latin Extended and Cyrillic ranges the app's locales use.
+const CHARS = []
+for (let c = 0x20; c <= 0x7e; c++) CHARS.push(c)
+for (let c = 0xa0; c <= 0x17f; c++) CHARS.push(c)
+for (const c of [0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x20ac, 0xb7]) CHARS.push(c)
+for (let c = 0x400; c <= 0x45f; c++) CHARS.push(c)
+
+const dir = path.resolve('src/assets/fonts')
+const table = {}
+for (const [family, regular, bold] of FONTS) {
+  for (const [weight, file] of [['regular', regular], ['bold', bold]]) {
+    const font = fontkit.create(readFileSync(path.join(dir, file)))
+    const upm = font.unitsPerEm
+    const widths = {}
+    for (const code of CHARS) {
+      if (!font.hasGlyphForCodePoint(code)) continue
+      const glyph = font.glyphForCodePoint(code)
+      widths[code] = Math.round((glyph.advanceWidth / upm) * 1000)
+    }
+    // A representative width for anything outside the table.
+    const fallback = widths[0x6e] ?? 500 // 'n'
+    table[`${family}|${weight}`] = { fallback, widths }
+  }
+}
+
+const out = `/**
+ * Character advance widths, in thousandths of the font size, for the exact
+ * font files the PDFs embed. Generated by scripts/generate-font-widths.mjs —
+ * do not edit by hand. This is what lets the server wrap text the same way
+ * the printed page will, without opening a font file at runtime.
+ */
+export interface WidthTable {
+  fallback: number
+  widths: Record<number, number>
+}
+
+export const FONT_WIDTHS: Record<string, WidthTable> = ${JSON.stringify(table)}
+`
+writeFileSync('src/features/invoice-designer/Pdf/fontWidths.ts', out)
+console.log('written', Object.keys(table).join(', '))

+ 83 - 93
src/__tests__/api/fetch-metadata-ssrf.test.ts

@@ -6,126 +6,116 @@
  *   - a blocked target (direct or via redirect) is rejected before fetch,
  *   - legitimate direct + redirecting URLs still return parsed metadata.
  */
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/auth", () => ({
+vi.mock('@/lib/auth', () => ({
   auth: { api: { getSession: vi.fn() } },
-}));
+}))
 
-vi.mock("next/headers", () => ({
+vi.mock('next/headers', () => ({
   headers: vi.fn().mockResolvedValue(new Headers()),
-}));
+}))
 
-vi.mock("@/features/webhooks/Lib/ssrf", () => ({
+vi.mock('@/features/webhooks/Lib/ssrf', () => ({
   checkWebhookUrl: vi.fn(),
-}));
+}))
 
-import { auth } from "@/lib/auth";
-import { headers } from "next/headers";
-import { checkWebhookUrl } from "@/features/webhooks/Lib/ssrf";
-import { POST } from "@/app/api/protected/fetch-metadata/route";
-import type { NextRequest } from "next/server";
+import { auth } from '@/lib/auth'
+import { headers } from 'next/headers'
+import { checkWebhookUrl } from '@/features/webhooks/Lib/ssrf'
+import { POST } from '@/app/api/protected/fetch-metadata/route'
+import type { NextRequest } from 'next/server'
 
-const mockGetSession = vi.mocked(auth.api.getSession);
-const mockCheck = vi.mocked(checkWebhookUrl);
-const mockFetch = vi.fn();
+const mockGetSession = vi.mocked(auth.api.getSession)
+const mockCheck = vi.mocked(checkWebhookUrl)
+const mockFetch = vi.fn()
 
 function req(url: unknown): NextRequest {
-  return { json: async () => ({ url }) } as unknown as NextRequest;
+  return { json: async () => ({ url }) } as unknown as NextRequest
 }
 
-function res(
-  status: number,
-  headers: Record<string, string> = {},
-  body = "",
-): Response {
+function res(status: number, headers: Record<string, string> = {}, body = ''): Response {
   return {
     status,
     ok: status >= 200 && status < 300,
     headers: new Headers(headers),
     text: async () => body,
-  } as unknown as Response;
+  } as unknown as Response
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
-  vi.stubGlobal("fetch", mockFetch);
+  vi.resetAllMocks()
+  vi.stubGlobal('fetch', mockFetch)
   // resetAllMocks wipes implementations, so re-establish the default mocks.
-  vi.mocked(headers).mockResolvedValue(new Headers());
-  mockGetSession.mockResolvedValue({ user: { id: "user-1" } } as never);
-});
-
-describe("POST /api/protected/fetch-metadata — SSRF guard", () => {
-  it("returns 401 when unauthenticated (and never fetches)", async () => {
-    mockGetSession.mockResolvedValue(null as never);
-    const r = await POST(req("http://internal.local"));
-    expect(r.status).toBe(401);
-    expect(mockFetch).not.toHaveBeenCalled();
-  });
-
-  it("rejects a blocked target with 400 and never fetches it", async () => {
-    mockCheck.mockResolvedValue({ ok: false, reason: "private_ip" });
-    const r = await POST(req("http://169.254.169.254/latest/meta-data/"));
-    expect(r.status).toBe(400);
-    expect(await r.json()).toEqual({ error: "This URL is not allowed." });
-    expect(mockCheck).toHaveBeenCalledWith("http://169.254.169.254/latest/meta-data/");
-    expect(mockFetch).not.toHaveBeenCalled();
-  });
-
-  it("returns parsed metadata for a legitimate direct URL", async () => {
-    mockCheck.mockResolvedValue({ ok: true });
+  vi.mocked(headers).mockResolvedValue(new Headers())
+  mockGetSession.mockResolvedValue({ user: { id: 'user-1' } } as never)
+})
+
+describe('POST /api/protected/fetch-metadata — SSRF guard', () => {
+  it('returns 401 when unauthenticated (and never fetches)', async () => {
+    mockGetSession.mockResolvedValue(null as never)
+    const r = await POST(req('http://internal.local'))
+    expect(r.status).toBe(401)
+    expect(mockFetch).not.toHaveBeenCalled()
+  })
+
+  it('rejects a blocked target with 400 and never fetches it', async () => {
+    mockCheck.mockResolvedValue({ ok: false, reason: 'private_ip' })
+    const r = await POST(req('http://169.254.169.254/latest/meta-data/'))
+    expect(r.status).toBe(400)
+    expect(await r.json()).toEqual({ error: 'This URL is not allowed.' })
+    expect(mockCheck).toHaveBeenCalledWith('http://169.254.169.254/latest/meta-data/')
+    expect(mockFetch).not.toHaveBeenCalled()
+  })
+
+  it('returns parsed metadata for a legitimate direct URL', async () => {
+    mockCheck.mockResolvedValue({ ok: true })
     mockFetch.mockResolvedValue(
-      res(200, { "content-type": "text/html" }, "<title>Example Domain</title>"),
-    );
-    const r = await POST(req("https://example.com"));
-    expect(r.status).toBe(200);
-    expect((await r.json()).name).toBe("Example Domain");
-    expect(mockFetch.mock.calls[0][1].redirect).toBe("manual");
-  });
-
-  it("follows a redirect and validates EVERY hop", async () => {
-    mockCheck.mockResolvedValue({ ok: true });
+      res(200, { 'content-type': 'text/html' }, '<title>Example Domain</title>')
+    )
+    const r = await POST(req('https://example.com'))
+    expect(r.status).toBe(200)
+    expect((await r.json()).name).toBe('Example Domain')
+    expect(mockFetch.mock.calls[0][1].redirect).toBe('manual')
+  })
+
+  it('follows a redirect and validates EVERY hop', async () => {
+    mockCheck.mockResolvedValue({ ok: true })
     mockFetch
-      .mockResolvedValueOnce(res(301, { location: "https://final.example/page" }))
-      .mockResolvedValueOnce(
-        res(200, { "content-type": "text/html" }, "<title>Final Page</title>"),
-      );
+      .mockResolvedValueOnce(res(301, { location: 'https://final.example/page' }))
+      .mockResolvedValueOnce(res(200, { 'content-type': 'text/html' }, '<title>Final Page</title>'))
 
-    const r = await POST(req("http://start.example"));
+    const r = await POST(req('http://start.example'))
 
-    expect(r.status).toBe(200);
-    expect((await r.json()).name).toBe("Final Page");
-    expect(mockFetch).toHaveBeenCalledTimes(2);
+    expect(r.status).toBe(200)
+    expect((await r.json()).name).toBe('Final Page')
+    expect(mockFetch).toHaveBeenCalledTimes(2)
     // Guard ran on both the initial URL and the redirect target.
-    expect(mockCheck).toHaveBeenCalledWith("http://start.example");
-    expect(mockCheck).toHaveBeenCalledWith("https://final.example/page");
+    expect(mockCheck).toHaveBeenCalledWith('http://start.example')
+    expect(mockCheck).toHaveBeenCalledWith('https://final.example/page')
     // Never delegates redirect-following to fetch.
-    expect(mockFetch.mock.calls[0][1].redirect).toBe("manual");
-  });
+    expect(mockFetch.mock.calls[0][1].redirect).toBe('manual')
+  })
 
-  it("blocks a redirect that points at an internal target (no second fetch)", async () => {
+  it('blocks a redirect that points at an internal target (no second fetch)', async () => {
     mockCheck
       .mockResolvedValueOnce({ ok: true }) // initial public URL
-      .mockResolvedValueOnce({ ok: false, reason: "private_ip" }); // redirect target
-    mockFetch.mockResolvedValueOnce(
-      res(302, { location: "http://127.0.0.1:5432/" }),
-    );
-
-    const r = await POST(req("http://start.example"));
-
-    expect(r.status).toBe(400);
-    expect(mockFetch).toHaveBeenCalledTimes(1); // never fetched the internal target
-    expect(mockCheck).toHaveBeenCalledWith("http://127.0.0.1:5432/");
-  });
-
-  it("stops with 422 on a redirect loop instead of following forever", async () => {
-    mockCheck.mockResolvedValue({ ok: true });
-    let n = 0;
-    mockFetch.mockImplementation(async () =>
-      res(302, { location: `https://hop.example/${n++}` }),
-    );
-    const r = await POST(req("http://start.example"));
-    expect(r.status).toBe(422);
-    expect(await r.json()).toEqual({ error: "Too many redirects" });
-  });
-});
+      .mockResolvedValueOnce({ ok: false, reason: 'private_ip' }) // redirect target
+    mockFetch.mockResolvedValueOnce(res(302, { location: 'http://127.0.0.1:5432/' }))
+
+    const r = await POST(req('http://start.example'))
+
+    expect(r.status).toBe(400)
+    expect(mockFetch).toHaveBeenCalledTimes(1) // never fetched the internal target
+    expect(mockCheck).toHaveBeenCalledWith('http://127.0.0.1:5432/')
+  })
+
+  it('stops with 422 on a redirect loop instead of following forever', async () => {
+    mockCheck.mockResolvedValue({ ok: true })
+    let n = 0
+    mockFetch.mockImplementation(async () => res(302, { location: `https://hop.example/${n++}` }))
+    const r = await POST(req('http://start.example'))
+    expect(r.status).toBe(422)
+    expect(await r.json()).toEqual({ error: 'Too many redirects' })
+  })
+})

+ 134 - 118
src/__tests__/api/subscription-upgrade-preview.test.ts

@@ -1,201 +1,217 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-const mockSubscriptionsRetrieve = vi.fn();
-const mockInvoicesCreatePreview = vi.fn();
+const mockSubscriptionsRetrieve = vi.fn()
+const mockInvoicesCreatePreview = vi.fn()
 
-vi.mock("@/lib/auth", () => ({
+vi.mock('@/lib/auth', () => ({
   auth: {
     api: { getSession: vi.fn() },
   },
-}));
+}))
 
-vi.mock("next/headers", () => ({
+vi.mock('next/headers', () => ({
   headers: vi.fn().mockResolvedValue(new Headers()),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     organizationMember: { findFirst: vi.fn() },
     subscription: { findUnique: vi.fn() },
   },
-}));
+}))
 
-vi.mock("@/lib/stripe-config", () => ({
+vi.mock('@/lib/stripe-config', () => ({
   getStripeConfig: vi.fn(),
   getStripeClient: vi.fn(),
-}));
+}))
 
-import { auth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
-import { POST } from "@/app/api/protected/subscription/upgrade-preview/route";
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { getStripeClient, getStripeConfig } from '@/lib/stripe-config'
+import { POST } from '@/app/api/protected/subscription/upgrade-preview/route'
 
-const mockGetSession = vi.mocked(auth.api.getSession);
-const mockFindMember = vi.mocked(db.organizationMember.findFirst);
-const mockFindSubscription = vi.mocked(db.subscription.findUnique);
-const mockGetStripeConfig = vi.mocked(getStripeConfig);
-const mockGetStripeClient = vi.mocked(getStripeClient);
+const mockGetSession = vi.mocked(auth.api.getSession)
+const mockFindMember = vi.mocked(db.organizationMember.findFirst)
+const mockFindSubscription = vi.mocked(db.subscription.findUnique)
+const mockGetStripeConfig = vi.mocked(getStripeConfig)
+const mockGetStripeClient = vi.mocked(getStripeClient)
 
 function setupAuth() {
   mockGetSession.mockResolvedValue({
-    user: { id: "user-1", email: "user@example.com" },
-  } as any);
-  mockFindMember.mockResolvedValue({ organizationId: "org-1" } as any);
+    user: { id: 'user-1', email: 'user@example.com' },
+  } as any)
+  mockFindMember.mockResolvedValue({ organizationId: 'org-1' } as any)
 }
 
 function setupStripe() {
   mockGetStripeConfig.mockResolvedValue({
-    secretKey: "sk_test",
-    webhookSecret: "whsec_test",
-    proPriceId: "price_pro",
-    enterprisePriceId: "price_enterprise",
-  });
+    secretKey: 'sk_test',
+    webhookSecret: 'whsec_test',
+    proPriceId: 'price_pro',
+    enterprisePriceId: 'price_enterprise',
+  })
   mockGetStripeClient.mockResolvedValue({
     subscriptions: { retrieve: mockSubscriptionsRetrieve },
     invoices: { createPreview: mockInvoicesCreatePreview },
-  } as any);
+  } as any)
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
-
-describe("POST /api/protected/subscription/upgrade-preview", () => {
-  it("returns 401 when not authenticated", async () => {
-    mockGetSession.mockResolvedValue(null);
-    const res = await POST();
-    expect(res.status).toBe(401);
-  });
-
-  it("returns 400 when no subscription exists", async () => {
-    setupAuth();
-    mockFindSubscription.mockResolvedValue(null);
-
-    const res = await POST();
-    expect(res.status).toBe(400);
-  });
-
-  it("returns only proration amount, not full invoice", async () => {
-    setupAuth();
+  vi.resetAllMocks()
+})
+
+describe('POST /api/protected/subscription/upgrade-preview', () => {
+  it('returns 401 when not authenticated', async () => {
+    mockGetSession.mockResolvedValue(null)
+    const res = await POST()
+    expect(res.status).toBe(401)
+  })
+
+  it('returns 400 when no subscription exists', async () => {
+    setupAuth()
+    mockFindSubscription.mockResolvedValue(null)
+
+    const res = await POST()
+    expect(res.status).toBe(400)
+  })
+
+  it('returns only proration amount, not full invoice', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      stripeCustomerId: "cus_123",
-      status: "active",
-    } as any);
-    setupStripe();
+      stripeSubscriptionId: 'sub_123',
+      stripeCustomerId: 'cus_123',
+      status: 'active',
+    } as any)
+    setupStripe()
 
     mockSubscriptionsRetrieve.mockResolvedValue({
-      id: "sub_123",
-      items: { data: [{ id: "si_item_1" }] },
-    });
+      id: 'sub_123',
+      items: { data: [{ id: 'si_item_1' }] },
+    })
 
     // Simulate Stripe preview with proration items + next period charge
     mockInvoicesCreatePreview.mockResolvedValue({
       amount_due: 18100, // Total including next period — should NOT be used
-      currency: "usd",
+      currency: 'usd',
       lines: {
         data: [
           {
             amount: -9900, // Credit for unused Pro
-            parent: { type: "invoice_item_details", invoice_item_details: { proration: true }, subscription_item_details: null },
+            parent: {
+              type: 'invoice_item_details',
+              invoice_item_details: { proration: true },
+              subscription_item_details: null,
+            },
           },
           {
             amount: 13900, // Charge for Enterprise remainder
-            parent: { type: "invoice_item_details", invoice_item_details: { proration: true }, subscription_item_details: null },
+            parent: {
+              type: 'invoice_item_details',
+              invoice_item_details: { proration: true },
+              subscription_item_details: null,
+            },
           },
           {
             amount: 14000, // Next period (should be excluded)
-            parent: { type: "subscription_item_details", invoice_item_details: null, subscription_item_details: { proration: false } },
+            parent: {
+              type: 'subscription_item_details',
+              invoice_item_details: null,
+              subscription_item_details: { proration: false },
+            },
           },
         ],
       },
-    });
+    })
 
-    const res = await POST();
-    expect(res.status).toBe(200);
-    const data = await res.json();
+    const res = await POST()
+    expect(res.status).toBe(200)
+    const data = await res.json()
     // Only proration items: -9900 + 13900 = 4000 cents = $40.00
-    expect(data.amountDue).toBe(40);
-    expect(data.currency).toBe("usd");
-    expect(data.prorationDate).toBeTypeOf("number");
-  });
+    expect(data.amountDue).toBe(40)
+    expect(data.currency).toBe('usd')
+    expect(data.prorationDate).toBeTypeOf('number')
+  })
 
-  it("returns 0 when proration is negative", async () => {
-    setupAuth();
+  it('returns 0 when proration is negative', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      stripeCustomerId: "cus_123",
-      status: "active",
-    } as any);
-    setupStripe();
+      stripeSubscriptionId: 'sub_123',
+      stripeCustomerId: 'cus_123',
+      status: 'active',
+    } as any)
+    setupStripe()
 
     mockSubscriptionsRetrieve.mockResolvedValue({
-      id: "sub_123",
-      items: { data: [{ id: "si_item_1" }] },
-    });
+      id: 'sub_123',
+      items: { data: [{ id: 'si_item_1' }] },
+    })
 
     mockInvoicesCreatePreview.mockResolvedValue({
       amount_due: 0,
-      currency: "usd",
+      currency: 'usd',
       lines: {
         data: [
           {
             amount: -5000,
-            parent: { type: "invoice_item_details", invoice_item_details: { proration: true }, subscription_item_details: null },
+            parent: {
+              type: 'invoice_item_details',
+              invoice_item_details: { proration: true },
+              subscription_item_details: null,
+            },
           },
         ],
       },
-    });
+    })
 
-    const res = await POST();
-    const data = await res.json();
-    expect(data.amountDue).toBe(0);
-  });
+    const res = await POST()
+    const data = await res.json()
+    expect(data.amountDue).toBe(0)
+  })
 
-  it("calls Stripe createPreview with correct params", async () => {
-    setupAuth();
+  it('calls Stripe createPreview with correct params', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      stripeCustomerId: "cus_123",
-      status: "active",
-    } as any);
-    setupStripe();
+      stripeSubscriptionId: 'sub_123',
+      stripeCustomerId: 'cus_123',
+      status: 'active',
+    } as any)
+    setupStripe()
 
     mockSubscriptionsRetrieve.mockResolvedValue({
-      id: "sub_123",
-      items: { data: [{ id: "si_item_1" }] },
-    });
+      id: 'sub_123',
+      items: { data: [{ id: 'si_item_1' }] },
+    })
 
     mockInvoicesCreatePreview.mockResolvedValue({
       amount_due: 0,
-      currency: "usd",
+      currency: 'usd',
       lines: { data: [] },
-    });
+    })
 
-    await POST();
+    await POST()
 
     expect(mockInvoicesCreatePreview).toHaveBeenCalledWith(
       expect.objectContaining({
-        customer: "cus_123",
-        subscription: "sub_123",
+        customer: 'cus_123',
+        subscription: 'sub_123',
         subscription_details: expect.objectContaining({
-          items: [{ id: "si_item_1", price: "price_enterprise" }],
+          items: [{ id: 'si_item_1', price: 'price_enterprise' }],
         }),
-      }),
-    );
-  });
+      })
+    )
+  })
 
-  it("returns 400 when subscription is not active", async () => {
-    setupAuth();
+  it('returns 400 when subscription is not active', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      stripeCustomerId: "cus_123",
-      status: "canceled",
-    } as any);
-
-    const res = await POST();
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("Subscription is not active");
-  });
-});
+      stripeSubscriptionId: 'sub_123',
+      stripeCustomerId: 'cus_123',
+      status: 'canceled',
+    } as any)
+
+    const res = await POST()
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('Subscription is not active')
+  })
+})

+ 220 - 222
src/__tests__/api/subscription-upgrade.test.ts

@@ -1,315 +1,313 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
 // ---------- Module mocks (hoisted) ----------
 
-const mockSubscriptionsRetrieve = vi.fn();
-const mockSubscriptionsUpdate = vi.fn();
+const mockSubscriptionsRetrieve = vi.fn()
+const mockSubscriptionsUpdate = vi.fn()
 
-vi.mock("@/lib/auth", () => ({
+vi.mock('@/lib/auth', () => ({
   auth: {
     api: { getSession: vi.fn() },
   },
-}));
+}))
 
-vi.mock("next/headers", () => ({
+vi.mock('next/headers', () => ({
   headers: vi.fn().mockResolvedValue(new Headers()),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     organizationMember: { findFirst: vi.fn() },
     subscription: { findUnique: vi.fn(), update: vi.fn() },
     subscriptionPlan: { upsert: vi.fn() },
     appSetting: { upsert: vi.fn() },
   },
-}));
+}))
 
-vi.mock("@/lib/stripe-config", () => ({
+vi.mock('@/lib/stripe-config', () => ({
   getStripeConfig: vi.fn(),
   getStripeClient: vi.fn(),
-}));
-
-import { auth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
-import { POST } from "@/app/api/protected/subscription/upgrade/route";
-
-const mockGetSession = vi.mocked(auth.api.getSession);
-const mockFindMember = vi.mocked(db.organizationMember.findFirst);
-const mockFindSubscription = vi.mocked(db.subscription.findUnique);
-const mockUpdateSubscription = vi.mocked(db.subscription.update);
-const mockUpsertPlan = vi.mocked(db.subscriptionPlan.upsert);
-const mockUpsertSetting = vi.mocked(db.appSetting.upsert);
-const mockGetStripeConfig = vi.mocked(getStripeConfig);
-const mockGetStripeClient = vi.mocked(getStripeClient);
+}))
+
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { getStripeClient, getStripeConfig } from '@/lib/stripe-config'
+import { POST } from '@/app/api/protected/subscription/upgrade/route'
+
+const mockGetSession = vi.mocked(auth.api.getSession)
+const mockFindMember = vi.mocked(db.organizationMember.findFirst)
+const mockFindSubscription = vi.mocked(db.subscription.findUnique)
+const mockUpdateSubscription = vi.mocked(db.subscription.update)
+const mockUpsertPlan = vi.mocked(db.subscriptionPlan.upsert)
+const mockUpsertSetting = vi.mocked(db.appSetting.upsert)
+const mockGetStripeConfig = vi.mocked(getStripeConfig)
+const mockGetStripeClient = vi.mocked(getStripeClient)
 
 function makeRequest(body: Record<string, unknown>) {
-  return new Request("http://localhost/api/protected/subscription/upgrade", {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
+  return new Request('http://localhost/api/protected/subscription/upgrade', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify(body),
-  });
+  })
 }
 
-function setupAuth(userId = "user-1", orgId = "org-1") {
+function setupAuth(userId = 'user-1', orgId = 'org-1') {
   mockGetSession.mockResolvedValue({
-    user: { id: userId, email: "user@example.com" },
-  } as any);
-  mockFindMember.mockResolvedValue({ organizationId: orgId } as any);
+    user: { id: userId, email: 'user@example.com' },
+  } as any)
+  mockFindMember.mockResolvedValue({ organizationId: orgId } as any)
 }
 
 function setupStripe() {
   mockGetStripeConfig.mockResolvedValue({
-    secretKey: "sk_test",
-    webhookSecret: "whsec_test",
-    proPriceId: "price_pro",
-    enterprisePriceId: "price_enterprise",
-  });
+    secretKey: 'sk_test',
+    webhookSecret: 'whsec_test',
+    proPriceId: 'price_pro',
+    enterprisePriceId: 'price_enterprise',
+  })
   mockGetStripeClient.mockResolvedValue({
     subscriptions: {
       retrieve: mockSubscriptionsRetrieve,
       update: mockSubscriptionsUpdate,
     },
-  } as any);
+  } as any)
 }
 
 function setupActiveSubscription() {
   mockFindSubscription.mockResolvedValue({
-    stripeSubscriptionId: "sub_123",
-    status: "active",
-    organizationId: "org-1",
-    plan: { name: "Torq Pro", stripePriceId: "price_pro" },
-  } as any);
+    stripeSubscriptionId: 'sub_123',
+    status: 'active',
+    organizationId: 'org-1',
+    plan: { name: 'Torq Pro', stripePriceId: 'price_pro' },
+  } as any)
 }
 
 function setupStripeResponses() {
   mockSubscriptionsRetrieve.mockResolvedValue({
-    id: "sub_123",
+    id: 'sub_123',
     items: {
-      data: [{ id: "si_item_1", price: { id: "price_pro" } }],
+      data: [{ id: 'si_item_1', price: { id: 'price_pro' } }],
     },
-  });
+  })
   mockSubscriptionsUpdate.mockResolvedValue({
-    id: "sub_123",
+    id: 'sub_123',
     items: {
       data: [
         {
-          id: "si_item_1",
-          price: { id: "price_enterprise" },
+          id: 'si_item_1',
+          price: { id: 'price_enterprise' },
           current_period_start: 1700000000,
           current_period_end: 1731536000,
         },
       ],
     },
-  });
+  })
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
-
-describe("POST /api/protected/subscription/upgrade", () => {
-  it("returns 401 when not authenticated", async () => {
-    mockGetSession.mockResolvedValue(null);
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(401);
-    const data = await res.json();
-    expect(data.error).toBe("Unauthorized");
-  });
-
-  it("returns 400 when user has no organization", async () => {
+  vi.resetAllMocks()
+})
+
+describe('POST /api/protected/subscription/upgrade', () => {
+  it('returns 401 when not authenticated', async () => {
+    mockGetSession.mockResolvedValue(null)
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(401)
+    const data = await res.json()
+    expect(data.error).toBe('Unauthorized')
+  })
+
+  it('returns 400 when user has no organization', async () => {
     mockGetSession.mockResolvedValue({
-      user: { id: "user-1" },
-    } as any);
-    mockFindMember.mockResolvedValue(null);
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("No organization found");
-  });
-
-  it("returns 400 for invalid plan (not enterprise)", async () => {
-    setupAuth();
-    const res = await POST(makeRequest({ plan: "pro" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("Can only upgrade to enterprise");
-  });
-
-  it("returns 400 when no subscription exists", async () => {
-    setupAuth();
-    mockFindSubscription.mockResolvedValue(null);
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("No active subscription found");
-  });
-
-  it("returns 400 when subscription is not active", async () => {
-    setupAuth();
+      user: { id: 'user-1' },
+    } as any)
+    mockFindMember.mockResolvedValue(null)
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('No organization found')
+  })
+
+  it('returns 400 for invalid plan (not enterprise)', async () => {
+    setupAuth()
+    const res = await POST(makeRequest({ plan: 'pro' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('Can only upgrade to enterprise')
+  })
+
+  it('returns 400 when no subscription exists', async () => {
+    setupAuth()
+    mockFindSubscription.mockResolvedValue(null)
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('No active subscription found')
+  })
+
+  it('returns 400 when subscription is not active', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      status: "canceled",
-    } as any);
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("Subscription is not active");
-  });
-
-  it("returns 500 when enterprise price ID is not configured", async () => {
-    setupAuth();
-    setupActiveSubscription();
+      stripeSubscriptionId: 'sub_123',
+      status: 'canceled',
+    } as any)
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('Subscription is not active')
+  })
+
+  it('returns 500 when enterprise price ID is not configured', async () => {
+    setupAuth()
+    setupActiveSubscription()
     mockGetStripeConfig.mockResolvedValue({
-      secretKey: "sk_test",
-      webhookSecret: "whsec_test",
-      proPriceId: "price_pro",
-      enterprisePriceId: "",
-    });
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(500);
-    const data = await res.json();
-    expect(data.error).toBe("Enterprise price ID not configured");
-  });
-
-  it("returns 400 when stripe subscription has no items", async () => {
-    setupAuth();
-    setupActiveSubscription();
-    setupStripe();
+      secretKey: 'sk_test',
+      webhookSecret: 'whsec_test',
+      proPriceId: 'price_pro',
+      enterprisePriceId: '',
+    })
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(500)
+    const data = await res.json()
+    expect(data.error).toBe('Enterprise price ID not configured')
+  })
+
+  it('returns 400 when stripe subscription has no items', async () => {
+    setupAuth()
+    setupActiveSubscription()
+    setupStripe()
     mockSubscriptionsRetrieve.mockResolvedValue({
-      id: "sub_123",
+      id: 'sub_123',
       items: { data: [] },
-    });
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("No subscription item found");
-  });
-
-  it("successfully upgrades from pro to enterprise with proration", async () => {
-    setupAuth("user-1", "org-1");
-    setupActiveSubscription();
-    setupStripe();
-    setupStripeResponses();
-    mockUpsertPlan.mockResolvedValue({ id: "plan-ent" } as any);
-    mockUpdateSubscription.mockResolvedValue({} as any);
-    mockUpsertSetting.mockResolvedValue({} as any);
-
-    const res = await POST(makeRequest({ plan: "enterprise", prorationDate: 1700000000 }));
-    expect(res.status).toBe(200);
-    const data = await res.json();
-    expect(data.success).toBe(true);
+    })
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('No subscription item found')
+  })
+
+  it('successfully upgrades from pro to enterprise with proration', async () => {
+    setupAuth('user-1', 'org-1')
+    setupActiveSubscription()
+    setupStripe()
+    setupStripeResponses()
+    mockUpsertPlan.mockResolvedValue({ id: 'plan-ent' } as any)
+    mockUpdateSubscription.mockResolvedValue({} as any)
+    mockUpsertSetting.mockResolvedValue({} as any)
+
+    const res = await POST(makeRequest({ plan: 'enterprise', prorationDate: 1700000000 }))
+    expect(res.status).toBe(200)
+    const data = await res.json()
+    expect(data.success).toBe(true)
 
     // Verify Stripe was called with correct proration and proration_date
-    expect(mockSubscriptionsRetrieve).toHaveBeenCalledWith("sub_123");
-    expect(mockSubscriptionsUpdate).toHaveBeenCalledWith("sub_123", {
-      items: [{ id: "si_item_1", price: "price_enterprise" }],
-      proration_behavior: "always_invoice",
+    expect(mockSubscriptionsRetrieve).toHaveBeenCalledWith('sub_123')
+    expect(mockSubscriptionsUpdate).toHaveBeenCalledWith('sub_123', {
+      items: [{ id: 'si_item_1', price: 'price_enterprise' }],
+      proration_behavior: 'always_invoice',
       proration_date: 1700000000,
-      metadata: { plan: "enterprise", organizationId: "org-1" },
-    });
+      metadata: { plan: 'enterprise', organizationId: 'org-1' },
+    })
 
     // Verify DB plan upsert
     expect(mockUpsertPlan).toHaveBeenCalledWith({
-      where: { stripePriceId: "price_enterprise" },
+      where: { stripePriceId: 'price_enterprise' },
       create: {
-        name: "Enterprise",
-        stripePriceId: "price_enterprise",
+        name: 'Enterprise',
+        stripePriceId: 'price_enterprise',
         price: 140,
-        interval: "year",
+        interval: 'year',
         maxMembers: 50,
       },
       update: {},
-    });
+    })
 
     // Verify DB subscription update
     expect(mockUpdateSubscription).toHaveBeenCalledWith({
-      where: { organizationId: "org-1" },
+      where: { organizationId: 'org-1' },
       data: {
-        planId: "plan-ent",
-        status: "active",
+        planId: 'plan-ent',
+        status: 'active',
         currentPeriodStart: new Date(1700000000 * 1000),
         currentPeriodEnd: new Date(1731536000 * 1000),
         cancelAtPeriodEnd: false,
       },
-    });
+    })
 
     // Verify license plan updated
     expect(mockUpsertSetting).toHaveBeenCalledWith({
       where: {
         organizationId_key: {
-          organizationId: "org-1",
-          key: "license.plan",
+          organizationId: 'org-1',
+          key: 'license.plan',
         },
       },
       create: {
-        organizationId: "org-1",
-        key: "license.plan",
-        value: "enterprise",
-        userId: "user-1",
+        organizationId: 'org-1',
+        key: 'license.plan',
+        value: 'enterprise',
+        userId: 'user-1',
       },
-      update: { value: "enterprise" },
-    });
-  });
-
-  it("returns 500 when Stripe API fails", async () => {
-    setupAuth();
-    setupActiveSubscription();
-    setupStripe();
-    mockSubscriptionsRetrieve.mockRejectedValue(new Error("Stripe API error"));
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(500);
-    const data = await res.json();
-    expect(data.error).toBe("Stripe API error");
-  });
-
-  it("returns 500 when Stripe update fails", async () => {
-    setupAuth();
-    setupActiveSubscription();
-    setupStripe();
+      update: { value: 'enterprise' },
+    })
+  })
+
+  it('returns 500 when Stripe API fails', async () => {
+    setupAuth()
+    setupActiveSubscription()
+    setupStripe()
+    mockSubscriptionsRetrieve.mockRejectedValue(new Error('Stripe API error'))
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(500)
+    const data = await res.json()
+    expect(data.error).toBe('Stripe API error')
+  })
+
+  it('returns 500 when Stripe update fails', async () => {
+    setupAuth()
+    setupActiveSubscription()
+    setupStripe()
     mockSubscriptionsRetrieve.mockResolvedValue({
-      id: "sub_123",
-      items: { data: [{ id: "si_item_1" }] },
-    });
-    mockSubscriptionsUpdate.mockRejectedValue(
-      new Error("Card was declined"),
-    );
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(500);
-    const data = await res.json();
-    expect(data.error).toBe("Card was declined");
-  });
-
-  it("handles subscription with past_due status", async () => {
-    setupAuth();
+      id: 'sub_123',
+      items: { data: [{ id: 'si_item_1' }] },
+    })
+    mockSubscriptionsUpdate.mockRejectedValue(new Error('Card was declined'))
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(500)
+    const data = await res.json()
+    expect(data.error).toBe('Card was declined')
+  })
+
+  it('handles subscription with past_due status', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
-      stripeSubscriptionId: "sub_123",
-      status: "past_due",
-    } as any);
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("Subscription is not active");
-  });
-
-  it("handles subscription without stripeSubscriptionId", async () => {
-    setupAuth();
+      stripeSubscriptionId: 'sub_123',
+      status: 'past_due',
+    } as any)
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('Subscription is not active')
+  })
+
+  it('handles subscription without stripeSubscriptionId', async () => {
+    setupAuth()
     mockFindSubscription.mockResolvedValue({
       stripeSubscriptionId: null,
-      status: "active",
-    } as any);
-
-    const res = await POST(makeRequest({ plan: "enterprise" }));
-    expect(res.status).toBe(400);
-    const data = await res.json();
-    expect(data.error).toBe("No active subscription found");
-  });
-});
+      status: 'active',
+    } as any)
+
+    const res = await POST(makeRequest({ plan: 'enterprise' }))
+    expect(res.status).toBe(400)
+    const data = await res.json()
+    expect(data.error).toBe('No active subscription found')
+  })
+})

+ 86 - 83
src/__tests__/auth/reset-password-validation.test.tsx

@@ -1,130 +1,133 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
 
 // Mock next/navigation before importing the component
-const mockGet = vi.fn();
-vi.mock("next/navigation", () => ({
+const mockGet = vi.fn()
+vi.mock('next/navigation', () => ({
   useSearchParams: () => ({ get: mockGet }),
   useRouter: () => ({ push: vi.fn() }),
-  usePathname: () => "/auth/reset-password",
-}));
+  usePathname: () => '/auth/reset-password',
+}))
 
 // Mock next/link
-vi.mock("next/link", () => ({
+vi.mock('next/link', () => ({
   default: ({ href, children }: { href: string; children: React.ReactNode }) => (
     <a href={href}>{children}</a>
   ),
-}));
+}))
 
 // Mock lucide-react icons used in the component
-vi.mock("lucide-react", () => ({
+vi.mock('lucide-react', () => ({
   Gauge: () => <svg data-testid="icon-gauge" />,
   Loader2: () => <svg data-testid="icon-loader" />,
   XCircle: () => <svg data-testid="icon-xcircle" />,
-}));
+}))
 
 // Mock UI components (pass-through)
-vi.mock("@/components/ui/button", () => ({
-  Button: ({ children, ...props }: React.HTMLAttributes<HTMLButtonElement> & { variant?: string }) => (
+vi.mock('@/components/ui/button', () => ({
+  Button: ({
+    children,
+    ...props
+  }: React.HTMLAttributes<HTMLButtonElement> & { variant?: string }) => (
     <button {...props}>{children}</button>
   ),
-}));
-vi.mock("@/components/ui/input", () => ({
+}))
+vi.mock('@/components/ui/input', () => ({
   Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
-}));
-vi.mock("@/components/ui/label", () => ({
+}))
+vi.mock('@/components/ui/label', () => ({
   Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
     <label {...props}>{children}</label>
   ),
-}));
+}))
 
 // Mock authClient
-const mockResetPassword = vi.fn();
-vi.mock("@/lib/auth-client", () => ({
+const mockResetPassword = vi.fn()
+vi.mock('@/lib/auth-client', () => ({
   authClient: {
     resetPassword: (...args: unknown[]) => mockResetPassword(...args),
   },
-}));
+}))
 
 // Import the inner component — we test ResetPasswordInner via ResetPasswordPage's Suspense wrapper
 // Since ResetPasswordInner is not exported, we render the default export which wraps it in Suspense.
-import ResetPasswordPage from "@/app/(public)/auth/reset-password/page";
+import ResetPasswordPage from '@/app/(public)/auth/reset-password/page'
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
-
-describe("ResetPasswordPage — ResetPasswordInner", () => {
-  it("shows invalid token message when no token in URL", () => {
-    mockGet.mockReturnValue(null);
-    render(<ResetPasswordPage />);
-    expect(screen.getByText(/invalid or missing reset token/i)).toBeInTheDocument();
-    expect(screen.queryByRole("form")).not.toBeInTheDocument();
-  });
-
-  it("shows the form when a token is present", () => {
-    mockGet.mockReturnValue("valid-token-abc");
-    render(<ResetPasswordPage />);
-    expect(screen.getByLabelText(/new password/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
-  });
-
-  it("shows passwords do not match error without calling resetPassword", async () => {
-    mockGet.mockReturnValue("valid-token-abc");
-    render(<ResetPasswordPage />);
-
-    await userEvent.type(screen.getByLabelText(/new password/i), "password1");
-    await userEvent.type(screen.getByLabelText(/confirm password/i), "password2");
-    fireEvent.submit(screen.getByRole("button", { name: /reset password/i }));
+  vi.resetAllMocks()
+})
+
+describe('ResetPasswordPage — ResetPasswordInner', () => {
+  it('shows invalid token message when no token in URL', () => {
+    mockGet.mockReturnValue(null)
+    render(<ResetPasswordPage />)
+    expect(screen.getByText(/invalid or missing reset token/i)).toBeInTheDocument()
+    expect(screen.queryByRole('form')).not.toBeInTheDocument()
+  })
+
+  it('shows the form when a token is present', () => {
+    mockGet.mockReturnValue('valid-token-abc')
+    render(<ResetPasswordPage />)
+    expect(screen.getByLabelText(/new password/i)).toBeInTheDocument()
+    expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument()
+  })
+
+  it('shows passwords do not match error without calling resetPassword', async () => {
+    mockGet.mockReturnValue('valid-token-abc')
+    render(<ResetPasswordPage />)
+
+    await userEvent.type(screen.getByLabelText(/new password/i), 'password1')
+    await userEvent.type(screen.getByLabelText(/confirm password/i), 'password2')
+    fireEvent.submit(screen.getByRole('button', { name: /reset password/i }))
 
     await waitFor(() => {
-      expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
-    });
-    expect(mockResetPassword).not.toHaveBeenCalled();
-  });
+      expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument()
+    })
+    expect(mockResetPassword).not.toHaveBeenCalled()
+  })
 
-  it("shows password length error without calling resetPassword", async () => {
-    mockGet.mockReturnValue("valid-token-abc");
-    render(<ResetPasswordPage />);
+  it('shows password length error without calling resetPassword', async () => {
+    mockGet.mockReturnValue('valid-token-abc')
+    render(<ResetPasswordPage />)
 
-    await userEvent.type(screen.getByLabelText(/new password/i), "short");
-    await userEvent.type(screen.getByLabelText(/confirm password/i), "short");
-    fireEvent.submit(screen.getByRole("button", { name: /reset password/i }));
+    await userEvent.type(screen.getByLabelText(/new password/i), 'short')
+    await userEvent.type(screen.getByLabelText(/confirm password/i), 'short')
+    fireEvent.submit(screen.getByRole('button', { name: /reset password/i }))
 
     await waitFor(() => {
-      expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
-    });
-    expect(mockResetPassword).not.toHaveBeenCalled();
-  });
+      expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument()
+    })
+    expect(mockResetPassword).not.toHaveBeenCalled()
+  })
 
-  it("shows error message when authClient.resetPassword returns an error", async () => {
-    mockGet.mockReturnValue("valid-token-abc");
-    mockResetPassword.mockResolvedValue({ error: { message: "Token has expired" } });
-    render(<ResetPasswordPage />);
+  it('shows error message when authClient.resetPassword returns an error', async () => {
+    mockGet.mockReturnValue('valid-token-abc')
+    mockResetPassword.mockResolvedValue({ error: { message: 'Token has expired' } })
+    render(<ResetPasswordPage />)
 
-    await userEvent.type(screen.getByLabelText(/new password/i), "securepass1");
-    await userEvent.type(screen.getByLabelText(/confirm password/i), "securepass1");
-    fireEvent.submit(screen.getByRole("button", { name: /reset password/i }));
+    await userEvent.type(screen.getByLabelText(/new password/i), 'securepass1')
+    await userEvent.type(screen.getByLabelText(/confirm password/i), 'securepass1')
+    fireEvent.submit(screen.getByRole('button', { name: /reset password/i }))
 
     await waitFor(() => {
-      expect(screen.getByText(/token has expired/i)).toBeInTheDocument();
-    });
-  });
+      expect(screen.getByText(/token has expired/i)).toBeInTheDocument()
+    })
+  })
 
-  it("shows success state with sign-in link when reset succeeds", async () => {
-    mockGet.mockReturnValue("valid-token-abc");
-    mockResetPassword.mockResolvedValue({ error: null });
-    render(<ResetPasswordPage />);
+  it('shows success state with sign-in link when reset succeeds', async () => {
+    mockGet.mockReturnValue('valid-token-abc')
+    mockResetPassword.mockResolvedValue({ error: null })
+    render(<ResetPasswordPage />)
 
-    await userEvent.type(screen.getByLabelText(/new password/i), "securepass1");
-    await userEvent.type(screen.getByLabelText(/confirm password/i), "securepass1");
-    fireEvent.submit(screen.getByRole("button", { name: /reset password/i }));
+    await userEvent.type(screen.getByLabelText(/new password/i), 'securepass1')
+    await userEvent.type(screen.getByLabelText(/confirm password/i), 'securepass1')
+    fireEvent.submit(screen.getByRole('button', { name: /reset password/i }))
 
     await waitFor(() => {
-      expect(screen.getByText(/password has been reset successfully/i)).toBeInTheDocument();
-    });
+      expect(screen.getByText(/password has been reset successfully/i)).toBeInTheDocument()
+    })
     // The success state renders a "Sign In" button (distinct from the footer "Back to Sign In" link)
-    expect(screen.getByRole("button", { name: /^sign in$/i })).toBeInTheDocument();
-  });
-});
+    expect(screen.getByRole('button', { name: /^sign in$/i })).toBeInTheDocument()
+  })
+})

+ 1 - 9
src/__tests__/components/banner-slot.test.tsx

@@ -11,15 +11,7 @@ import { describe, it, expect } from 'vitest'
 import { render, screen, act } from '@testing-library/react'
 import { BannerSlotProvider, useBannerSlot, BANNER_PRIORITY } from '@/components/banner-slot'
 
-function Strip({
-  id,
-  priority,
-  wants = true,
-}: {
-  id: string
-  priority: number
-  wants?: boolean
-}) {
+function Strip({ id, priority, wants = true }: { id: string; priority: number; wants?: boolean }) {
   const mine = useBannerSlot(id, priority, wants)
   return mine ? <div data-testid={`strip-${id}`}>{id}</div> : null
 }

+ 79 - 45
src/__tests__/features/public/invoice-view.test.tsx

@@ -16,6 +16,7 @@ vi.mock('lucide-react', () => ({
 }))
 
 import { InvoiceView } from '@/app/(public)/share/invoice/[orgId]/[token]/invoice-view'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 
 const WORKSHOP = {
   name: 'Speed Shop',
@@ -85,6 +86,39 @@ const DEFAULT_PROPS = {
   enabledProviders: [],
 }
 
+/**
+ * The sheet is built server-side in production; the tests build it the same
+ * way from the record, so what they assert on is what a customer sees.
+ */
+function renderView(props: any) {
+  const record = props.record
+  const paid = (record.payments ?? []).reduce(
+    (sum: number, p: { amount: number }) => sum + p.amount,
+    0
+  )
+  const total = record.totalAmount > 0 ? record.totalAmount : record.cost
+  const paymentSummary =
+    (record.payments ?? []).length > 0 || record.manuallyPaid
+      ? {
+          totalPaid: record.manuallyPaid ? total : paid,
+          payments: (record.payments ?? []).map(
+            (p: { amount: number; date: Date; method: string }) => ({
+              amount: p.amount,
+              date: String(p.date),
+              method: p.method,
+            })
+          ),
+        }
+      : undefined
+  const spec = buildInvoicePrintSpec({
+    data: record,
+    workshop: WORKSHOP,
+    invoiceSettings: { currencyCode: props.currencyCode },
+    paymentSummary,
+  })
+  return render(<InvoiceView spec={spec} {...props} />)
+}
+
 let mockFetch: ReturnType<typeof vi.fn>
 
 beforeAll(() => {
@@ -112,31 +146,31 @@ beforeEach(() => {
 describe('InvoiceView', () => {
   describe('rendering', () => {
     it('renders invoice number and title', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
-      expect(screen.getByText('INV-0001')).toBeInTheDocument()
-      expect(screen.getByText('Full Service')).toBeInTheDocument()
+      renderView(DEFAULT_PROPS)
+      expect(screen.getAllByText('INV-0001').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Full Service').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders Download PDF button', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       expect(screen.getByRole('button', { name: /download pdf/i })).toBeInTheDocument()
     })
 
     it('renders customer info in Bill To section', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
-      expect(screen.getByText('Bill To')).toBeInTheDocument()
-      expect(screen.getByText('Bob Builder')).toBeInTheDocument()
-      expect(screen.getByText('bob@example.com')).toBeInTheDocument()
+      renderView(DEFAULT_PROPS)
+      expect(screen.getAllByText('Bill To').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Bob Builder').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('bob@example.com').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders vehicle info', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
-      expect(screen.getByText('2019 Honda Civic')).toBeInTheDocument()
+      renderView(DEFAULT_PROPS)
+      expect(screen.getAllByText('2019 Honda Civic').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders technician name', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
-      expect(screen.getByText('Tech: Alice')).toBeInTheDocument()
+      renderView(DEFAULT_PROPS)
+      expect(screen.getAllByText('Tech: Alice').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders parts table when parts are present', () => {
@@ -149,9 +183,9 @@ describe('InvoiceView', () => {
           ],
         },
       }
-      render(<InvoiceView {...props} />)
-      expect(screen.getByText('Parts')).toBeInTheDocument()
-      expect(screen.getByText('Brake Pads')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('Parts').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Brake Pads').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders labor table when labor items are present', () => {
@@ -162,19 +196,19 @@ describe('InvoiceView', () => {
           laborItems: [{ description: 'Brake Replacement', hours: 1.5, rate: 90, total: 135 }],
         },
       }
-      render(<InvoiceView {...props} />)
-      expect(screen.getByText('Labor & Services')).toBeInTheDocument()
-      expect(screen.getByText('Brake Replacement')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('Labor').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Brake Replacement').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders terms of sale link when termsOfSaleUrl is provided', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} termsOfSaleUrl="https://example.com/terms" />)
+      renderView({ ...DEFAULT_PROPS, termsOfSaleUrl: 'https://example.com/terms' })
       const link = screen.getByRole('link', { name: /terms of sale/i })
       expect(link).toHaveAttribute('href', 'https://example.com/terms')
     })
 
     it('does not render terms of sale link when not provided', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       expect(screen.queryByRole('link', { name: /terms of sale/i })).not.toBeInTheDocument()
     })
   })
@@ -185,7 +219,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         enabledProviders: ['stripe'],
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       expect(screen.getByText('Balance Due')).toBeInTheDocument()
     })
 
@@ -195,7 +229,7 @@ describe('InvoiceView', () => {
         enabledProviders: ['stripe'],
         record: { ...BASE_RECORD, manuallyPaid: true },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       // Payment provider buttons should not render when balance is zero
       expect(screen.queryByRole('button', { name: /pay.*card/i })).not.toBeInTheDocument()
     })
@@ -209,39 +243,39 @@ describe('InvoiceView', () => {
           payments: [{ amount: 200, date: new Date(), method: 'card' }],
         },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       expect(screen.queryByRole('button', { name: /pay.*card/i })).not.toBeInTheDocument()
     })
 
     it('shows Stripe pay button when stripe provider is enabled', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       expect(screen.getByRole('button', { name: /pay.*card/i })).toBeInTheDocument()
     })
 
     it('shows Vipps pay button when vipps provider is enabled', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['vipps']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['vipps'] })
       expect(screen.getByRole('button', { name: /pay.*vipps/i })).toBeInTheDocument()
     })
 
     it('shows PayPal pay button when paypal provider is enabled', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['paypal']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['paypal'] })
       expect(screen.getByRole('button', { name: /pay.*paypal/i })).toBeInTheDocument()
     })
 
     it('shows Full amount and Partial payment toggle buttons', () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       expect(screen.getByRole('button', { name: /full amount/i })).toBeInTheDocument()
       expect(screen.getByRole('button', { name: /partial payment/i })).toBeInTheDocument()
     })
 
     it('shows amount input when Partial payment is selected', async () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       await userEvent.click(screen.getByRole('button', { name: /partial payment/i }))
       expect(screen.getByLabelText(/enter amount/i)).toBeInTheDocument()
     })
 
     it('hides amount input when Full amount is re-selected', async () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       await userEvent.click(screen.getByRole('button', { name: /partial payment/i }))
       expect(screen.getByLabelText(/enter amount/i)).toBeInTheDocument()
       await userEvent.click(screen.getByRole('button', { name: /full amount/i }))
@@ -249,7 +283,7 @@ describe('InvoiceView', () => {
     })
 
     it('shows validation error when amount is invalid', async () => {
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       await userEvent.click(screen.getByRole('button', { name: /partial payment/i }))
       const input = screen.getByLabelText(/enter amount/i)
       // Use fireEvent for number inputs to avoid userEvent quirks with controlled inputs
@@ -271,7 +305,7 @@ describe('InvoiceView', () => {
         href: 'http://localhost/share/invoice/org-1/tok-xyz',
         assign: vi.fn(),
       })
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       await userEvent.click(screen.getByRole('button', { name: /pay.*card/i }))
 
       await waitFor(() => {
@@ -293,7 +327,7 @@ describe('InvoiceView', () => {
         ok: false,
         json: () => Promise.resolve({ error: 'Provider unavailable' }),
       })
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       await userEvent.click(screen.getByRole('button', { name: /pay.*card/i }))
 
       await waitFor(() => {
@@ -312,7 +346,7 @@ describe('InvoiceView', () => {
         ok: true,
         json: () => Promise.resolve({ verified: true, amount: 200 }),
       })
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['stripe']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
 
       await waitFor(() => {
         expect(screen.getByText(/payment received/i)).toBeInTheDocument()
@@ -337,7 +371,7 @@ describe('InvoiceView', () => {
         ok: true,
         json: () => Promise.resolve({ verified: true, amount: 200 }),
       })
-      render(<InvoiceView {...DEFAULT_PROPS} enabledProviders={['vipps']} />)
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['vipps'] })
 
       await waitFor(() => {
         const call = mockFetch.mock.calls.find(
@@ -358,8 +392,8 @@ describe('InvoiceView', () => {
           payments: [{ amount: 200, date: new Date(), method: 'card' }],
         },
       }
-      render(<InvoiceView {...props} />)
-      expect(screen.getByText('PAID IN FULL')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('PAID IN FULL').length).toBeGreaterThanOrEqual(1)
     })
   })
 
@@ -379,7 +413,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         record: { ...BASE_RECORD, attachments: [IMAGE_ATTACHMENT] },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       expect(screen.getByText(/service images/i)).toBeInTheDocument()
       expect(screen.getByAltText('Before photo')).toBeInTheDocument()
     })
@@ -389,7 +423,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         record: { ...BASE_RECORD, attachments: [IMAGE_ATTACHMENT] },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       const thumbnail = screen.getByAltText('Before photo')
       await userEvent.click(thumbnail.closest('button')!)
       // Carousel is shown (image is in a fullscreen modal)
@@ -401,7 +435,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         record: { ...BASE_RECORD, attachments: [IMAGE_ATTACHMENT] },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       await userEvent.click(screen.getByAltText('Before photo').closest('button')!)
       const closeBtn = screen.getByTestId('icon-x').closest('button')!
       await userEvent.click(closeBtn)
@@ -415,7 +449,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         record: { ...BASE_RECORD, attachments: [IMAGE_ATTACHMENT, duplicate] },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       // Should show "1" in the title, not "2"
       expect(screen.getByText(/service images \(1\)/i)).toBeInTheDocument()
     })
@@ -436,7 +470,7 @@ describe('InvoiceView', () => {
         ...DEFAULT_PROPS,
         record: { ...BASE_RECORD, attachments: [pdfAttachment] },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       expect(screen.getByText(/diagnostic reports/i)).toBeInTheDocument()
       const link = screen.getByRole('link', { name: /diagnostic.pdf/i })
       expect(link).toHaveAttribute('download', 'diagnostic.pdf')
@@ -447,7 +481,7 @@ describe('InvoiceView', () => {
     it('fetches the PDF from the correct API URL', async () => {
       const mockBlob = new Blob(['pdf'], { type: 'application/pdf' })
       mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(mockBlob) })
-      render(<InvoiceView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /download pdf/i }))
 
       await waitFor(() => {
@@ -477,7 +511,7 @@ describe('InvoiceView', () => {
           cost: 125,
         },
       }
-      const { unmount } = render(<InvoiceView {...exclusiveProps} />)
+      const { unmount } = renderView(exclusiveProps)
       // Exclusive shows the net unit price (100) and the tax amount (25)
       expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1)
       expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1)
@@ -501,7 +535,7 @@ describe('InvoiceView', () => {
           cost: 125,
         },
       }
-      render(<InvoiceView {...inclusiveProps} />)
+      renderView(inclusiveProps)
       // The display back-calculates the line item to net 100, and shows the same tax 25 + total 125
       expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1)
       expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1)
@@ -522,7 +556,7 @@ describe('InvoiceView', () => {
           cost: 100,
         },
       }
-      render(<InvoiceView {...props} />)
+      renderView(props)
       expect(screen.queryByText(/Tax \(\d/)).toBeNull()
     })
   })

+ 62 - 50
src/__tests__/features/public/quote-view.test.tsx

@@ -15,6 +15,7 @@ vi.mock('lucide-react', () => ({
 }))
 
 import { QuoteView } from '@/app/(public)/share/quote/[orgId]/[token]/quote-view'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
 
 const WORKSHOP = {
   name: 'Speed Shop',
@@ -53,6 +54,19 @@ const DEFAULT_PROPS = {
   token: 'tok-abc',
 }
 
+/**
+ * The sheet is built server-side in production; the tests build it the same
+ * way from the quote, so what they assert on is what a customer sees.
+ */
+function renderView(props: any) {
+  const spec = buildQuotePrintSpec({
+    data: props.quote,
+    workshop: WORKSHOP,
+    currencyCode: props.currencyCode,
+  })
+  return render(<QuoteView spec={spec} {...props} />)
+}
+
 let mockFetch: ReturnType<typeof vi.fn>
 
 beforeAll(() => {
@@ -73,13 +87,13 @@ beforeEach(() => {
 describe('QuoteView', () => {
   describe('rendering', () => {
     it('renders the quote number and title', () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
-      expect(screen.getByText('QT-0001')).toBeInTheDocument()
-      expect(screen.getByText('Oil Change & Filter')).toBeInTheDocument()
+      renderView(DEFAULT_PROPS)
+      expect(screen.getAllByText('QT-0001').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Oil Change & Filter').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders the Download PDF button', () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       expect(screen.getByRole('button', { name: /download pdf/i })).toBeInTheDocument()
     })
 
@@ -97,10 +111,10 @@ describe('QuoteView', () => {
           },
         },
       }
-      render(<QuoteView {...props} />)
-      expect(screen.getByText('Jane Doe')).toBeInTheDocument()
-      expect(screen.getByText('jane@example.com')).toBeInTheDocument()
-      expect(screen.getByText('Acme Corp')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('Jane Doe').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('jane@example.com').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Acme Corp').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders vehicle info when provided', () => {
@@ -117,10 +131,10 @@ describe('QuoteView', () => {
           },
         },
       }
-      render(<QuoteView {...props} />)
-      expect(screen.getByText('2020 Toyota Camry')).toBeInTheDocument()
-      expect(screen.getByText('VIN: VIN123')).toBeInTheDocument()
-      expect(screen.getByText('Plate: ABC-123')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('2020 Toyota Camry').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('VIN: VIN123').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Plate: ABC-123').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders parts table when parts are present', () => {
@@ -133,15 +147,15 @@ describe('QuoteView', () => {
           ],
         },
       }
-      render(<QuoteView {...props} />)
+      renderView(props)
       // "Parts" appears in both the table header and the totals row
       expect(screen.getAllByText('Parts').length).toBeGreaterThanOrEqual(1)
-      expect(screen.getByText('Oil Filter')).toBeInTheDocument()
-      expect(screen.getByText('P-001')).toBeInTheDocument()
+      expect(screen.getAllByText('Oil Filter').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('P-001').length).toBeGreaterThanOrEqual(1)
     })
 
     it('does not render parts table when there are no parts', () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       expect(screen.queryByText('Parts')).not.toBeInTheDocument()
     })
 
@@ -153,10 +167,10 @@ describe('QuoteView', () => {
           laborItems: [{ description: 'Labor - Oil Change', hours: 0.5, rate: 80, total: 40 }],
         },
       }
-      render(<QuoteView {...props} />)
+      renderView(props)
       // "Labor & Services" appears in both the table header and the totals row
-      expect(screen.getAllByText('Labor & Services').length).toBeGreaterThanOrEqual(1)
-      expect(screen.getByText('Labor - Oil Change')).toBeInTheDocument()
+      expect(screen.getAllByText('Labor').length).toBeGreaterThanOrEqual(1)
+      expect(screen.getAllByText('Labor - Oil Change').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders tax row when taxRate > 0', () => {
@@ -164,8 +178,8 @@ describe('QuoteView', () => {
         ...DEFAULT_PROPS,
         quote: { ...BASE_QUOTE, taxRate: 10, taxAmount: 10, totalAmount: 110 },
       }
-      render(<QuoteView {...props} />)
-      expect(screen.getByText('Tax (10%)')).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText('Tax (10%)').length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders discount row when discountAmount > 0', () => {
@@ -181,60 +195,58 @@ describe('QuoteView', () => {
           totalAmount: 100,
         },
       }
-      render(<QuoteView {...props} />)
-      expect(screen.getByText(/discount/i)).toBeInTheDocument()
+      renderView(props)
+      expect(screen.getAllByText(/discount/i).length).toBeGreaterThanOrEqual(1)
     })
 
     it('renders workshop name in header', () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       // Name appears in both the header h2 and the footer — verify at least one is a heading
-      const instances = screen.getAllByText('Speed Shop')
-      expect(instances.length).toBeGreaterThanOrEqual(1)
-      expect(instances.some((el) => el.tagName === 'H2')).toBe(true)
+      expect(screen.getAllByText('Speed Shop').length).toBeGreaterThanOrEqual(1)
     })
   })
 
   describe('status badges and action buttons', () => {
     it('shows Accept Quote and Request Changes buttons when status=sent', () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       expect(screen.getByRole('button', { name: /accept quote/i })).toBeInTheDocument()
       expect(screen.getByRole('button', { name: /request changes/i })).toBeInTheDocument()
     })
 
     it('shows Accept Quote and Request Changes buttons when status=draft', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'draft' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       expect(screen.getByRole('button', { name: /accept quote/i })).toBeInTheDocument()
     })
 
     it('hides action buttons when status=accepted', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'accepted' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       expect(screen.queryByRole('button', { name: /accept quote/i })).not.toBeInTheDocument()
       expect(screen.queryByRole('button', { name: /request changes/i })).not.toBeInTheDocument()
     })
 
     it('hides action buttons when status=rejected', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'rejected' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       expect(screen.queryByRole('button', { name: /accept quote/i })).not.toBeInTheDocument()
     })
 
     it('hides action buttons when status=expired', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'expired' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       expect(screen.queryByRole('button', { name: /accept quote/i })).not.toBeInTheDocument()
     })
 
     it('shows Quote Accepted confirmation when status=accepted', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'accepted' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       expect(screen.getByText('Quote Accepted')).toBeInTheDocument()
     })
 
     it('shows Changes Requested confirmation when status=changes_requested', () => {
       const props = { ...DEFAULT_PROPS, quote: { ...BASE_QUOTE, status: 'changes_requested' } }
-      render(<QuoteView {...props} />)
+      renderView(props)
       // Unique confirmation message (the badge also says "Changes Requested" but this text is unique)
       expect(screen.getByText(/your change request has been submitted/i)).toBeInTheDocument()
     })
@@ -246,7 +258,7 @@ describe('QuoteView', () => {
         ok: true,
         json: () => Promise.resolve({ success: true }),
       })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /accept quote/i }))
 
       await waitFor(() => {
@@ -269,7 +281,7 @@ describe('QuoteView', () => {
         ok: true,
         json: () => Promise.resolve({ success: true }),
       })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /accept quote/i }))
 
       await waitFor(() => {
@@ -282,7 +294,7 @@ describe('QuoteView', () => {
         ok: true,
         json: () => Promise.resolve({ success: true }),
       })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /accept quote/i }))
 
       await waitFor(() => {
@@ -293,19 +305,19 @@ describe('QuoteView', () => {
 
   describe('Request Changes interaction', () => {
     it('shows the changes form when Request Changes is clicked', async () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       expect(screen.getByPlaceholderText(/describe the changes/i)).toBeInTheDocument()
     })
 
     it('Submit Request button is disabled when message is empty', async () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       expect(screen.getByRole('button', { name: /submit request/i })).toBeDisabled()
     })
 
     it('Submit Request button is enabled after typing a message', async () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       await userEvent.type(
         screen.getByPlaceholderText(/describe the changes/i),
@@ -319,7 +331,7 @@ describe('QuoteView', () => {
         ok: true,
         json: () => Promise.resolve({ success: true }),
       })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       await userEvent.type(screen.getByPlaceholderText(/describe the changes/i), 'Need a discount')
       await userEvent.click(screen.getByRole('button', { name: /submit request/i }))
@@ -340,7 +352,7 @@ describe('QuoteView', () => {
         ok: true,
         json: () => Promise.resolve({ success: true }),
       })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       await userEvent.type(screen.getByPlaceholderText(/describe the changes/i), 'Fix it')
       await userEvent.click(screen.getByRole('button', { name: /submit request/i }))
@@ -352,7 +364,7 @@ describe('QuoteView', () => {
     })
 
     it('Cancel button hides the changes form', async () => {
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /request changes/i }))
       expect(screen.getByPlaceholderText(/describe the changes/i)).toBeInTheDocument()
       await userEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
@@ -364,7 +376,7 @@ describe('QuoteView', () => {
     it('fetches the PDF from the correct API URL', async () => {
       const mockBlob = new Blob(['pdf'], { type: 'application/pdf' })
       mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(mockBlob) })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /download pdf/i }))
 
       await waitFor(() => {
@@ -375,7 +387,7 @@ describe('QuoteView', () => {
     it('creates an object URL for the downloaded blob', async () => {
       const mockBlob = new Blob(['pdf'], { type: 'application/pdf' })
       mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(mockBlob) })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /download pdf/i }))
 
       await waitFor(() => {
@@ -386,7 +398,7 @@ describe('QuoteView', () => {
     it('revokes the object URL after download', async () => {
       const mockBlob = new Blob(['pdf'], { type: 'application/pdf' })
       mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(mockBlob) })
-      render(<QuoteView {...DEFAULT_PROPS} />)
+      renderView(DEFAULT_PROPS)
       await userEvent.click(screen.getByRole('button', { name: /download pdf/i }))
 
       await waitFor(() => {
@@ -415,10 +427,10 @@ describe('QuoteView', () => {
           totalAmount: 125,
         },
       }
-      render(<QuoteView {...props} />)
+      renderView(props)
 
       // The tax line is shown as the actual tax amount (not as "incl.")
-      expect(screen.getByText('Tax (25%)')).toBeInTheDocument()
+      expect(screen.getAllByText('Tax (25%)').length).toBeGreaterThanOrEqual(1)
       // Per-line unit price is back-calculated to net (100, not 125)
       // Currency formatting may vary, so we look for any element containing the net value.
       const netCells = screen.getAllByText(/100\.00/)
@@ -441,7 +453,7 @@ describe('QuoteView', () => {
           totalAmount: 125,
         },
       }
-      const { unmount } = render(<QuoteView {...exclusiveProps} />)
+      const { unmount } = renderView(exclusiveProps)
       // Tax line shows 25.00
       expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1)
       // Total shows 125.00
@@ -463,7 +475,7 @@ describe('QuoteView', () => {
           totalAmount: 125,
         },
       }
-      render(<QuoteView {...inclusiveProps} />)
+      renderView(inclusiveProps)
       // Same tax line 25.00 and same total 125.00 — proves both modes produce the same display
       expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1)
       expect(screen.getAllByText(/125\.00/).length).toBeGreaterThanOrEqual(1)

+ 141 - 0
src/__tests__/features/settings/designer-controls.test.ts

@@ -0,0 +1,141 @@
+/**
+ * The designer's structure, guarded.
+ *
+ * The bug this file exists for happened repeatedly: a property is added, saved
+ * and read back correctly, and the thing that draws it never looks at it. The
+ * document model is the answer — one description, walked by renderers that know
+ * nothing about invoices — and these are the properties that keep it that way.
+ */
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+const SCHEMA = readFileSync('src/features/settings/Schema/invoiceLayoutSchema.ts', 'utf8')
+const SPEC = readFileSync('src/features/invoice-designer/Spec/buildSpec.ts', 'utf8')
+const RENDER = readFileSync('src/features/invoice-designer/Render/renderHtml.tsx', 'utf8')
+const CANVAS = readFileSync('src/features/invoice-designer/Render/SpecCanvas.tsx', 'utf8')
+const INSPECTOR = readFileSync(
+  'src/features/invoice-designer/Components/DesignerInspector.tsx',
+  'utf8'
+)
+const SAMPLE = readFileSync('src/features/invoice-designer/Components/sample.ts', 'utf8')
+
+const keysOf = (from: string, to: string) =>
+  [...SCHEMA.slice(SCHEMA.indexOf(from), SCHEMA.indexOf(to)).matchAll(/^ {2}(\w+): z\./gm)].map(
+    (m) => m[1]
+  )
+
+describe('the renderer knows nothing about invoices', () => {
+  // This is the whole point of the model. A renderer that mentions a section by
+  // name has started to be a second implementation of the document, which is
+  // what drifted from the PDF and broke every time either was touched.
+  const SECTION_NAMES = [
+    'customer',
+    'vehicle',
+    'service',
+    'items_table',
+    'document_title',
+    'totals',
+    'findings',
+    'warranty',
+    'bank_account',
+  ]
+
+  it.each(SECTION_NAMES)('renderHtml does not mention %s', (name) => {
+    expect(RENDER).not.toContain(name)
+  })
+
+  it('draws only the shapes the model defines', () => {
+    for (const kind of ['stack', 'row', 'text', 'image', 'table', 'spacer']) {
+      expect(RENDER).toContain(`case '${kind}'`)
+    }
+  })
+})
+
+describe('the generator covers what a layout can express', () => {
+  it('has a value for every field a section can show', () => {
+    const ids = new Set<string>()
+    for (const list of [
+      'BUILTIN_CUSTOMER_FIELDS',
+      'BUILTIN_VEHICLE_FIELDS',
+      'BUILTIN_SERVICE_FIELDS',
+      'BUILTIN_HEADER_FIELDS',
+      'BUILTIN_FOOTER_FIELDS',
+      'BUILTIN_BANK_ACCOUNT_FIELDS',
+    ]) {
+      const start = SCHEMA.indexOf(`${list} = [`)
+      const block = SCHEMA.slice(start, SCHEMA.indexOf('] as const', start))
+      for (const m of block.matchAll(/id: '([a-z_]+)'/g)) ids.add(m[1])
+    }
+    // `logo` comes from the workshop's upload rather than a sample string.
+    const missing = [...ids].filter((id) => id !== 'logo' && !SAMPLE.includes(`${id}:`))
+    expect(missing).toEqual([])
+  })
+
+  it('asks each section which fields it shows', () => {
+    expect(SPEC).toContain('sectionFields')
+    expect(SPEC).toContain('getBuiltinFieldsForSection')
+  })
+
+  it('reads every appearance key a section carries', () => {
+    const missing = keysOf(
+      'export const invoiceSectionStyleSchema',
+      'export const invoiceSectionSchema'
+    ).filter((key) => !SPEC.includes(key))
+    expect(missing).toEqual([])
+  })
+
+  it('reads every appearance key the sheet carries', () => {
+    const missing = keysOf(
+      'export const invoiceDocumentStyleSchema',
+      'export const invoiceLayoutConfigSchema'
+    ).filter((key) => !SPEC.includes(key) && !INSPECTOR.includes(key))
+    expect(missing).toEqual([])
+  })
+})
+
+describe('the inspector can reach every property', () => {
+  it('offers everything a section carries', () => {
+    const missing = keysOf('export const invoiceSectionSchema', 'export const anchorSchema')
+      .filter((key) => !['id', 'order'].includes(key))
+      .filter((key) => !INSPECTOR.includes(key))
+    expect(missing).toEqual([])
+  })
+})
+
+describe('measuring cannot loop', () => {
+  it('keeps measured geometry out of state', () => {
+    // Measuring produces a fresh Map every pass. In state that scheduled
+    // another render, which measured again: a loop with no way out, which
+    // React stopped for us with "Maximum update depth exceeded".
+    expect(CANVAS).toContain('rectsRef')
+    expect(CANVAS).not.toContain('setRects')
+  })
+
+  it('only stores a measurement that changed', () => {
+    // The one measurement that does drive rendering — how tall each block is,
+    // which decides where the page breaks — has to bail when nothing moved.
+    const call = CANVAS.indexOf('setHeights((prev)')
+    expect(call).toBeGreaterThan(-1)
+    expect(CANVAS.slice(call, call + 220)).toContain('prev.every')
+  })
+})
+
+describe('the sheet is a sheet', () => {
+  it('is A4 and stays A4', () => {
+    // Base size and margins change how much fits on the page, never its size.
+    expect(SPEC).toContain('width: 595')
+    expect(SPEC).toContain('height: 842')
+    expect(CANVAS).toContain('spec.page.height')
+  })
+
+  it('holds a printed footer against the foot of the sheet', () => {
+    expect(SPEC).toContain("mode: 'pinned'")
+    expect(CANVAS).toContain('pinned')
+  })
+
+  it('positions anything that has been dragged, and snaps it to what is there', () => {
+    expect(CANVAS).toContain('guidesFor')
+    expect(CANVAS).toContain('snapTo')
+    expect(SPEC).toContain("mode: 'anchored'")
+  })
+})

+ 110 - 0
src/__tests__/features/settings/designer-flow.test.ts

@@ -0,0 +1,110 @@
+/**
+ * Two sections share a row when both are given a column, and the rule that
+ * decides it lives in one place so the sheet and the paper group identically.
+ */
+import { describe, expect, it } from 'vitest'
+import { groupFlowBlocks, type Block } from '@/features/invoice-designer/Spec/documentSpec'
+import { materializeHiddenSection } from '@/features/settings/Schema/invoiceLayoutSchema'
+
+const block = (id: string, order: number, column?: 'left' | 'right'): Block => ({
+  id,
+  label: id,
+  placement: { mode: 'flow', order, column },
+  content: { kind: 'text', text: id },
+})
+
+describe('a drop around the borrowed title', () => {
+  /**
+   * When Document Title is hidden, the generator draws a title strip glued
+   * under the header anyway, so the number always prints. A drop that
+   * references that strip must first make the section real at its drawn
+   * position, or the drop lands wherever the hidden section's stored order
+   * happens to point.
+   */
+  const section = (id: string, order: number, visible = true) => ({ id, visible, order })
+
+  it('turns the hidden title visible right after the header', () => {
+    const layout = {
+      sections: [
+        section('header', 0),
+        section('slogan', 1),
+        section('document_title', 4, false),
+        section('parts_table', 5),
+      ],
+    }
+    const next = materializeHiddenSection(layout, 'document_title')
+    const ids = [...next.sections].sort((a, b) => a.order - b.order).map((s) => s.id)
+    expect(ids).toEqual(['header', 'document_title', 'slogan', 'parts_table'])
+    expect(next.sections.find((s) => s.id === 'document_title')?.visible).toBe(true)
+  })
+
+  it('leaves a visible title exactly where it is', () => {
+    const layout = { sections: [section('header', 0), section('document_title', 3)] }
+    expect(materializeHiddenSection(layout, 'document_title')).toBe(layout)
+  })
+
+  it('marks the borrowed title block as synthetic in the spec', () => {
+    const synthetic: Block = {
+      id: 'document_title',
+      label: 'document title',
+      synthetic: true,
+      placement: { mode: 'flow', order: 0.5 },
+      content: { kind: 'text', text: 'INVOICE' },
+    }
+    expect(synthetic.synthetic).toBe(true)
+  })
+})
+
+describe('flow rows', () => {
+  it('puts a full-width section on a row of its own', () => {
+    const rows = groupFlowBlocks([block('header', 0), block('totals', 1)])
+    expect(rows.map((r) => r.type)).toEqual(['single', 'single'])
+  })
+
+  it('pairs a left and a right section side by side', () => {
+    const rows = groupFlowBlocks([
+      block('header', 0),
+      block('customer', 1, 'left'),
+      block('vehicle', 2, 'right'),
+      block('items_table', 3),
+    ])
+    expect(rows.map((r) => r.type)).toEqual(['single', 'pair', 'single'])
+    const pair = rows[1] as Extract<(typeof rows)[number], { type: 'pair' }>
+    expect(pair.left.map((b) => b.id)).toEqual(['customer'])
+    expect(pair.right.map((b) => b.id)).toEqual(['vehicle'])
+  })
+
+  it('stacks several sections in the same column', () => {
+    const rows = groupFlowBlocks([
+      block('customer', 0, 'left'),
+      block('service', 1, 'left'),
+      block('vehicle', 2, 'right'),
+    ])
+    const pair = rows[0] as Extract<(typeof rows)[number], { type: 'pair' }>
+    expect(pair.left.map((b) => b.id)).toEqual(['customer', 'service'])
+    expect(pair.right.map((b) => b.id)).toEqual(['vehicle'])
+  })
+
+  it('breaks the pair when a full-width section comes between', () => {
+    const rows = groupFlowBlocks([
+      block('customer', 0, 'left'),
+      block('items_table', 1),
+      block('vehicle', 2, 'right'),
+    ])
+    expect(rows.map((r) => r.type)).toEqual(['pair', 'single', 'pair'])
+  })
+
+  it('leaves anything placed by hand out of the flow entirely', () => {
+    const anchored: Block = {
+      id: 'logo',
+      label: 'logo',
+      placement: { mode: 'anchored', anchor: { x: 10, y: 10 } },
+      content: { kind: 'text', text: 'logo' },
+    }
+    const rows = groupFlowBlocks([block('customer', 0, 'left'), anchored])
+    expect(rows).toHaveLength(1)
+    expect(
+      (rows[0] as Extract<(typeof rows)[number], { type: 'pair' }>).left.map((b) => b.id)
+    ).toEqual(['customer'])
+  })
+})

+ 215 - 0
src/__tests__/features/settings/designer-layout-engine.test.ts

@@ -0,0 +1,215 @@
+/**
+ * The geometry the designer drags against is computed here, not measured from
+ * the DOM, so it can be held to account: rows land where the numbers say,
+ * page breaks fall between rows, the flow steps around what was placed by
+ * hand, and the pinned footer keeps the foot of the sheet to itself.
+ */
+import { describe, expect, it } from 'vitest'
+import type { Block, DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+import { BLOCK_GAP, layoutDocument } from '@/features/invoice-designer/Render/layoutEngine'
+
+const MARGIN = 40
+const PAGE = { width: 595, height: 842 }
+const CONTENT_WIDTH = PAGE.width - MARGIN * 2
+
+function specOf(blocks: Block[]): DocumentSpec {
+  return {
+    page: {
+      ...PAGE,
+      margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN },
+      text: '#111827',
+      muted: '#6b7280',
+      accent: '#d97706',
+      fontFamily: 'Helvetica',
+      fontSize: 9,
+    },
+    blocks,
+  }
+}
+
+const flow = (id: string, order: number, column?: 'left' | 'right'): Block => ({
+  id,
+  label: id,
+  placement: { mode: 'flow', order, column },
+  content: { kind: 'text', text: id },
+})
+
+const anchoredAt = (id: string, x: number, y: number, width: number, page = 1): Block => ({
+  id,
+  label: id,
+  placement: { mode: 'anchored', anchor: { x, y, width, page } },
+  content: { kind: 'text', text: id },
+})
+
+const pinnedFooter = (id = 'footer'): Block => ({
+  id,
+  label: id,
+  placement: { mode: 'pinned', edge: 'bottom' },
+  content: { kind: 'text', text: id },
+})
+
+const heights = (map: Record<string, number>) => (id: string) => map[id] ?? 0
+
+describe('flow placement', () => {
+  it('stacks rows from the top margin with the gap between them', () => {
+    const layout = layoutDocument(specOf([flow('a', 0), flow('b', 1)]), heights({ a: 100, b: 50 }))
+    expect(layout.rows.map((r) => [r.page, r.y, r.height])).toEqual([
+      [1, MARGIN, 100],
+      [1, MARGIN + 100 + BLOCK_GAP, 50],
+    ])
+  })
+
+  it('offers a gap before every row and one past the end', () => {
+    const layout = layoutDocument(specOf([flow('a', 0), flow('b', 1)]), heights({ a: 100, b: 50 }))
+    expect(layout.gaps.map((g) => g.index)).toEqual([0, 1, 2])
+  })
+
+  it('moves a row that does not fit whole to the next sheet', () => {
+    const layout = layoutDocument(specOf([flow('a', 0), flow('b', 1)]), heights({ a: 700, b: 200 }))
+    expect(layout.rows.map((r) => r.page)).toEqual([1, 2])
+    expect(layout.pageCount).toBe(2)
+  })
+
+  it('gives paired columns one row as tall as the taller lane', () => {
+    const layout = layoutDocument(
+      specOf([flow('customer', 0, 'left'), flow('vehicle', 1, 'right')]),
+      heights({ customer: 80, vehicle: 120 })
+    )
+    expect(layout.rows).toHaveLength(1)
+    expect(layout.rows[0].height).toBe(120)
+    const customer = layout.rects.get('customer')
+    const vehicle = layout.rects.get('vehicle')
+    expect(customer?.x).toBe(MARGIN)
+    expect(vehicle?.x).toBe(MARGIN + layout.colWidth + BLOCK_GAP)
+  })
+})
+
+describe('making room', () => {
+  it('pushes the flow below a block placed over the column', () => {
+    const card = anchoredAt('customer', 100, 60, 200)
+    const layout = layoutDocument(specOf([flow('a', 0), card]), heights({ a: 100, customer: 90 }))
+    // The row starts below the card and the gap that separates them.
+    expect(layout.rows[0].y).toBe(60 + 90 + BLOCK_GAP)
+  })
+
+  it('leaves the flow alone when the placed block misses the column', () => {
+    const outside = anchoredAt('stamp', 0, 60, 30)
+    const layout = layoutDocument(specOf([flow('a', 0), outside]), heights({ a: 100, stamp: 90 }))
+    expect(layout.rows[0].y).toBe(MARGIN)
+  })
+
+  it('drops only the lane a placed block covers, not its neighbour', () => {
+    // The card sits over the right lane; the left lane keeps its place.
+    const card = anchoredAt('note', 340, 50, 180)
+    const layout = layoutDocument(
+      specOf([flow('customer', 0, 'left'), flow('vehicle', 1, 'right'), card]),
+      heights({ customer: 80, vehicle: 80, note: 60 })
+    )
+    const row = layout.rows[0]
+    expect(row.laneOffsets.left).toBe(0)
+    expect(row.laneOffsets.right).toBeGreaterThan(0)
+  })
+
+  it('adapts live to the space a lifted block would take', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), flow('b', 1)]),
+      heights({ a: 100, b: 50 }),
+      {
+        liftedId: 'b',
+        band: { page: 1, x: MARGIN, y: 40, width: CONTENT_WIDTH, height: 60 },
+      }
+    )
+    expect(layout.rows).toHaveLength(1)
+    expect(layout.rows[0].y).toBe(40 + 60 + BLOCK_GAP)
+  })
+
+  it('opens a slot where an insertion would land', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), flow('b', 1)]),
+      heights({ a: 100, b: 50 }),
+      {
+        gapInsert: { index: 1, height: 30 },
+      }
+    )
+    expect(layout.rows[1].y).toBe(MARGIN + 100 + BLOCK_GAP + 30 + BLOCK_GAP)
+  })
+})
+
+describe('a section with margin', () => {
+  it('keeps the asked-for room around a full-width block', () => {
+    const roomy: Block = {
+      ...flow('a', 0),
+      margin: { top: 20, right: 30, bottom: 10, left: 15 },
+    }
+    const layout = layoutDocument(specOf([roomy, flow('b', 1)]), heights({ a: 100, b: 50 }))
+    const rect = layout.rects.get('a')
+    expect(rect).toMatchObject({ x: MARGIN + 15, y: MARGIN + 20, width: CONTENT_WIDTH - 45 })
+    // The next row starts after the block plus its top and bottom margins.
+    expect(layout.rows[1].y).toBe(MARGIN + 20 + 100 + 10 + BLOCK_GAP)
+  })
+
+  it('keeps the room inside a lane as well', () => {
+    const roomy: Block = {
+      ...flow('customer', 0, 'left'),
+      margin: { top: 8, right: 0, bottom: 0, left: 0 },
+    }
+    const layout = layoutDocument(
+      specOf([roomy, flow('vehicle', 1, 'right')]),
+      heights({ customer: 80, vehicle: 80 })
+    )
+    expect(layout.rects.get('customer')?.y).toBe(MARGIN + 8)
+    expect(layout.rows[0].height).toBe(88)
+  })
+})
+
+describe('the foot of the sheet', () => {
+  it('stops the flow above the pinned footer', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), flow('b', 1), pinnedFooter()]),
+      heights({ a: 700, b: 60, footer: 40 })
+    )
+    // Without the footer both would fit; with it the second row must move on.
+    expect(layout.rows.map((r) => r.page)).toEqual([1, 2])
+  })
+
+  it('places the footer against the bottom on every sheet', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), pinnedFooter()]),
+      heights({ a: 100, footer: 40 })
+    )
+    const rect = layout.rects.get('footer')
+    expect(rect?.page).toBe(-1)
+    expect(rect?.y).toBe(PAGE.height - MARGIN / 2 - 40)
+  })
+})
+
+describe('a lifted block', () => {
+  it('is laid out as absent, so its slot closes', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), flow('b', 1), flow('c', 2)]),
+      heights({ a: 100, b: 50, c: 50 }),
+      { liftedId: 'b' }
+    )
+    expect(layout.rows.map((r) => r.row.type === 'single' && r.row.block.id)).toEqual(['a', 'c'])
+    expect(layout.rows[1].y).toBe(MARGIN + 100 + BLOCK_GAP)
+  })
+
+  it('casts no band from where it used to be', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), anchoredAt('card', 100, 60, 200)]),
+      heights({ a: 100, card: 90 }),
+      { liftedId: 'card' }
+    )
+    expect(layout.rows[0].y).toBe(MARGIN)
+  })
+})
+
+describe('sheets', () => {
+  it('counts the sheet an anchored block was carried to', () => {
+    const layout = layoutDocument(
+      specOf([flow('a', 0), anchoredAt('card', 100, 100, 200, 3)]),
+      heights({ a: 100, card: 90 })
+    )
+    expect(layout.pageCount).toBe(3)
+  })
+})

+ 87 - 0
src/__tests__/features/settings/document-title-placement.test.tsx

@@ -0,0 +1,87 @@
+/**
+ * @vitest-environment node
+ *
+ * The number, the date and the amount a customer quotes back must print
+ * exactly once, wherever the layout puts them. A layout with a Document Title
+ * section shows it there; a layout without one gets it set directly under the
+ * header, which is where every header used to print it. Enforced in the
+ * document generator, so the designer and the paper obey the same rule.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+const data: InvoiceData = {
+  id: 'svc-title',
+  title: 'Service',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  shopName: 'Shop',
+  techName: null,
+  mileage: null,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: 'INV-2026-1001',
+  partItems: [],
+  laborItems: [{ description: 'Work', hours: 1, rate: 100, total: 100 }],
+  customer: null,
+  vehicle: null,
+}
+
+function specWithTitleVisible(visible: boolean) {
+  const layout = getDefaultInvoiceLayout()
+  layout.sections = layout.sections.map((s) => (s.id === 'document_title' ? { ...s, visible } : s))
+  return buildInvoicePrintSpec({ data, template: { layoutConfig: layout } })
+}
+
+describe('document title placement', () => {
+  it.each([true, false])('prints the block exactly once (visible: %s)', (visible) => {
+    const spec = specWithTitleVisible(visible)
+    const titles = spec.blocks.filter((b) => b.id === 'document_title')
+    expect(titles).toHaveLength(1)
+    expect(JSON.stringify(titles[0])).toContain('INV-2026-1001')
+  })
+
+  it('sets a borrowed title directly under the header', () => {
+    const spec = specWithTitleVisible(false)
+    const header = spec.blocks.find((b) => b.id === 'header')
+    const title = spec.blocks.find((b) => b.id === 'document_title')
+    expect(header?.placement.mode).toBe('flow')
+    expect(title?.placement.mode).toBe('flow')
+    if (header?.placement.mode === 'flow' && title?.placement.mode === 'flow') {
+      expect(title.placement.order).toBeGreaterThan(header.placement.order)
+      const between = spec.blocks.filter(
+        (b) =>
+          b.placement.mode === 'flow' &&
+          header.placement.mode === 'flow' &&
+          title.placement.mode === 'flow' &&
+          b.placement.order > header.placement.order &&
+          b.placement.order < title.placement.order
+      )
+      expect(between).toHaveLength(0)
+    }
+  })
+
+  it('leaves a visible title section at its own place in the order', () => {
+    const spec = specWithTitleVisible(true)
+    const title = spec.blocks.find((b) => b.id === 'document_title')
+    expect(title?.placement.mode).toBe('flow')
+  })
+
+  it('anchors the framed letterhead onto the band, title in the flow below', () => {
+    const layout = getDefaultInvoiceLayout()
+    const spec = buildInvoicePrintSpec({
+      data,
+      template: { headerStyle: 'framed', layoutConfig: layout },
+    })
+    expect(spec.blocks.find((b) => b.id === 'header')?.placement.mode).toBe('anchored')
+    expect(spec.blocks.find((b) => b.id === 'document_title')?.placement.mode).toBe('flow')
+  })
+})

+ 267 - 0
src/__tests__/features/settings/framed-template-preset.test.tsx

@@ -0,0 +1,267 @@
+/**
+ * @vitest-environment node
+ *
+ * The Framed preset is the first one that rearranges the sheet rather than only
+ * recoloring it, so two things have to keep holding: a workshop that never
+ * picks it sees the invoice it has always seen, and a workshop that does pick
+ * it gets the banded letterhead, the rail down the left edge, and every line on
+ * one numbered list.
+ */
+import { describe, it, expect } from 'vitest'
+import { renderToBuffer } from '@react-pdf/renderer'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf'
+import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
+import { createStyles, FRAMED } from '@/features/vehicles/Components/invoice-pdf/styles'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
+import {
+  BOXED_ELIGIBLE_SECTIONS,
+  getDefaultInvoiceLayout,
+  getLetterheadMark,
+  invoiceLayoutConfigSchema,
+  withLetterheadMark,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+// The Letterhead template is the framed sheet: the band, the rail, and one
+// numbered list. It is what a workshop picks in the designer, so the layout
+// asserted here is the one the designer actually builds from it.
+const letterhead = layoutPresets.find((p) => p.id === 'letterhead')!
+const framedLayout = buildLayoutFromPreset(letterhead)
+
+/** The page style, widened past the union createStyles returns per variant. */
+function pageStyle(headerStyle: string, background?: string): Record<string, unknown> {
+  return createStyles('#ee7623', 'Helvetica', headerStyle, background).page as Record<
+    string,
+    unknown
+  >
+}
+
+function visibility(config: { sections: { id: string; visible: boolean }[] }) {
+  return Object.fromEntries(config.sections.map((s) => [s.id, s.visible]))
+}
+
+const INVOICE_DATA = {
+  id: 'svc-framed',
+  title: 'Brakes and coolant',
+  description: null,
+  type: 'Repair',
+  serviceDate: new Date('2026-08-14'),
+  shopName: 'Test Workshop',
+  techName: 'Sam',
+  mileage: 105866,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 300,
+  taxRate: 19,
+  taxAmount: 57,
+  totalAmount: 357,
+  cost: 357,
+  invoiceNumber: 'INV-1',
+  partItems: [
+    {
+      partNumber: 'BS-1',
+      name: 'Brake disc',
+      quantity: 2,
+      unit: 'Stk.',
+      unitPrice: 50,
+      total: 100,
+    },
+    { partNumber: null, name: 'Shop supplies', quantity: 1, unit: null, unitPrice: 20, total: 20 },
+  ],
+  laborItems: [
+    { description: 'Front brakes replaced', hours: 4, rate: 45, total: 180, pricingType: 'hourly' },
+  ],
+  customer: {
+    name: 'A Customer',
+    email: null,
+    phone: null,
+    address: 'Some Street 1',
+    company: null,
+    taxId: null,
+    customerNumber: 'C-1044',
+  },
+  vehicle: {
+    make: 'Volvo',
+    model: 'V60',
+    year: 2021,
+    vin: 'YV1AA0000L0000000',
+    licensePlate: 'AB 12345',
+    mileage: 105866,
+    customer: null,
+  },
+} as never
+
+const QUOTE_DATA = {
+  ...(INVOICE_DATA as unknown as Record<string, unknown>),
+  quoteNumber: 'Q-1',
+  status: 'sent',
+  createdAt: new Date('2026-08-14'),
+  validUntil: new Date('2026-09-14'),
+  notes: null,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+} as never
+
+describe('framed template preset', () => {
+  it('is not what a workshop gets by default', () => {
+    const seen = visibility(getDefaultInvoiceLayout())
+    expect(seen.items_table).toBe(false)
+    expect(seen.document_title).toBe(false)
+    expect(seen.parts_table).toBe(true)
+    expect(seen.labor_table).toBe(true)
+  })
+
+  it('swaps the two tables for the combined one and moves the title down', () => {
+    const seen = visibility(framedLayout)
+    expect(seen.items_table).toBe(true)
+    expect(seen.document_title).toBe(true)
+    expect(seen.parts_table).toBe(false)
+    expect(seen.labor_table).toBe(false)
+  })
+
+  it('stands the vehicle opposite the customer', () => {
+    const sections = framedLayout.sections
+    expect(sections.find((s) => s.id === 'customer')?.column).toBe('left')
+    expect(sections.find((s) => s.id === 'vehicle')?.column).toBe('right')
+  })
+
+  it('draws the rail as the page border, so it repeats on every page', () => {
+    const page = pageStyle('framed')
+    expect(page.borderLeftWidth).toBe(FRAMED.railWidth)
+    expect(page.borderLeftColor).toBe('#ee7623')
+    // The letterhead escapes the padding by exactly this much to reach the
+    // sheet edge, so the two numbers must stay in step.
+    expect(page.paddingTop).toBe(FRAMED.padTop)
+    expect(page.paddingLeft).toBe(FRAMED.padLeft)
+  })
+
+  it('leaves every other header style unframed', () => {
+    for (const style of ['standard', 'compact', 'modern']) {
+      expect(pageStyle(style).borderLeftWidth).toBeUndefined()
+    }
+  })
+
+  it('carries one mark on the band, the logo when there is one', () => {
+    const markOf = (logoDataUri: string | undefined, showLogo: boolean) => {
+      const header = buildInvoicePrintSpec({
+        data: INVOICE_DATA,
+        logoDataUri,
+        template: { headerStyle: 'framed', showLogo },
+      }).blocks.find((b) => b.id === 'header')
+      const json = JSON.stringify(header)
+      return json.includes('header.logo') ? 'logo' : 'name'
+    }
+    expect(markOf('data:image/png;base64,x', true)).toBe('logo')
+    expect(markOf(undefined, true)).toBe('name')
+    expect(markOf('data:image/png;base64,x', false)).toBe('name')
+  })
+
+  it('lets a workshop put its name on the band instead of its logo', () => {
+    const layout = getDefaultInvoiceLayout()
+    expect(getLetterheadMark(layout)).toBe('logo')
+
+    const named = withLetterheadMark(layout, 'company_name')
+    expect(getLetterheadMark(named)).toBe('company_name')
+
+    // Both fields move together: the band shows one, and leaving the other
+    // visible would mislead whoever opens the layout editor next.
+    const header = named.sections.find((s) => s.id === 'header')
+    expect(header?.fields?.find((f) => f.id === 'logo')?.visible).toBe(false)
+    expect(header?.fields?.find((f) => f.id === 'company_name')?.visible).toBe(true)
+
+    expect(getLetterheadMark(withLetterheadMark(named, 'logo'))).toBe('logo')
+  })
+
+  it('leaves the footer the one note line it has always been by default', () => {
+    const footer = getDefaultInvoiceLayout().sections.find((s) => s.id === 'footer')
+    const visible = footer?.fields?.filter((f) => f.visible).map((f) => f.id)
+    expect(visible).toEqual(['footer_note'])
+  })
+
+  it('moves the company details down to the footer', () => {
+    const sections = framedLayout.sections
+    const shown = (id: string) =>
+      sections
+        .find((s) => s.id === id)
+        ?.fields?.filter((f) => f.visible)
+        .map((f) => f.id) ?? []
+
+    // The letterhead keeps who the shop is; the ways to reach it print along
+    // the bottom, the way printed stationery sets them. The slogan is its own
+    // section now, not a header field.
+    expect(shown('header')).toEqual(['logo', 'company_name'])
+    expect(shown('footer')).toContain('company_address')
+    expect(shown('footer')).toContain('company_phone')
+    expect(shown('footer')).toContain('company_email')
+    expect(shown('footer')).not.toContain('footer_note')
+  })
+
+  it('offers to take the panel off every card that draws one', () => {
+    expect([...BOXED_ELIGIBLE_SECTIONS].sort()).toEqual([
+      'customer',
+      'general',
+      'notes',
+      'service',
+      'telegram_qr',
+      'vehicle',
+      'warranty',
+    ])
+  })
+
+  it('leaves every section boxed until a layout says otherwise', () => {
+    // Unset, not true: that is what every saved layout already holds, and it
+    // has to keep reading as boxed.
+    for (const section of getDefaultInvoiceLayout().sections) {
+      expect(section.boxed).toBeUndefined()
+    }
+
+    const parsed = invoiceLayoutConfigSchema.parse({
+      sections: [{ id: 'customer', visible: true, order: 0, boxed: false }],
+    })
+    expect(parsed.sections[0].boxed).toBe(false)
+  })
+
+  it('lines the footer up with the content, not the rail', () => {
+    const styles = createStyles('#ee7623', 'Helvetica', 'framed')
+    // Absolute offsets are measured from inside the page border, so the rail's
+    // width is already accounted for.
+    expect(styles.footer.left).toBe(FRAMED.padLeft)
+  })
+
+  it('leaves the sheet white until a background is asked for', () => {
+    expect(pageStyle('framed').backgroundColor).toBeUndefined()
+    expect(pageStyle('standard').backgroundColor).toBeUndefined()
+    expect(pageStyle('framed', '#f3f4f6').backgroundColor).toBe('#f3f4f6')
+    expect(pageStyle('standard', '#f3f4f6').backgroundColor).toBe('#f3f4f6')
+  })
+
+  it('renders an invoice and a quote through the framed sheet', async () => {
+    const template = {
+      primaryColor: letterhead.template.primaryColor,
+      fontFamily: letterhead.template.fontFamily,
+      headerStyle: letterhead.template.headerStyle,
+      layoutConfig: framedLayout,
+    }
+
+    const invoice = await renderToBuffer(
+      <InvoicePDF
+        data={INVOICE_DATA}
+        template={template}
+        workshop={{
+          name: 'Test Workshop',
+          address: 'Some Street 1',
+          phone: '123',
+          email: 'a@b.c',
+        }}
+      />
+    )
+    expect(invoice.byteLength).toBeGreaterThan(1000)
+
+    const quote = await renderToBuffer(
+      <QuotePDF data={QUOTE_DATA} template={template} layoutConfig={framedLayout} />
+    )
+    expect(quote.byteLength).toBeGreaterThan(1000)
+  })
+})

+ 77 - 0
src/__tests__/features/settings/invoice-appearance.test.ts

@@ -0,0 +1,77 @@
+/**
+ * The appearance a workshop chooses has to reach every line, not the few that
+ * happen to inherit it. Text color once only moved the lines with no color of
+ * their own; everything styled inline stayed gray.
+ */
+import { describe, it, expect } from 'vitest'
+import {
+  createStyles,
+  inkColors,
+  mixColor,
+} from '@/features/vehicles/Components/invoice-pdf/styles'
+
+const read = (styles: Record<string, unknown>, key: string) =>
+  (styles[key] as { color?: string }).color
+
+describe('invoice appearance', () => {
+  it('leaves the default palette alone when nothing is chosen', () => {
+    const styles = createStyles('#d97706', 'Helvetica')
+    expect(read(styles, 'page')).toBe('#111827')
+    expect(read(styles, 'infoTextSmall')).toBe('#6b7280')
+    expect(read(styles, 'footer')).toBe('#6b7280')
+  })
+
+  it('measures every size from the body size', async () => {
+    const { BASE_FONT_SIZE } = await import('@/features/vehicles/Components/invoice-pdf/styles')
+    const styles = createStyles('#d97706', 'Helvetica') as unknown as Record<
+      string,
+      { fontSize?: number }
+    >
+    // Sizes used to be written out one by one, so raising the body size left a
+    // table at one scale and a heading at another.
+    expect(styles.page.fontSize).toBe(BASE_FONT_SIZE)
+    expect(styles.infoText.fontSize).toBe(BASE_FONT_SIZE)
+    expect(styles.infoTextSmall.fontSize).toBe(BASE_FONT_SIZE - 1)
+    expect(styles.tableHeaderCell.fontSize).toBe(BASE_FONT_SIZE - 2)
+    expect(styles.sectionTitle.fontSize).toBe(BASE_FONT_SIZE + 2)
+    expect(styles.grandTotalValue.fontSize).toBe(BASE_FONT_SIZE + 4)
+  })
+
+  it('carries a chosen ink into every text style, primary and secondary', () => {
+    const styles = createStyles('#d97706', 'Helvetica', 'standard', undefined, '#1e3a5f')
+    const { ink, muted } = inkColors(styles)
+
+    expect(ink).toBe('#1e3a5f')
+    expect(read(styles, 'page')).toBe(ink)
+    expect(read(styles, 'sectionTitle')).toBe(ink)
+
+    // Secondary lines follow the ink rather than staying at one fixed gray, or
+    // they would be unreadable on a dark sheet and mismatched on a tinted one.
+    for (const key of ['infoTextSmall', 'totalLabel', 'notesText', 'footer', 'brandSub']) {
+      expect(read(styles, key), key).toBe(muted)
+    }
+    expect(muted).not.toBe(ink)
+    expect(muted).not.toBe('#6b7280')
+  })
+
+  it('mutes toward the sheet, so the tone suits the paper it prints on', () => {
+    const onWhite = inkColors(
+      createStyles('#d97706', 'Helvetica', 'standard', undefined, '#000000')
+    )
+    const onBlack = inkColors(
+      createStyles('#d97706', 'Helvetica', 'standard', '#000000', '#ffffff')
+    )
+    expect(onWhite.muted).toBe(mixColor('#000000', '#ffffff', 0.42))
+    expect(onBlack.muted).toBe(mixColor('#ffffff', '#000000', 0.42))
+  })
+
+  it('tints the sheet only when asked', () => {
+    expect(read(createStyles('#d97706', 'Helvetica'), 'page')).toBeTruthy()
+    const plain = createStyles('#d97706', 'Helvetica').page as { backgroundColor?: string }
+    const tinted = createStyles('#d97706', 'Helvetica', 'standard', '#f4f4f2').page as {
+      backgroundColor?: string
+    }
+    expect(plain.backgroundColor).toBeUndefined()
+    expect(tinted.backgroundColor).toBe('#f4f4f2')
+  })
+})

+ 10 - 10
src/__tests__/features/settings/itemized-template-preset.test.tsx

@@ -11,10 +11,11 @@ import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
 import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf'
 import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
-import { templatePresets } from '@/features/settings/Schema/templatePresets'
+import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
 import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
 
-const itemized = templatePresets.find((p) => p.id === 'itemized')
+const itemized = layoutPresets.find((p) => p.id === 'itemized')!
+const itemizedLayout = buildLayoutFromPreset(itemized)
 
 function visibility(config: { sections: { id: string; visible: boolean }[] }) {
   return Object.fromEntries(config.sections.map((s) => [s.id, s.visible]))
@@ -91,32 +92,31 @@ describe('itemized template preset', () => {
   })
 
   it('swaps the two tables for the combined one', () => {
-    expect(itemized?.layoutConfig).toBeDefined()
-    const seen = visibility(itemized!.layoutConfig!)
+    const seen = visibility(itemizedLayout)
     expect(seen.items_table).toBe(true)
     expect(seen.parts_table).toBe(false)
     expect(seen.labor_table).toBe(false)
   })
 
   it('stands the vehicle opposite the customer', () => {
-    const sections = itemized!.layoutConfig!.sections
+    const sections = itemizedLayout.sections
     expect(sections.find((s) => s.id === 'customer')?.column).toBe('left')
     expect(sections.find((s) => s.id === 'vehicle')?.column).toBe('right')
   })
 
   it('renders an invoice and a quote through the combined table', async () => {
     const template = {
-      primaryColor: itemized!.primaryColor,
-      fontFamily: itemized!.fontFamily,
-      headerStyle: itemized!.headerStyle,
-      layoutConfig: itemized!.layoutConfig,
+      primaryColor: itemized.template.primaryColor,
+      fontFamily: itemized.template.fontFamily,
+      headerStyle: itemized.template.headerStyle,
+      layoutConfig: itemizedLayout,
     }
 
     const invoice = await renderToBuffer(<InvoicePDF data={INVOICE_DATA} template={template} />)
     expect(invoice.byteLength).toBeGreaterThan(1000)
 
     const quote = await renderToBuffer(
-      <QuotePDF data={QUOTE_DATA} template={template} layoutConfig={itemized!.layoutConfig} />
+      <QuotePDF data={QUOTE_DATA} template={template} layoutConfig={itemizedLayout} />
     )
     expect(quote.byteLength).toBeGreaterThan(1000)
   })

+ 54 - 0
src/__tests__/features/settings/layout-editor-labels.test.ts

@@ -0,0 +1,54 @@
+/**
+ * Every section and field the layout editor can list needs a name in every
+ * locale. A missing one used to take the editor down: next-intl raises on a key
+ * it cannot resolve, and the editor's fallback to the built-in English name
+ * never ran because it was written expecting the key back instead.
+ *
+ * `warranty` had been missing in all twelve locales for exactly that reason,
+ * unnoticed until someone opened the editor on a layout that had it.
+ */
+import { readdirSync, readFileSync } from 'node:fs'
+import { describe, it, expect } from 'vitest'
+import {
+  BUILTIN_SECTIONS,
+  BUILTIN_CUSTOMER_FIELDS,
+  BUILTIN_VEHICLE_FIELDS,
+  BUILTIN_SERVICE_FIELDS,
+  BUILTIN_HEADER_FIELDS,
+  BUILTIN_BANK_ACCOUNT_FIELDS,
+  BUILTIN_FOOTER_FIELDS,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+const LOCALES = readdirSync('messages')
+
+const FIELD_IDS = [
+  ...BUILTIN_CUSTOMER_FIELDS,
+  ...BUILTIN_VEHICLE_FIELDS,
+  ...BUILTIN_SERVICE_FIELDS,
+  ...BUILTIN_HEADER_FIELDS,
+  ...BUILTIN_BANK_ACCOUNT_FIELDS,
+  ...BUILTIN_FOOTER_FIELDS,
+].map((f) => f.id)
+
+function layoutEditorMessages(locale: string) {
+  const json = JSON.parse(readFileSync(`messages/${locale}/settings.json`, 'utf8'))
+  return json.layoutEditor ?? {}
+}
+
+describe('layout editor labels', () => {
+  it('has at least the twelve shipped locales to check', () => {
+    expect(LOCALES.length).toBeGreaterThanOrEqual(12)
+  })
+
+  it.each(LOCALES)('names every section in %s', (locale) => {
+    const named = Object.keys(layoutEditorMessages(locale).sections ?? {})
+    const missing = BUILTIN_SECTIONS.map((s) => s.id).filter((id) => !named.includes(id))
+    expect(missing).toEqual([])
+  })
+
+  it.each(LOCALES)('names every field in %s', (locale) => {
+    const named = Object.keys(layoutEditorMessages(locale).fields ?? {})
+    const missing = FIELD_IDS.filter((id) => !named.includes(id))
+    expect(missing).toEqual([])
+  })
+})

+ 106 - 0
src/__tests__/features/settings/layout-presets.test.ts

@@ -0,0 +1,106 @@
+/**
+ * The designer's starting arrangements: where every section sits, and the
+ * colors, header style and typeface that go with it.
+ */
+import { readdirSync, readFileSync } from 'node:fs'
+import { describe, it, expect } from 'vitest'
+import { layoutPresets, buildLayoutFromPreset } from '@/features/settings/Schema/layoutPresets'
+import {
+  BUILTIN_FOOTER_FIELDS,
+  BUILTIN_SECTIONS,
+  invoiceLayoutConfigSchema,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+const LOCALES = readdirSync('messages')
+
+describe('layout presets', () => {
+  it('offers five arrangements', () => {
+    expect(layoutPresets.map((p) => p.id)).toEqual([
+      'classic',
+      'letterhead',
+      'compact',
+      'itemized',
+      'detailed',
+    ])
+  })
+
+  it.each(layoutPresets)('$id only names sections that exist', (preset) => {
+    const known = new Set<string>(BUILTIN_SECTIONS.map((s) => s.id))
+    const unknown = [
+      ...preset.order,
+      ...Object.keys(preset.columns ?? {}),
+      ...(preset.plain ?? []),
+    ].filter((id) => !known.has(id))
+    expect(unknown).toEqual([])
+  })
+
+  it.each(layoutPresets)('$id builds a valid layout', (preset) => {
+    const config = buildLayoutFromPreset(preset)
+    expect(() => invoiceLayoutConfigSchema.parse(config)).not.toThrow()
+
+    // Every section survives, so trying a preset on and picking another never
+    // throws away a section the workshop had configured.
+    expect(config.sections.map((s) => s.id).sort()).toEqual(
+      BUILTIN_SECTIONS.map((s) => s.id).sort()
+    )
+  })
+
+  it.each(layoutPresets)('$id prints its sections in the order it asked for', (preset) => {
+    const visible = buildLayoutFromPreset(preset)
+      .sections.filter((s) => s.visible)
+      .sort((a, b) => a.order - b.order)
+      .map((s) => s.id)
+    expect(visible).toEqual(preset.order)
+  })
+
+  it.each(layoutPresets)('$id leaves other sections their own fields', (preset) => {
+    const config = buildLayoutFromPreset(preset)
+    // A preset names fields for the header and the footer only. Everything else
+    // keeps what it had, or the detail blocks come out empty.
+    for (const id of ['customer', 'vehicle', 'service', 'bank_account']) {
+      const fields = config.sections.find((s) => s.id === id)?.fields
+      expect(fields?.length, id).toBeGreaterThan(0)
+      expect(
+        fields?.every((f) => f.visible),
+        id
+      ).toBe(true)
+    }
+  })
+
+  it('shows the whole footer on the detailed arrangement', () => {
+    const detailed = layoutPresets.find((p) => p.id === 'detailed')
+    const footer = buildLayoutFromPreset(detailed!).sections.find((s) => s.id === 'footer')
+    // Every footer field, derived rather than listed, so one added later is
+    // carried here instead of quietly missing.
+    expect(footer?.fields?.filter((f) => f.visible).map((f) => f.id)).toEqual(
+      BUILTIN_FOOTER_FIELDS.map((f) => f.id)
+    )
+  })
+
+  it('gives every template its own look, not only its own arrangement', () => {
+    // Four templates that changed nothing but which sections were on looked
+    // identical, because the band, the rail and the color were saved elsewhere
+    // and never touched by picking one.
+    const looks = layoutPresets.map((p) => JSON.stringify(p.template))
+    expect(new Set(looks).size).toBe(layoutPresets.length)
+
+    const headers = layoutPresets.map((p) => p.template.headerStyle)
+    expect(new Set(headers).size).toBeGreaterThan(1)
+    const colors = layoutPresets.map((p) => p.template.primaryColor)
+    expect(new Set(colors).size).toBe(layoutPresets.length)
+  })
+
+  it('gives every arrangement a different one', () => {
+    const shapes = layoutPresets.map((p) => JSON.stringify(buildLayoutFromPreset(p)))
+    expect(new Set(shapes).size).toBe(layoutPresets.length)
+  })
+
+  it.each(LOCALES)('names every arrangement in %s', (locale) => {
+    const json = JSON.parse(readFileSync(`messages/${locale}/settings.json`, 'utf8'))
+    const named = json.layoutEditor?.presets ?? {}
+    for (const preset of layoutPresets) {
+      expect(named[preset.id]?.name, `${locale}/${preset.id}`).toBeTruthy()
+      expect(named[preset.id]?.description, `${locale}/${preset.id}`).toBeTruthy()
+    }
+  })
+})

+ 46 - 0
src/__tests__/features/settings/layout-preview-workshop.test.ts

@@ -0,0 +1,46 @@
+/**
+ * The layout preview is meant to look like this workshop's own paper. It used
+ * to greet every workshop as "Your Workshop" because the sample shop was
+ * hardcoded and the real company details never reached the renderer.
+ */
+import { describe, it, expect } from 'vitest'
+import { resolveWorkshop } from '@/features/settings/Components/InvoiceLayoutPreviewRenderer'
+
+describe('layout preview workshop', () => {
+  it('uses the workshop that has been set up', () => {
+    expect(
+      resolveWorkshop({
+        name: 'Die Autofabrik',
+        address: 'Hauptstraße 480',
+        phone: '044 89 12 27',
+        email: 'info@autofabrik.net',
+        slogan: 'Ihre Mehrmarken-Meisterwerkstatt',
+      })
+    ).toEqual({
+      name: 'Die Autofabrik',
+      address: 'Hauptstraße 480',
+      phone: '044 89 12 27',
+      email: 'info@autofabrik.net',
+      slogan: 'Ihre Mehrmarken-Meisterwerkstatt',
+    })
+  })
+
+  it('falls back to the sample only for what has not been filled in', () => {
+    const resolved = resolveWorkshop({ name: 'Die Autofabrik', address: '  ' })
+    expect(resolved.name).toBe('Die Autofabrik')
+    expect(resolved.address).toBe('123 Main Street, Springfield')
+  })
+
+  it('shows the sample shop when nothing has been set up at all', () => {
+    expect(resolveWorkshop(undefined).name).toBe('Your Workshop')
+  })
+
+  it('never invents a slogan', () => {
+    // A workshop always has a name and an address, so a placeholder there reads
+    // as "yours goes here". An invented slogan reads as one it never wrote.
+    expect(resolveWorkshop(undefined).slogan).toBeUndefined()
+    expect(resolveWorkshop({ name: 'Die Autofabrik' }).slogan).toBeUndefined()
+    expect(resolveWorkshop({ slogan: '   ' }).slogan).toBeUndefined()
+    expect(resolveWorkshop({ slogan: 'All makes, one roof' }).slogan).toBe('All makes, one roof')
+  })
+})

+ 152 - 0
src/__tests__/features/settings/layout-styling.test.ts

@@ -0,0 +1,152 @@
+/**
+ * Appearance lives in the layout, not in settings keys.
+ *
+ * Every option added the old way cost a settings key, plumbing through each of
+ * the six PDF builders, a control, and twelve translations. These two helpers
+ * are the whole mechanism instead: five keys on a section, six on the sheet,
+ * folded into the stylesheet the sections already receive.
+ */
+import { describe, it, expect } from 'vitest'
+import {
+  createStyles,
+  withSectionStyle,
+  withDocumentStyle,
+} from '@/features/vehicles/Components/invoice-pdf/styles'
+
+const base = () => createStyles('#d97706', 'Helvetica')
+const read = (styles: Record<string, unknown>, key: string, prop = 'color') =>
+  (styles[key] as Record<string, unknown>)[prop]
+
+describe('section styling', () => {
+  it('changes nothing without a style', () => {
+    const styles = base()
+    expect(withSectionStyle(styles, undefined)).toBe(styles)
+  })
+
+  it('maps one key onto every entry that carries its meaning', () => {
+    const styled = withSectionStyle(base(), {
+      textColor: '#111111',
+      labelColor: '#222222',
+      backgroundColor: '#333333',
+      borderColor: '#444444',
+    })
+
+    for (const key of ['infoText', 'tableCell', 'notesText', 'totalValue']) {
+      expect(read(styled, key), key).toBe('#111111')
+    }
+    // A table's column headings are its label, the same way a panel's heading is.
+    for (const key of ['infoLabel', 'sectionTitle', 'tableHeaderCell']) {
+      expect(read(styled, key), key).toBe('#222222')
+    }
+    // A background is a panel fill on a detail block and the bar behind the
+    // headings on a table.
+    for (const key of ['infoBox', 'tableHeader', 'totalsBox']) {
+      expect(read(styled, key, 'backgroundColor'), key).toBe('#333333')
+    }
+    expect(read(styled, 'infoBox', 'borderColor')).toBe('#444444')
+    expect(read(styled, 'tableRow', 'borderBottomColor')).toBe('#444444')
+  })
+
+  it('gives a border to a panel that had none when one is asked for', () => {
+    const styled = withSectionStyle(base(), { borderColor: '#444444' })
+    expect(read(styled, 'infoBox', 'borderWidth')).toBeGreaterThan(0)
+  })
+
+  it('leaves the rest of the sheet alone', () => {
+    const styled = withSectionStyle(base(), { textColor: '#111111' })
+    expect(read(styled, 'page')).toBe(read(base(), 'page'))
+  })
+})
+
+describe('document styling', () => {
+  it('changes nothing without a style', () => {
+    const styles = base()
+    expect(withDocumentStyle(styles, undefined)).toBe(styles)
+  })
+
+  it('scales the whole sheet from one size', () => {
+    const styled = withDocumentStyle(base(), { fontSize: 8 })
+    expect(read(styled, 'page', 'fontSize')).toBe(8)
+    expect(read(styled, 'tableCell', 'fontSize')).toBe(7)
+    expect(read(styled, 'sectionTitle', 'fontSize')).toBe(10)
+  })
+
+  it('never scales text below legibility', () => {
+    const styled = withDocumentStyle(base(), { fontSize: 6 })
+    expect(read(styled, 'tableHeaderCell', 'fontSize')).toBe(5)
+  })
+
+  it('turns banding off, or recolors it', () => {
+    expect(withDocumentStyle(base(), { stripes: false }).tableRowAlt).toEqual({})
+    expect(
+      read(withDocumentStyle(base(), { stripeColor: '#eef2ff' }), 'tableRowAlt', 'backgroundColor')
+    ).toBe('#eef2ff')
+  })
+
+  it('keeps the framed sheet clear of its own frame', () => {
+    const framed = createStyles('#d97706', 'Helvetica', 'framed')
+    const tightened = withDocumentStyle(framed, { margin: 20 }, true)
+    // The top and left are the room the band and rail occupy, not margins.
+    expect(read(tightened, 'page', 'paddingTop')).toBe(read(framed, 'page', 'paddingTop'))
+    expect(read(tightened, 'page', 'paddingLeft')).toBe(read(framed, 'page', 'paddingLeft'))
+    expect(read(tightened, 'page', 'paddingRight')).toBe(20)
+  })
+
+  it('moves the accent off the primary color', () => {
+    const styled = withDocumentStyle(base(), { accentColor: '#334155' })
+    expect(read(styled, 'infoLabel')).toBe('#334155')
+    expect(read(styled, 'totalDivider', 'borderTopColor')).toBe('#334155')
+  })
+})
+
+describe('layout config carries its styling', () => {
+  it('keeps the whole-sheet block through a merge with defaults', async () => {
+    const { mergeWithDefaults, getSectionStyle } = await import(
+      '@/features/settings/Schema/invoiceLayoutSchema'
+    )
+    const saved = {
+      document: { fontSize: 8, accentColor: '#334155' },
+      sections: [{ id: 'customer', visible: true, order: 0, style: { textColor: '#111111' } }],
+    }
+    const merged = mergeWithDefaults(saved)
+    // Sections a saved layout has never seen are appended; what it does carry
+    // has to survive, or styling the sheet and then reordering would drop it.
+    expect(merged.document).toEqual(saved.document)
+    expect(getSectionStyle(merged, 'customer')?.textColor).toBe('#111111')
+    expect(getSectionStyle(merged, 'vehicle')).toBeUndefined()
+  })
+
+  it('treats an emptied style bag as none', async () => {
+    const { getSectionStyle } = await import('@/features/settings/Schema/invoiceLayoutSchema')
+    const config = {
+      sections: [{ id: 'customer', visible: true, order: 0, style: { textColor: undefined } }],
+    }
+    expect(getSectionStyle(config, 'customer')).toBeUndefined()
+  })
+})
+
+describe('typefaces', () => {
+  it('gives each name its own family, rather than one font wearing three labels', async () => {
+    const { getFontRegular, getFontBold } = await import(
+      '@/features/vehicles/Components/invoice-pdf/styles'
+    )
+    const families = ['Helvetica', 'Times-Roman', 'Courier'].map(getFontRegular)
+    expect(new Set(families).size).toBe(3)
+    expect(getFontBold('Times-Roman')).toBe(`${getFontRegular('Times-Roman')}-Bold`)
+    // An unknown name still has to render, so it falls back rather than throwing.
+    expect(getFontRegular('Something Else')).toBe('Roboto')
+  })
+
+  it('sets a section in its own typeface without touching the sheet', () => {
+    const styled = withSectionStyle(base(), { fontFamily: 'Times-Roman' })
+    expect(read(styled, 'infoText', 'fontFamily')).toBe('Noto Serif')
+    expect(read(styled, 'sectionTitle', 'fontFamily')).toBe('Noto Serif-Bold')
+    expect(read(styled, 'page', 'fontFamily')).toBe(read(base(), 'page', 'fontFamily'))
+  })
+
+  it('sets the whole sheet, page included', () => {
+    const styled = withDocumentStyle(base(), { fontFamily: 'Courier' })
+    expect(read(styled, 'page', 'fontFamily')).toBe('Noto Sans Mono')
+    expect(read(styled, 'tableCell', 'fontFamily')).toBe('Noto Sans Mono')
+  })
+})

+ 38 - 0
src/__tests__/features/settings/preview-remount-key.test.ts

@@ -0,0 +1,38 @@
+/**
+ * The layout preview remounts its viewer on a key. That key used to list four
+ * properties of each section, so every option added since — colors, the frame
+ * edge, whether a section is boxed, which header fields print — changed the
+ * document without changing the key, and looked from the outside like it did
+ * nothing at all.
+ *
+ * The key is the whole document now. This holds it there.
+ */
+import { readFileSync } from 'node:fs'
+import { describe, it, expect } from 'vitest'
+
+const SOURCE = readFileSync(
+  'src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx',
+  'utf8'
+)
+
+describe('layout preview remount key', () => {
+  it('is built from the whole document, not a list of properties', () => {
+    const key = SOURCE.slice(SOURCE.indexOf('const documentKey'), SOURCE.indexOf('useSettled('))
+    for (const part of ['config', 'templateConfig', 'logoUrl', 'previewWorkshop']) {
+      expect(key, part).toContain(part)
+    }
+    // A hand-picked subset is what broke it. Anything that reads properties off
+    // sections to build the key has gone back to the old shape.
+    expect(key).not.toMatch(/s\.(id|order|visible|column)/)
+  })
+
+  it('waits for the value to settle before remounting', () => {
+    // A color picker fires on every step of a drag, and each distinct key is a
+    // full re-render of the document.
+    expect(SOURCE).toContain('useSettled(documentKey')
+  })
+
+  it('passes the settled key to the viewer', () => {
+    expect(SOURCE).toContain('key={configKey}')
+  })
+})

+ 259 - 0
src/__tests__/features/settings/spec-pdf-print.test.tsx

@@ -0,0 +1,259 @@
+// @vitest-environment node
+/**
+ * The printed sheet is the designed sheet.
+ *
+ * These render real PDFs through the same generator and layout engine the
+ * designer draws with, and hold the print to what the designer promises: a
+ * hand-placed block keeps its spot and width, a narrowed slogan wraps instead
+ * of running back out to full width, margins reserve their room, and every
+ * header style still produces a valid document.
+ */
+import React from 'react'
+import { renderToBuffer, type DocumentProps } from '@react-pdf/renderer'
+import { describe, expect, it } from 'vitest'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { estimateBlockHeights } from '@/features/invoice-designer/Pdf/estimateHeights'
+import { lineCount } from '@/features/invoice-designer/Pdf/measure'
+import { InvoicePDF } from '@/features/vehicles/Components/invoice-pdf/InvoicePDF'
+import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
+import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+const invoice: InvoiceData = {
+  id: 'svc_12345678',
+  title: 'Brakes and coolant',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  invoiceDate: new Date('2026-08-14'),
+  invoiceDueDate: new Date('2026-08-24'),
+  shopName: 'Testshop',
+  techName: 'Jamie Lee',
+  mileage: 105866,
+  diagnosticNotes: null,
+  invoiceNotes:
+    '<p>Thank you for choosing our workshop. <strong>Contact us</strong> if you have any questions.</p>',
+  subtotal: 1053.63,
+  taxRate: 19,
+  taxAmount: 200.19,
+  totalAmount: 1253.82,
+  cost: 1253.82,
+  invoiceNumber: 'INV-2026-0042',
+  discountType: 'percentage',
+  discountValue: 5,
+  discountAmount: 52.68,
+  partItems: [
+    {
+      partNumber: 'BD-1042',
+      name: 'Brake disc, front left',
+      quantity: 1,
+      unitPrice: 256.12,
+      total: 256.12,
+    },
+  ],
+  laborItems: [{ description: 'Front brakes replaced', hours: 4.8, rate: 56.5, total: 271.2 }],
+  customFields: [
+    { fieldId: 'cfdef1', label: 'Insurance no.', value: 'INS-991', fieldType: 'text' },
+  ],
+  findings: [{ description: 'Rear pads at 15%', severity: 'needs_work', notes: 'Replace soon' }],
+  warrantyMonths: 12,
+  warrantyMileage: 20000,
+  warrantyExpiresAt: null,
+  warrantyNotes: 'Parts and labour.',
+  customer: {
+    name: 'Alex Carter',
+    email: 'alex@example.com',
+    phone: '+1 555 0134',
+    address: '12 Harbour Road, Springfield',
+    company: 'Carter Logistics Ltd',
+    customerNumber: 'C-0117',
+  },
+  vehicle: {
+    make: 'Volvo',
+    model: 'V60',
+    year: 2021,
+    vin: 'YV1AA0000L0000000',
+    licensePlate: 'AB 12345',
+    mileage: 105866,
+    customer: null,
+  },
+}
+
+const workshop = {
+  name: 'Testshop',
+  address: 'Somewhere 1',
+  phone: '+47 123 45 678',
+  email: 'post@testshop.example',
+  slogan: 'Quality service you can trust since nineteen ninety eight',
+}
+
+const settings = { currencyCode: 'EUR', bankAccount: 'XX00 1234 5678', orgNumber: '123 456 789' }
+
+describe('the printed invoice follows the designed layout', () => {
+  it('renders a valid PDF for every header style', async () => {
+    for (const headerStyle of ['standard', 'compact', 'modern', 'framed']) {
+      const buffer = await renderToBuffer(
+        (
+          <InvoicePDF
+            data={invoice}
+            workshop={workshop}
+            invoiceSettings={settings}
+            template={{ primaryColor: '#d97706', headerStyle }}
+          />
+        ) as React.ReactElement<DocumentProps>
+      )
+      expect(buffer.subarray(0, 5).toString()).toBe('%PDF-')
+    }
+  })
+
+  it('prints the slogan as its own block, apart from the header', () => {
+    const spec = buildInvoicePrintSpec({ data: invoice, workshop, invoiceSettings: settings })
+    const slogan = spec.blocks.find((b) => b.id === 'slogan')
+    expect(slogan).toBeDefined()
+    expect(JSON.stringify(spec.blocks.find((b) => b.id === 'header'))).not.toContain(
+      workshop.slogan
+    )
+  })
+
+  it('keeps a hand-placed slogan at its spot and width', async () => {
+    const layout = {
+      ...getDefaultInvoiceLayout(),
+      anchors: { slogan: { x: 320, y: 180, width: 120, page: 1 } },
+    }
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: { layoutConfig: layout },
+    })
+    const slogan = spec.blocks.find((b) => b.id === 'slogan')
+    expect(slogan?.placement).toEqual({
+      mode: 'anchored',
+      anchor: { x: 320, y: 180, width: 120, page: 1 },
+    })
+
+    const buffer = await renderToBuffer(
+      (
+        <InvoicePDF
+          data={invoice}
+          workshop={workshop}
+          invoiceSettings={settings}
+          template={{ layoutConfig: layout }}
+        />
+      ) as React.ReactElement<DocumentProps>
+    )
+    expect(buffer.subarray(0, 5).toString()).toBe('%PDF-')
+  })
+
+  it('wraps a narrowed slogan onto more lines, as the designer shows', () => {
+    const wide = lineCount(workshop.slogan, 400, { fontSize: 9 }, 9)
+    const narrow = lineCount(workshop.slogan, 120, { fontSize: 9 }, 9)
+    expect(wide).toBe(1)
+    expect(narrow).toBeGreaterThan(1)
+
+    const anchored = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: {
+        layoutConfig: {
+          ...getDefaultInvoiceLayout(),
+          anchors: { slogan: { x: 320, y: 180, width: 120, page: 1 } },
+        },
+      },
+    })
+    const flowing = buildInvoicePrintSpec({ data: invoice, workshop, invoiceSettings: settings })
+    const narrowHeight = estimateBlockHeights(anchored).get('slogan') ?? 0
+    const wideHeight = estimateBlockHeights(flowing).get('slogan') ?? 0
+    expect(narrowHeight).toBeGreaterThan(wideHeight)
+  })
+
+  it('reserves the margin a section asks for', () => {
+    const layout = getDefaultInvoiceLayout()
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'parts_table' ? { ...s, style: { marginTop: 30, marginLeft: 40 } } : s
+    )
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: { layoutConfig: layout },
+    })
+    const table = spec.blocks.find((b) => b.id === 'parts_table')
+    expect(table?.margin).toEqual({ top: 30, right: 0, bottom: 0, left: 40 })
+  })
+
+  it('always prints the number and the date, even with the title section off', () => {
+    // The default layout hides the document title; the sheet borrows it.
+    const spec = buildInvoicePrintSpec({ data: invoice, workshop, invoiceSettings: settings })
+    const title = spec.blocks.find((b) => b.id === 'document_title')
+    expect(title).toBeDefined()
+    expect(JSON.stringify(title)).toContain('INV-2026-0042')
+  })
+
+  it('carries discounts, payments and the balance into the totals', () => {
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      paymentSummary: {
+        totalPaid: 500,
+        payments: [{ amount: 500, date: '15.08.2026', method: 'card' }],
+      },
+    })
+    const totals = JSON.stringify(spec.blocks.find((b) => b.id === 'totals'))
+    expect(totals).toContain('Discount (5%)')
+    expect(totals).toContain('15.08.2026')
+    expect(totals).toContain('Amount Due')
+  })
+})
+
+describe('the printed quote follows the designed layout', () => {
+  it('renders a valid PDF and strikes lines the customer opted out of', async () => {
+    const buffer = await renderToBuffer(
+      (
+        <QuotePDF
+          data={{
+            quoteNumber: 'QT-100',
+            title: 'Brake job',
+            description: '<p>As discussed.</p>',
+            validUntil: new Date('2026-09-30'),
+            createdAt: new Date('2026-08-30'),
+            subtotal: 500,
+            taxRate: 25,
+            taxAmount: 125,
+            discountType: null,
+            discountValue: 0,
+            discountAmount: 0,
+            totalAmount: 625,
+            notes: null,
+            partItems: [
+              {
+                partNumber: 'P-1',
+                name: 'Pads',
+                quantity: 1,
+                unitPrice: 200,
+                total: 200,
+                excluded: true,
+              },
+            ],
+            laborItems: [{ description: 'Fit pads', hours: 2, rate: 150, total: 300 }],
+            customer: {
+              name: 'Alex',
+              email: null,
+              phone: null,
+              address: null,
+              company: null,
+            },
+            vehicle: { make: 'Volvo', model: 'V60', year: 2021, vin: null, licensePlate: null },
+          }}
+          workshop={workshop}
+          currencyCode="EUR"
+          template={{ primaryColor: '#2563eb', headerStyle: 'framed' }}
+        />
+      ) as React.ReactElement<DocumentProps>
+    )
+    expect(buffer.subarray(0, 5).toString()).toBe('%PDF-')
+  })
+})

+ 1 - 3
src/__tests__/features/status-report-send.test.ts

@@ -46,9 +46,7 @@ vi.mock('@/features/telegram/Actions/telegramActions', () => ({
   sendTelegramToCustomer: (i: unknown) => sendTelegramToCustomer(i),
 }))
 
-const { sendStatusReport } = await import(
-  '@/features/status-reports/Actions/sendStatusReport'
-)
+const { sendStatusReport } = await import('@/features/status-reports/Actions/sendStatusReport')
 
 const CUSTOMER = {
   id: 'cust',

+ 71 - 71
src/__tests__/hooks/use-debounced-search.test.ts

@@ -7,97 +7,97 @@
  * user typed in the meantime, and the input would visibly drop characters.
  */
 
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { act, renderHook } from "@testing-library/react";
-import { useDebouncedSearch } from "@/hooks/use-debounced-search";
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
-const DELAY = 350;
+const DELAY = 350
 
-describe("useDebouncedSearch", () => {
-  beforeEach(() => vi.useFakeTimers());
-  afterEach(() => vi.useRealTimers());
+describe('useDebouncedSearch', () => {
+  beforeEach(() => vi.useFakeTimers())
+  afterEach(() => vi.useRealTimers())
 
-  it("commits once after typing pauses", () => {
-    const onCommit = vi.fn();
-    const { result } = renderHook(() => useDebouncedSearch("", onCommit, DELAY));
+  it('commits once after typing pauses', () => {
+    const onCommit = vi.fn()
+    const { result } = renderHook(() => useDebouncedSearch('', onCommit, DELAY))
 
-    act(() => result.current.setValue("bra"));
-    act(() => result.current.setValue("brak"));
-    act(() => result.current.setValue("brake"));
-    expect(onCommit).not.toHaveBeenCalled();
+    act(() => result.current.setValue('bra'))
+    act(() => result.current.setValue('brak'))
+    act(() => result.current.setValue('brake'))
+    expect(onCommit).not.toHaveBeenCalled()
 
-    act(() => void vi.advanceTimersByTime(DELAY));
-    expect(onCommit).toHaveBeenCalledTimes(1);
-    expect(onCommit).toHaveBeenCalledWith("brake");
-  });
+    act(() => void vi.advanceTimersByTime(DELAY))
+    expect(onCommit).toHaveBeenCalledTimes(1)
+    expect(onCommit).toHaveBeenCalledWith('brake')
+  })
 
-  it("does not clobber characters typed while the search navigates", () => {
-    const onCommit = vi.fn();
+  it('does not clobber characters typed while the search navigates', () => {
+    const onCommit = vi.fn()
     const { result, rerender } = renderHook(
       ({ initial }) => useDebouncedSearch(initial, onCommit, DELAY),
-      { initialProps: { initial: "" } },
-    );
+      { initialProps: { initial: '' } }
+    )
 
-    act(() => result.current.setValue("brak"));
-    act(() => void vi.advanceTimersByTime(DELAY));
-    expect(onCommit).toHaveBeenCalledWith("brak");
+    act(() => result.current.setValue('brak'))
+    act(() => void vi.advanceTimersByTime(DELAY))
+    expect(onCommit).toHaveBeenCalledWith('brak')
 
     // User keeps typing before the server responds...
-    act(() => result.current.setValue("brake"));
+    act(() => result.current.setValue('brake'))
     // ...and the committed term now arrives back as a prop.
-    rerender({ initial: "brak" });
+    rerender({ initial: 'brak' })
 
     // The extra character must survive.
-    expect(result.current.value).toBe("brake");
-  });
+    expect(result.current.value).toBe('brake')
+  })
 
-  it("still adopts a genuinely external URL change (back button, reset)", () => {
-    const onCommit = vi.fn();
+  it('still adopts a genuinely external URL change (back button, reset)', () => {
+    const onCommit = vi.fn()
     const { result, rerender } = renderHook(
       ({ initial }) => useDebouncedSearch(initial, onCommit, DELAY),
-      { initialProps: { initial: "brake" } },
-    );
+      { initialProps: { initial: 'brake' } }
+    )
 
-    expect(result.current.value).toBe("brake");
-    rerender({ initial: "" });
-    expect(result.current.value).toBe("");
-  });
+    expect(result.current.value).toBe('brake')
+    rerender({ initial: '' })
+    expect(result.current.value).toBe('')
+  })
 
-  it("does not re-commit the term the server already applied", () => {
-    const onCommit = vi.fn();
-    renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
-    act(() => void vi.advanceTimersByTime(DELAY * 2));
-    expect(onCommit).not.toHaveBeenCalled();
-  });
+  it('does not re-commit the term the server already applied', () => {
+    const onCommit = vi.fn()
+    renderHook(() => useDebouncedSearch('brake', onCommit, DELAY))
+    act(() => void vi.advanceTimersByTime(DELAY * 2))
+    expect(onCommit).not.toHaveBeenCalled()
+  })
 
-  it("commitNow fires immediately and cancels the pending debounce", () => {
-    const onCommit = vi.fn();
-    const { result } = renderHook(() => useDebouncedSearch("", onCommit, DELAY));
+  it('commitNow fires immediately and cancels the pending debounce', () => {
+    const onCommit = vi.fn()
+    const { result } = renderHook(() => useDebouncedSearch('', onCommit, DELAY))
 
-    act(() => result.current.setValue("brake"));
-    act(() => result.current.commitNow());
-    expect(onCommit).toHaveBeenCalledTimes(1);
+    act(() => result.current.setValue('brake'))
+    act(() => result.current.commitNow())
+    expect(onCommit).toHaveBeenCalledTimes(1)
 
     // The already-scheduled timer must not fire a duplicate navigation.
-    act(() => void vi.advanceTimersByTime(DELAY * 2));
-    expect(onCommit).toHaveBeenCalledTimes(1);
-  });
-
-  it("clearing the input commits undefined so the param is removed", () => {
-    const onCommit = vi.fn();
-    const { result } = renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
-
-    act(() => result.current.setValue(""));
-    act(() => void vi.advanceTimersByTime(DELAY));
-    expect(onCommit).toHaveBeenCalledWith(undefined);
-  });
-
-  it("treats a trailing space as the same search", () => {
-    const onCommit = vi.fn();
-    const { result } = renderHook(() => useDebouncedSearch("brake", onCommit, DELAY));
-
-    act(() => result.current.setValue("brake "));
-    act(() => void vi.advanceTimersByTime(DELAY * 2));
-    expect(onCommit).not.toHaveBeenCalled();
-  });
-});
+    act(() => void vi.advanceTimersByTime(DELAY * 2))
+    expect(onCommit).toHaveBeenCalledTimes(1)
+  })
+
+  it('clearing the input commits undefined so the param is removed', () => {
+    const onCommit = vi.fn()
+    const { result } = renderHook(() => useDebouncedSearch('brake', onCommit, DELAY))
+
+    act(() => result.current.setValue(''))
+    act(() => void vi.advanceTimersByTime(DELAY))
+    expect(onCommit).toHaveBeenCalledWith(undefined)
+  })
+
+  it('treats a trailing space as the same search', () => {
+    const onCommit = vi.fn()
+    const { result } = renderHook(() => useDebouncedSearch('brake', onCommit, DELAY))
+
+    act(() => result.current.setValue('brake '))
+    act(() => void vi.advanceTimersByTime(DELAY * 2))
+    expect(onCommit).not.toHaveBeenCalled()
+  })
+})

+ 38 - 8
src/__tests__/i18n/audit-summaries.test.ts

@@ -58,7 +58,13 @@ describe('audit summaries', () => {
 
   it('has English wording for every key the app uses', () => {
     const en = summary('en')
-    const orphans = [...new Set(keysInUse().filter((k) => !en[k.key]).map((k) => k.key))]
+    const orphans = [
+      ...new Set(
+        keysInUse()
+          .filter((k) => !en[k.key])
+          .map((k) => k.key)
+      ),
+    ]
     expect(orphans, `no English for:\n${orphans.join('\n')}`).toEqual([])
   })
 
@@ -87,13 +93,37 @@ describe('audit summaries', () => {
     // in that language only, which is exactly the sort of thing that reaches
     // production because nobody tests in Lithuanian.
     const sample: Record<string, string | number> = {
-      name: 'X', id: 'X', ref: 'X', code: 'X', from: 'A', to: 'B', jobRef: 'J',
-      title: 'T', description: 'D', fileName: 'f.jpg', recipient: 'a@b.c',
-      email: 'a@b.c', phone: '+1', amount: '10', rate: 8, items: 'a, b',
-      channels: 'email', vehicleId: 'V', serviceRecordId: 'S', sendAt: 'now',
-      fields: 'colour', year: 2024, make: 'M', model: 'M',
-      status: 'draft', role: 'admin', channel: 'email', type: 'wash_tires',
-      count: 2, records: 2, quotes: 2,
+      name: 'X',
+      id: 'X',
+      ref: 'X',
+      code: 'X',
+      from: 'A',
+      to: 'B',
+      jobRef: 'J',
+      title: 'T',
+      description: 'D',
+      fileName: 'f.jpg',
+      recipient: 'a@b.c',
+      email: 'a@b.c',
+      phone: '+1',
+      amount: '10',
+      rate: 8,
+      items: 'a, b',
+      channels: 'email',
+      vehicleId: 'V',
+      serviceRecordId: 'S',
+      sendAt: 'now',
+      fields: 'colour',
+      year: 2024,
+      make: 'M',
+      model: 'M',
+      status: 'draft',
+      role: 'admin',
+      channel: 'email',
+      type: 'wash_tires',
+      count: 2,
+      records: 2,
+      quotes: 2,
     }
     for (const locale of LOCALES) {
       const messages = summary(locale)

+ 74 - 77
src/__tests__/i18n/translation-parity.test.ts

@@ -1,113 +1,110 @@
-import { describe, it, expect } from "vitest";
-import fs from "fs";
-import path from "path";
+import { describe, it, expect } from 'vitest'
+import fs from 'fs'
+import path from 'path'
 
-const messagesRoot = path.resolve(process.cwd(), "messages");
-const REFERENCE_LOCALE = "en";
+const messagesRoot = path.resolve(process.cwd(), 'messages')
+const REFERENCE_LOCALE = 'en'
 
 const allLocales = fs
   .readdirSync(messagesRoot)
   .filter((entry) => fs.statSync(path.join(messagesRoot, entry)).isDirectory())
-  .sort();
+  .sort()
 
-const otherLocales = allLocales.filter((l) => l !== REFERENCE_LOCALE);
+const otherLocales = allLocales.filter((l) => l !== REFERENCE_LOCALE)
 
 function loadNamespace(locale: string, namespace: string): unknown {
-  const file = path.join(messagesRoot, locale, `${namespace}.json`);
-  if (!fs.existsSync(file)) return undefined;
-  return JSON.parse(fs.readFileSync(file, "utf-8"));
+  const file = path.join(messagesRoot, locale, `${namespace}.json`)
+  if (!fs.existsSync(file)) return undefined
+  return JSON.parse(fs.readFileSync(file, 'utf-8'))
 }
 
-function collectLeafPaths(obj: unknown, prefix = ""): string[] {
-  if (obj == null || typeof obj !== "object") return [];
-  const out: string[] = [];
+function collectLeafPaths(obj: unknown, prefix = ''): string[] {
+  if (obj == null || typeof obj !== 'object') return []
+  const out: string[] = []
   for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
-    const next = prefix ? `${prefix}.${key}` : key;
-    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
-      out.push(...collectLeafPaths(value, next));
+    const next = prefix ? `${prefix}.${key}` : key
+    if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+      out.push(...collectLeafPaths(value, next))
     } else {
-      out.push(next);
+      out.push(next)
     }
   }
-  return out;
+  return out
 }
 
 function resolve(obj: unknown, keyPath: string): unknown {
-  let cur = obj;
-  for (const seg of keyPath.split(".")) {
-    if (cur == null || typeof cur !== "object") return undefined;
-    cur = (cur as Record<string, unknown>)[seg];
+  let cur = obj
+  for (const seg of keyPath.split('.')) {
+    if (cur == null || typeof cur !== 'object') return undefined
+    cur = (cur as Record<string, unknown>)[seg]
   }
-  return cur;
+  return cur
 }
 
 const referenceNamespaces = fs
   .readdirSync(path.join(messagesRoot, REFERENCE_LOCALE))
-  .filter((f) => f.endsWith(".json"))
-  .map((f) => f.replace(/\.json$/, ""))
-  .sort();
+  .filter((f) => f.endsWith('.json'))
+  .map((f) => f.replace(/\.json$/, ''))
+  .sort()
 
-describe("translation parity", () => {
-  it("discovers all expected locales", () => {
-    expect(allLocales).toContain(REFERENCE_LOCALE);
-    expect(allLocales.length).toBeGreaterThanOrEqual(12);
-  });
+describe('translation parity', () => {
+  it('discovers all expected locales', () => {
+    expect(allLocales).toContain(REFERENCE_LOCALE)
+    expect(allLocales.length).toBeGreaterThanOrEqual(12)
+  })
 
-  describe.each(referenceNamespaces)("namespace %s", (namespace) => {
-    const referenceMessages = loadNamespace(REFERENCE_LOCALE, namespace);
-    const referencePaths = collectLeafPaths(referenceMessages);
+  describe.each(referenceNamespaces)('namespace %s', (namespace) => {
+    const referenceMessages = loadNamespace(REFERENCE_LOCALE, namespace)
+    const referencePaths = collectLeafPaths(referenceMessages)
 
     it.each(otherLocales)(
-      `every en key resolves in %s/${"%%"}`.replace("%%", namespace),
+      `every en key resolves in %s/${'%%'}`.replace('%%', namespace),
       (locale) => {
-        const messages = loadNamespace(locale, namespace);
-        expect(messages, `messages/${locale}/${namespace}.json missing`).toBeDefined();
+        const messages = loadNamespace(locale, namespace)
+        expect(messages, `messages/${locale}/${namespace}.json missing`).toBeDefined()
 
-        const missing: string[] = [];
+        const missing: string[] = []
         for (const keyPath of referencePaths) {
-          const value = resolve(messages, keyPath);
-          if (typeof value !== "string") {
-            missing.push(keyPath);
+          const value = resolve(messages, keyPath)
+          if (typeof value !== 'string') {
+            missing.push(keyPath)
           }
         }
         expect(
           missing,
-          `${locale}/${namespace}.json is missing ${missing.length} key(s):\n  - ${missing.join("\n  - ")}`,
-        ).toEqual([]);
-      },
-    );
-  });
-});
+          `${locale}/${namespace}.json is missing ${missing.length} key(s):\n  - ${missing.join('\n  - ')}`
+        ).toEqual([])
+      }
+    )
+  })
+})
 
-describe("recently added i18n keys", () => {
+describe('recently added i18n keys', () => {
   const requiredKeys: Array<{ namespace: string; key: string }> = [
-    { namespace: "dashboard", key: "maintenance.settingsAriaLabel" },
-    { namespace: "dashboard", key: "maintenance.dismissAriaLabel" },
-    { namespace: "dashboard", key: "quoteRequests.dismissAriaLabel" },
-    { namespace: "dashboard", key: "quoteResponses.dismissAriaLabel" },
-    { namespace: "statusReport", key: "create.createReportFailed" },
-    { namespace: "quotes", key: "images.failedUploadFile" },
-    { namespace: "quotes", key: "images.failedSaveFile" },
-    { namespace: "quotes", key: "documents.failedUploadFile" },
-    { namespace: "quotes", key: "documents.failedSaveFile" },
-    { namespace: "settings", key: "subscription.checkoutError" },
-    { namespace: "settings", key: "reportSchedule.failedSave" },
-    { namespace: "settings", key: "reportSchedule.failedSend" },
-    { namespace: "settings", key: "reportSchedule.failedToggle" },
-    { namespace: "settings", key: "reportSchedule.failedDelete" },
-  ];
+    { namespace: 'dashboard', key: 'maintenance.settingsAriaLabel' },
+    { namespace: 'dashboard', key: 'maintenance.dismissAriaLabel' },
+    { namespace: 'dashboard', key: 'quoteRequests.dismissAriaLabel' },
+    { namespace: 'dashboard', key: 'quoteResponses.dismissAriaLabel' },
+    { namespace: 'statusReport', key: 'create.createReportFailed' },
+    { namespace: 'quotes', key: 'images.failedUploadFile' },
+    { namespace: 'quotes', key: 'images.failedSaveFile' },
+    { namespace: 'quotes', key: 'documents.failedUploadFile' },
+    { namespace: 'quotes', key: 'documents.failedSaveFile' },
+    { namespace: 'settings', key: 'subscription.checkoutError' },
+    { namespace: 'settings', key: 'reportSchedule.failedSave' },
+    { namespace: 'settings', key: 'reportSchedule.failedSend' },
+    { namespace: 'settings', key: 'reportSchedule.failedToggle' },
+    { namespace: 'settings', key: 'reportSchedule.failedDelete' },
+  ]
 
-  it.each(requiredKeys)(
-    "$namespace.$key is present in all locales",
-    ({ namespace, key }) => {
-      for (const locale of allLocales) {
-        const messages = loadNamespace(locale, namespace);
-        const value = resolve(messages, key);
-        expect(
-          typeof value,
-          `${locale}/${namespace}.json missing ${key} (got ${JSON.stringify(value)})`,
-        ).toBe("string");
-      }
-    },
-  );
-});
+  it.each(requiredKeys)('$namespace.$key is present in all locales', ({ namespace, key }) => {
+    for (const locale of allLocales) {
+      const messages = loadNamespace(locale, namespace)
+      const value = resolve(messages, key)
+      expect(
+        typeof value,
+        `${locale}/${namespace}.json missing ${key} (got ${JSON.stringify(value)})`
+      ).toBe('string')
+    }
+  })
+})

+ 120 - 122
src/__tests__/lib/features.test.ts

@@ -1,168 +1,166 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     subscription: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
   },
-}));
+}))
 
-import { db } from "@/lib/db";
-import { getFeatures, PLAN_FEATURES } from "@/lib/features";
+import { db } from '@/lib/db'
+import { getFeatures, PLAN_FEATURES } from '@/lib/features'
 
-const mockFindUnique = vi.mocked(db.subscription.findUnique);
-const mockFindMany = vi.mocked(db.appSetting.findMany);
+const mockFindUnique = vi.mocked(db.subscription.findUnique)
+const mockFindMany = vi.mocked(db.appSetting.findMany)
 
 beforeEach(() => {
-  vi.resetAllMocks();
-  vi.unstubAllEnvs();
-});
+  vi.resetAllMocks()
+  vi.unstubAllEnvs()
+})
 
-describe("getFeatures — cloud mode", () => {
+describe('getFeatures — cloud mode', () => {
   beforeEach(() => {
-    vi.stubEnv("TORQVOICE_MODE", "cloud");
-  });
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+  })
 
-  it("returns free features when no subscription exists", async () => {
-    mockFindUnique.mockResolvedValue(null);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.free);
-  });
+  it('returns free features when no subscription exists', async () => {
+    mockFindUnique.mockResolvedValue(null)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.free)
+  })
 
-  it("returns free features when subscription status is canceled", async () => {
+  it('returns free features when subscription status is canceled', async () => {
     mockFindUnique.mockResolvedValue({
-      status: "canceled",
-      plan: { name: "Torq Pro" },
+      status: 'canceled',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: null,
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.free);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.free)
+  })
 
   it("returns pro features for plan named 'Torq Pro'", async () => {
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Torq Pro" },
+      status: 'active',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: new Date(Date.now() + 86400000),
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.pro);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.pro)
+  })
 
   it("returns pro features for plan named 'Pro'", async () => {
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Pro" },
+      status: 'active',
+      plan: { name: 'Pro' },
       currentPeriodEnd: new Date(Date.now() + 86400000),
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.pro);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.pro)
+  })
 
   it("returns enterprise features for plan named 'Enterprise'", async () => {
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Enterprise" },
+      status: 'active',
+      plan: { name: 'Enterprise' },
       currentPeriodEnd: new Date(Date.now() + 86400000),
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.enterprise);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.enterprise)
+  })
 
-  it("returns pro features for trialing subscription", async () => {
+  it('returns pro features for trialing subscription', async () => {
     mockFindUnique.mockResolvedValue({
-      status: "trialing",
-      plan: { name: "Torq Pro" },
+      status: 'trialing',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: new Date(Date.now() + 86400000),
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.pro);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.pro)
+  })
 
-  it("returns free features when past_due", async () => {
+  it('returns free features when past_due', async () => {
     mockFindUnique.mockResolvedValue({
-      status: "past_due",
-      plan: { name: "Torq Pro" },
+      status: 'past_due',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: null,
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.free);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.free)
+  })
 
-  it("returns free features when period ended and grace period elapsed", async () => {
-    const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000);
+  it('returns free features when period ended and grace period elapsed', async () => {
+    const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000)
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Torq Pro" },
+      status: 'active',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: fourDaysAgo,
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.free);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.free)
+  })
 
-  it("returns pro features within grace period after period end", async () => {
-    const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
+  it('returns pro features within grace period after period end', async () => {
+    const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Torq Pro" },
+      status: 'active',
+      plan: { name: 'Torq Pro' },
       currentPeriodEnd: oneHourAgo,
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.pro);
-  });
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.pro)
+  })
 
-  it("returns free features for unknown plan name", async () => {
+  it('returns free features for unknown plan name', async () => {
     mockFindUnique.mockResolvedValue({
-      status: "active",
-      plan: { name: "Unknown Plan" },
+      status: 'active',
+      plan: { name: 'Unknown Plan' },
       currentPeriodEnd: new Date(Date.now() + 86400000),
-    } as any);
-    const features = await getFeatures("org-1");
-    expect(features).toEqual(PLAN_FEATURES.free);
-  });
-});
+    } as any)
+    const features = await getFeatures('org-1')
+    expect(features).toEqual(PLAN_FEATURES.free)
+  })
+})
 
-describe("getFeatures — self-hosted mode", () => {
+describe('getFeatures — self-hosted mode', () => {
   beforeEach(() => {
-    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
-  });
-
-  it("unlocks all features except branding when no license", async () => {
-    mockFindMany.mockResolvedValue([]);
-    const features = await getFeatures("org-1");
-    expect(features.smtp).toBe(true);
-    expect(features.api).toBe(true);
-    expect(features.payments).toBe(true);
-    expect(features.brandingRemoved).toBe(false);
-    expect(features.customPlatformName).toBe(false);
-  });
-
-  it("unlocks branding when license is valid and not expired", async () => {
-    const future = new Date(Date.now() + 86400000).toISOString();
+    vi.stubEnv('TORQVOICE_MODE', 'self-hosted')
+  })
+
+  it('unlocks all features except branding when no license', async () => {
+    mockFindMany.mockResolvedValue([])
+    const features = await getFeatures('org-1')
+    expect(features.smtp).toBe(true)
+    expect(features.api).toBe(true)
+    expect(features.payments).toBe(true)
+    expect(features.brandingRemoved).toBe(false)
+    expect(features.customPlatformName).toBe(false)
+  })
+
+  it('unlocks branding when license is valid and not expired', async () => {
+    const future = new Date(Date.now() + 86400000).toISOString()
     mockFindMany.mockResolvedValue([
-      { key: "license.valid", value: "true" },
-      { key: "license.expiresAt", value: future },
-    ] as any);
-    const features = await getFeatures("org-1");
-    expect(features.brandingRemoved).toBe(true);
-    expect(features.customPlatformName).toBe(true);
-  });
-
-  it("does not unlock branding when license is expired", async () => {
-    const past = new Date(Date.now() - 86400000).toISOString();
+      { key: 'license.valid', value: 'true' },
+      { key: 'license.expiresAt', value: future },
+    ] as any)
+    const features = await getFeatures('org-1')
+    expect(features.brandingRemoved).toBe(true)
+    expect(features.customPlatformName).toBe(true)
+  })
+
+  it('does not unlock branding when license is expired', async () => {
+    const past = new Date(Date.now() - 86400000).toISOString()
     mockFindMany.mockResolvedValue([
-      { key: "license.valid", value: "true" },
-      { key: "license.expiresAt", value: past },
-    ] as any);
-    const features = await getFeatures("org-1");
-    expect(features.brandingRemoved).toBe(false);
-  });
-
-  it("does not unlock branding when license.valid is false", async () => {
-    mockFindMany.mockResolvedValue([
-      { key: "license.valid", value: "false" },
-    ] as any);
-    const features = await getFeatures("org-1");
-    expect(features.brandingRemoved).toBe(false);
-  });
-});
+      { key: 'license.valid', value: 'true' },
+      { key: 'license.expiresAt', value: past },
+    ] as any)
+    const features = await getFeatures('org-1')
+    expect(features.brandingRemoved).toBe(false)
+  })
+
+  it('does not unlock branding when license.valid is false', async () => {
+    mockFindMany.mockResolvedValue([{ key: 'license.valid', value: 'false' }] as any)
+    const features = await getFeatures('org-1')
+    expect(features.brandingRemoved).toBe(false)
+  })
+})

+ 69 - 69
src/__tests__/lib/get-auth-context.test.ts

@@ -1,101 +1,101 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: {
       findUnique: vi.fn(),
     },
   },
-}));
+}))
 
-import { getAuthContext, getAuthContextDetailed } from "@/lib/get-auth-context";
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getAuthContext, getAuthContextDetailed } from '@/lib/get-auth-context'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 
-const mockGetCachedSession = vi.mocked(getCachedSession);
-const mockGetCachedMembership = vi.mocked(getCachedMembership);
-const mockFindUnique = vi.mocked(db.user.findUnique);
+const mockGetCachedSession = vi.mocked(getCachedSession)
+const mockGetCachedMembership = vi.mocked(getCachedMembership)
+const mockFindUnique = vi.mocked(db.user.findUnique)
 
-const SESSION = { user: { id: "user-1", email: "user@example.com" } };
+const SESSION = { user: { id: 'user-1', email: 'user@example.com' } }
 const MEMBERSHIP = {
-  organizationId: "org-1",
-  role: "member",
+  organizationId: 'org-1',
+  role: 'member',
   roleId: null,
   customRole: null,
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
+  vi.resetAllMocks()
+})
 
-describe("getAuthContext", () => {
-  it("returns null when unauthenticated", async () => {
-    mockGetCachedSession.mockResolvedValue(null);
-    expect(await getAuthContext()).toBeNull();
-  });
+describe('getAuthContext', () => {
+  it('returns null when unauthenticated', async () => {
+    mockGetCachedSession.mockResolvedValue(null)
+    expect(await getAuthContext()).toBeNull()
+  })
 
-  it("returns null when session exists but no membership and not super admin", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockGetCachedMembership.mockResolvedValue(null);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    expect(await getAuthContext()).toBeNull();
-  });
+  it('returns null when session exists but no membership and not super admin', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockGetCachedMembership.mockResolvedValue(null)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    expect(await getAuthContext()).toBeNull()
+  })
 
-  it("returns context when session and membership are present", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    const ctx = await getAuthContext();
+  it('returns context when session and membership are present', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    const ctx = await getAuthContext()
     expect(ctx).toMatchObject({
-      userId: "user-1",
-      organizationId: "org-1",
-      role: "member",
+      userId: 'user-1',
+      organizationId: 'org-1',
+      role: 'member',
       isSuperAdmin: false,
       isAdmin: false,
-    });
-  });
-});
+    })
+  })
+})
 
-describe("getAuthContextDetailed", () => {
-  it("returns unauthenticated when no session", async () => {
-    mockGetCachedSession.mockResolvedValue(null);
-    expect(await getAuthContextDetailed()).toEqual({ status: "unauthenticated" });
-  });
+describe('getAuthContextDetailed', () => {
+  it('returns unauthenticated when no session', async () => {
+    mockGetCachedSession.mockResolvedValue(null)
+    expect(await getAuthContextDetailed()).toEqual({ status: 'unauthenticated' })
+  })
 
-  it("returns no-organization when session exists but no membership and not super admin", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockGetCachedMembership.mockResolvedValue(null);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    expect(await getAuthContextDetailed()).toEqual({ status: "no-organization" });
-  });
+  it('returns no-organization when session exists but no membership and not super admin', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockGetCachedMembership.mockResolvedValue(null)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    expect(await getAuthContextDetailed()).toEqual({ status: 'no-organization' })
+  })
 
-  it("returns ok with context when membership is present", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
-    const result = await getAuthContextDetailed();
+  it('returns ok with context when membership is present', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
+    const result = await getAuthContextDetailed()
     expect(result).toEqual({
-      status: "ok",
+      status: 'ok',
       context: {
-        userId: "user-1",
-        organizationId: "org-1",
-        role: "member",
+        userId: 'user-1',
+        organizationId: 'org-1',
+        role: 'member',
         isSuperAdmin: false,
         isAdmin: false,
       },
-    });
-  });
+    })
+  })
 
-  it("super admin with no membership returns no-organization", async () => {
-    mockGetCachedSession.mockResolvedValue(SESSION as any);
-    mockGetCachedMembership.mockResolvedValue(null);
-    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any);
-    const result = await getAuthContextDetailed();
-    expect(result).toEqual({ status: "no-organization" });
-  });
-});
+  it('super admin with no membership returns no-organization', async () => {
+    mockGetCachedSession.mockResolvedValue(SESSION as any)
+    mockGetCachedMembership.mockResolvedValue(null)
+    mockFindUnique.mockResolvedValue({ isSuperAdmin: true } as any)
+    const result = await getAuthContextDetailed()
+    expect(result).toEqual({ status: 'no-organization' })
+  })
+})

+ 170 - 176
src/__tests__/lib/license-expiry.test.ts

@@ -1,7 +1,7 @@
-import { vi, describe, it, expect, beforeEach } from "vitest";
+import { vi, describe, it, expect, beforeEach } from 'vitest'
 
 // Mock dependencies
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     appSetting: {
       findUnique: vi.fn(),
@@ -13,57 +13,54 @@ vi.mock("@/lib/db", () => ({
     },
     $transaction: vi.fn(),
   },
-}));
+}))
 
-vi.mock("@/lib/email", () => ({
+vi.mock('@/lib/email', () => ({
   sendOrgMail: vi.fn().mockResolvedValue(undefined),
-  getOrgFromAddress: vi.fn().mockResolvedValue("noreply@test.com"),
-}));
+  getOrgFromAddress: vi.fn().mockResolvedValue('noreply@test.com'),
+}))
 
-vi.mock("@/lib/notify", () => ({
+vi.mock('@/lib/notify', () => ({
   notify: vi.fn().mockResolvedValue(undefined),
-}));
+}))
 
-vi.mock("@/lib/notification-bus", () => ({
+vi.mock('@/lib/notification-bus', () => ({
   notificationBus: { emit: vi.fn() },
-}));
+}))
 
-import { db } from "@/lib/db";
-import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
-import { notify } from "@/lib/notify";
-import {
-  revalidateOrganizationLicense,
-  sendExpiryWarning,
-} from "@/lib/cron/check-licenses";
+import { db } from '@/lib/db'
+import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
+import { notify } from '@/lib/notify'
+import { revalidateOrganizationLicense, sendExpiryWarning } from '@/lib/cron/check-licenses'
 
-const ORG_ID = "org-test-1";
-const USER_ID = "user-test-1";
-const LICENSE_KEY = "test-license-key";
+const ORG_ID = 'org-test-1'
+const USER_ID = 'user-test-1'
+const LICENSE_KEY = 'test-license-key'
 
 function daysFromNow(days: number): string {
-  const d = new Date();
-  d.setDate(d.getDate() + days);
-  return d.toISOString();
+  const d = new Date()
+  d.setDate(d.getDate() + days)
+  return d.toISOString()
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
+  vi.resetAllMocks()
   vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
     userId: USER_ID,
-  } as any);
-  vi.mocked(db.$transaction).mockResolvedValue(undefined);
-  vi.mocked(db.appSetting.upsert).mockResolvedValue({} as any);
-});
-
-describe("sendExpiryWarning", () => {
-  it("sends notification and email when no warning sent today", async () => {
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+  } as any)
+  vi.mocked(db.$transaction).mockResolvedValue(undefined)
+  vi.mocked(db.appSetting.upsert).mockResolvedValue({} as any)
+})
+
+describe('sendExpiryWarning', () => {
+  it('sends notification and email when no warning sent today', async () => {
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await sendExpiryWarning(ORG_ID, 7);
+    await sendExpiryWarning(ORG_ID, 7)
 
     // Records today's warning
     expect(db.appSetting.upsert).toHaveBeenCalledWith(
@@ -71,265 +68,262 @@ describe("sendExpiryWarning", () => {
         where: {
           organizationId_key: {
             organizationId: ORG_ID,
-            key: "license.lastExpiryWarning",
+            key: 'license.lastExpiryWarning',
           },
         },
       })
-    );
+    )
 
     // Sends in-app notification
     expect(notify).toHaveBeenCalledWith(
       expect.objectContaining({
-        type: "license_expiring",
-        title: "License expires in 7 days",
+        type: 'license_expiring',
+        title: 'License expires in 7 days',
         organizationId: ORG_ID,
-        entityUrl: "/settings/license",
+        entityUrl: '/settings/license',
       })
-    );
+    )
 
     // Sends email
-    expect(getOrgFromAddress).toHaveBeenCalledWith(ORG_ID);
+    expect(getOrgFromAddress).toHaveBeenCalledWith(ORG_ID)
     expect(sendOrgMail).toHaveBeenCalledWith(
       ORG_ID,
       expect.objectContaining({
-        to: "owner@test.com",
-        subject: "Your license expires in 7 days",
+        to: 'owner@test.com',
+        subject: 'Your license expires in 7 days',
       })
-    );
-  });
+    )
+  })
 
   it("uses singular 'day' when 1 day left", async () => {
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await sendExpiryWarning(ORG_ID, 1);
+    await sendExpiryWarning(ORG_ID, 1)
 
     expect(notify).toHaveBeenCalledWith(
       expect.objectContaining({
-        title: "License expires in 1 day",
+        title: 'License expires in 1 day',
       })
-    );
+    )
     expect(sendOrgMail).toHaveBeenCalledWith(
       ORG_ID,
       expect.objectContaining({
-        subject: "Your license expires in 1 day",
+        subject: 'Your license expires in 1 day',
       })
-    );
-  });
+    )
+  })
 
-  it("skips if already warned today", async () => {
-    const today = new Date().toISOString().slice(0, 10);
+  it('skips if already warned today', async () => {
+    const today = new Date().toISOString().slice(0, 10)
     vi.mocked(db.appSetting.findUnique).mockResolvedValue({
       value: today,
-    } as any);
+    } as any)
 
-    await sendExpiryWarning(ORG_ID, 7);
+    await sendExpiryWarning(ORG_ID, 7)
 
-    expect(notify).not.toHaveBeenCalled();
-    expect(sendOrgMail).not.toHaveBeenCalled();
-  });
+    expect(notify).not.toHaveBeenCalled()
+    expect(sendOrgMail).not.toHaveBeenCalled()
+  })
 
-  it("sends again on a new day", async () => {
+  it('sends again on a new day', async () => {
     vi.mocked(db.appSetting.findUnique).mockResolvedValue({
-      value: "2020-01-01",
-    } as any);
+      value: '2020-01-01',
+    } as any)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await sendExpiryWarning(ORG_ID, 5);
+    await sendExpiryWarning(ORG_ID, 5)
 
-    expect(notify).toHaveBeenCalled();
-    expect(sendOrgMail).toHaveBeenCalled();
-  });
+    expect(notify).toHaveBeenCalled()
+    expect(sendOrgMail).toHaveBeenCalled()
+  })
 
-  it("skips email if no org member found", async () => {
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
+  it('skips email if no org member found', async () => {
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
 
-    await sendExpiryWarning(ORG_ID, 7);
+    await sendExpiryWarning(ORG_ID, 7)
 
-    expect(notify).not.toHaveBeenCalled();
-    expect(sendOrgMail).not.toHaveBeenCalled();
-  });
+    expect(notify).not.toHaveBeenCalled()
+    expect(sendOrgMail).not.toHaveBeenCalled()
+  })
 
-  it("skips email if owner has no email", async () => {
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+  it('skips email if owner has no email', async () => {
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     // First call: findFirst for orgMember (to record warning)
     // Second call: findFirst for owner (to send email)
     vi.mocked(db.organizationMember.findFirst)
       .mockResolvedValueOnce({ userId: USER_ID } as any)
-      .mockResolvedValueOnce({ user: { email: null } } as any);
+      .mockResolvedValueOnce({ user: { email: null } } as any)
 
-    await sendExpiryWarning(ORG_ID, 7);
+    await sendExpiryWarning(ORG_ID, 7)
 
     // Notification is still sent
-    expect(notify).toHaveBeenCalled();
+    expect(notify).toHaveBeenCalled()
     // Email is skipped
-    expect(sendOrgMail).not.toHaveBeenCalled();
-  });
+    expect(sendOrgMail).not.toHaveBeenCalled()
+  })
 
-  it("does not throw if email sending fails", async () => {
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+  it('does not throw if email sending fails', async () => {
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
-    vi.mocked(sendOrgMail).mockRejectedValue(new Error("SMTP down"));
+      user: { email: 'owner@test.com' },
+    } as any)
+    vi.mocked(sendOrgMail).mockRejectedValue(new Error('SMTP down'))
 
     // Should not throw
-    await expect(sendExpiryWarning(ORG_ID, 3)).resolves.not.toThrow();
-    expect(notify).toHaveBeenCalled();
-  });
-});
-
-describe("revalidateOrganizationLicense", () => {
-  function mockFetch(response: {
-    ok: boolean;
-    data?: Record<string, unknown>;
-  }) {
+    await expect(sendExpiryWarning(ORG_ID, 3)).resolves.not.toThrow()
+    expect(notify).toHaveBeenCalled()
+  })
+})
+
+describe('revalidateOrganizationLicense', () => {
+  function mockFetch(response: { ok: boolean; data?: Record<string, unknown> }) {
     global.fetch = vi.fn().mockResolvedValue({
       ok: response.ok,
       json: () => Promise.resolve(response.data || {}),
-    });
+    })
   }
 
-  it("stores license data and sends expiry warning when within 14 days", async () => {
-    const expiresAt = daysFromNow(10);
+  it('stores license data and sends expiry warning when within 14 days', async () => {
+    const expiresAt = daysFromNow(10)
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt },
-    });
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+      data: { valid: true, plan: 'pro', expiresAt },
+    })
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
     // Should store license settings
-    expect(db.$transaction).toHaveBeenCalled();
+    expect(db.$transaction).toHaveBeenCalled()
 
     // Should trigger expiry warning
     expect(notify).toHaveBeenCalledWith(
       expect.objectContaining({
-        type: "license_expiring",
+        type: 'license_expiring',
         organizationId: ORG_ID,
       })
-    );
-  });
+    )
+  })
 
-  it("does not send warning when expiry is more than 14 days away", async () => {
-    const expiresAt = daysFromNow(30);
+  it('does not send warning when expiry is more than 14 days away', async () => {
+    const expiresAt = daysFromNow(30)
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt },
-    });
+      data: { valid: true, plan: 'pro', expiresAt },
+    })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(db.$transaction).toHaveBeenCalled();
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(db.$transaction).toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("does not send warning when license is invalid", async () => {
-    const expiresAt = daysFromNow(5);
+  it('does not send warning when license is invalid', async () => {
+    const expiresAt = daysFromNow(5)
     mockFetch({
       ok: true,
       data: { valid: false, expiresAt },
-    });
+    })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("does not send warning when license is already expired", async () => {
-    const expiresAt = daysFromNow(-1);
+  it('does not send warning when license is already expired', async () => {
+    const expiresAt = daysFromNow(-1)
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt },
-    });
+      data: { valid: true, plan: 'pro', expiresAt },
+    })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("does not send warning when no expiresAt", async () => {
+  it('does not send warning when no expiresAt', async () => {
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro" },
-    });
+      data: { valid: true, plan: 'pro' },
+    })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("handles API failure gracefully", async () => {
-    mockFetch({ ok: false });
+  it('handles API failure gracefully', async () => {
+    mockFetch({ ok: false })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
     // Should still store the result (valid=false)
-    expect(db.$transaction).toHaveBeenCalled();
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(db.$transaction).toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("skips if no org member found", async () => {
+  it('skips if no org member found', async () => {
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt: daysFromNow(5) },
-    });
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
+      data: { valid: true, plan: 'pro', expiresAt: daysFromNow(5) },
+    })
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(db.$transaction).not.toHaveBeenCalled();
-    expect(notify).not.toHaveBeenCalled();
-  });
+    expect(db.$transaction).not.toHaveBeenCalled()
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-  it("sends warning at exactly 14 days", async () => {
-    const expiresAt = daysFromNow(14);
+  it('sends warning at exactly 14 days', async () => {
+    const expiresAt = daysFromNow(14)
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt },
-    });
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+      data: { valid: true, plan: 'pro', expiresAt },
+    })
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(notify).toHaveBeenCalled();
-  });
+    expect(notify).toHaveBeenCalled()
+  })
 
-  it("sends warning at exactly 1 day", async () => {
-    const expiresAt = daysFromNow(1);
+  it('sends warning at exactly 1 day', async () => {
+    const expiresAt = daysFromNow(1)
     mockFetch({
       ok: true,
-      data: { valid: true, plan: "pro", expiresAt },
-    });
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+      data: { valid: true, plan: 'pro', expiresAt },
+    })
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
-      user: { email: "owner@test.com" },
-    } as any);
+      user: { email: 'owner@test.com' },
+    } as any)
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY);
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
     expect(notify).toHaveBeenCalledWith(
       expect.objectContaining({
-        title: "License expires in 1 day",
+        title: 'License expires in 1 day',
       })
-    );
-  });
-});
+    )
+  })
+})

+ 44 - 44
src/__tests__/lib/permissions.test.ts

@@ -1,58 +1,58 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect } from 'vitest'
 import {
   hasPermission,
   hasAllPermissions,
   PermissionAction,
   PermissionSubject,
-} from "@/lib/permissions";
+} from '@/lib/permissions'
 
-describe("hasPermission", () => {
-  const read_quotes = { action: PermissionAction.READ, subject: PermissionSubject.QUOTES };
-  const create_quotes = { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES };
-  const read_vehicles = { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES };
+describe('hasPermission', () => {
+  const read_quotes = { action: PermissionAction.READ, subject: PermissionSubject.QUOTES }
+  const create_quotes = { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES }
+  const read_vehicles = { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }
 
-  it("returns true when permission matches action and subject", () => {
-    expect(hasPermission([read_quotes], read_quotes)).toBe(true);
-  });
+  it('returns true when permission matches action and subject', () => {
+    expect(hasPermission([read_quotes], read_quotes)).toBe(true)
+  })
 
-  it("returns false when action does not match", () => {
-    expect(hasPermission([read_quotes], create_quotes)).toBe(false);
-  });
+  it('returns false when action does not match', () => {
+    expect(hasPermission([read_quotes], create_quotes)).toBe(false)
+  })
 
-  it("returns false when subject does not match", () => {
-    expect(hasPermission([read_quotes], read_vehicles)).toBe(false);
-  });
+  it('returns false when subject does not match', () => {
+    expect(hasPermission([read_quotes], read_vehicles)).toBe(false)
+  })
 
-  it("returns false for empty permissions array", () => {
-    expect(hasPermission([], read_quotes)).toBe(false);
-  });
+  it('returns false for empty permissions array', () => {
+    expect(hasPermission([], read_quotes)).toBe(false)
+  })
 
-  it("returns true when the matching permission is among multiple", () => {
-    expect(hasPermission([create_quotes, read_vehicles, read_quotes], read_quotes)).toBe(true);
-  });
-});
+  it('returns true when the matching permission is among multiple', () => {
+    expect(hasPermission([create_quotes, read_vehicles, read_quotes], read_quotes)).toBe(true)
+  })
+})
 
-describe("hasAllPermissions", () => {
-  const read_quotes = { action: PermissionAction.READ, subject: PermissionSubject.QUOTES };
-  const create_quotes = { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES };
-  const read_vehicles = { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES };
+describe('hasAllPermissions', () => {
+  const read_quotes = { action: PermissionAction.READ, subject: PermissionSubject.QUOTES }
+  const create_quotes = { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES }
+  const read_vehicles = { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }
 
-  it("returns true when all required permissions are present", () => {
+  it('returns true when all required permissions are present', () => {
     expect(
-      hasAllPermissions([read_quotes, create_quotes, read_vehicles], [read_quotes, create_quotes]),
-    ).toBe(true);
-  });
-
-  it("returns false when one required permission is missing", () => {
-    expect(hasAllPermissions([read_quotes], [read_quotes, create_quotes])).toBe(false);
-  });
-
-  it("returns true when required array is empty", () => {
-    expect(hasAllPermissions([], [])).toBe(true);
-    expect(hasAllPermissions([read_quotes], [])).toBe(true);
-  });
-
-  it("returns false when user has no permissions", () => {
-    expect(hasAllPermissions([], [read_quotes])).toBe(false);
-  });
-});
+      hasAllPermissions([read_quotes, create_quotes, read_vehicles], [read_quotes, create_quotes])
+    ).toBe(true)
+  })
+
+  it('returns false when one required permission is missing', () => {
+    expect(hasAllPermissions([read_quotes], [read_quotes, create_quotes])).toBe(false)
+  })
+
+  it('returns true when required array is empty', () => {
+    expect(hasAllPermissions([], [])).toBe(true)
+    expect(hasAllPermissions([read_quotes], [])).toBe(true)
+  })
+
+  it('returns false when user has no permissions', () => {
+    expect(hasAllPermissions([], [read_quotes])).toBe(false)
+  })
+})

+ 48 - 48
src/__tests__/lib/safe-path.test.ts

@@ -1,59 +1,59 @@
-import { describe, it, expect } from "vitest";
-import path from "path";
-import { resolveWithinDir } from "@/lib/safe-path";
-
-const base = path.join(path.sep, "tmp", "import-123");
-
-describe("resolveWithinDir", () => {
-  it("allows legitimate nested archive entries", () => {
-    expect(resolveWithinDir(base, "lubelog_db_backup_x/data/cartracker.db")).toBe(
-      path.join(base, "lubelog_db_backup_x/data/cartracker.db"),
-    );
-    expect(resolveWithinDir(base, "documents/client/file.pdf")).toBe(
-      path.join(base, "documents/client/file.pdf"),
-    );
-    expect(resolveWithinDir(base, "documents/sub/ok.png")).toBe(
-      path.join(base, "documents/sub/ok.png"),
-    );
-  });
-
-  it("allows the base directory itself", () => {
-    expect(resolveWithinDir(base, "")).toBe(base);
-    expect(resolveWithinDir(base, ".")).toBe(base);
-  });
-
-  it("rejects plain parent-directory traversal (zip-slip)", () => {
-    expect(resolveWithinDir(base, "../../../../home/sinamics/evil.js")).toBeNull();
-    expect(resolveWithinDir(base, "..")).toBeNull();
-  });
-
-  it("rejects traversal that hides behind an allowed prefix", () => {
+import { describe, it, expect } from 'vitest'
+import path from 'path'
+import { resolveWithinDir } from '@/lib/safe-path'
+
+const base = path.join(path.sep, 'tmp', 'import-123')
+
+describe('resolveWithinDir', () => {
+  it('allows legitimate nested archive entries', () => {
+    expect(resolveWithinDir(base, 'lubelog_db_backup_x/data/cartracker.db')).toBe(
+      path.join(base, 'lubelog_db_backup_x/data/cartracker.db')
+    )
+    expect(resolveWithinDir(base, 'documents/client/file.pdf')).toBe(
+      path.join(base, 'documents/client/file.pdf')
+    )
+    expect(resolveWithinDir(base, 'documents/sub/ok.png')).toBe(
+      path.join(base, 'documents/sub/ok.png')
+    )
+  })
+
+  it('allows the base directory itself', () => {
+    expect(resolveWithinDir(base, '')).toBe(base)
+    expect(resolveWithinDir(base, '.')).toBe(base)
+  })
+
+  it('rejects plain parent-directory traversal (zip-slip)', () => {
+    expect(resolveWithinDir(base, '../../../../home/sinamics/evil.js')).toBeNull()
+    expect(resolveWithinDir(base, '..')).toBeNull()
+  })
+
+  it('rejects traversal that hides behind an allowed prefix', () => {
     // The Invoice-Ninja `startsWith("documents/")` bypass.
-    expect(resolveWithinDir(base, "documents/../../../../etc/cron.d/evil")).toBeNull();
-  });
+    expect(resolveWithinDir(base, 'documents/../../../../etc/cron.d/evil')).toBeNull()
+  })
 
-  it("rejects absolute-path entries instead of remapping them inside base", () => {
-    expect(resolveWithinDir(base, "/etc/passwd")).toBeNull();
-  });
+  it('rejects absolute-path entries instead of remapping them inside base', () => {
+    expect(resolveWithinDir(base, '/etc/passwd')).toBeNull()
+  })
 
-  it("does not treat a sibling directory with a shared prefix as inside", () => {
+  it('does not treat a sibling directory with a shared prefix as inside', () => {
     // /tmp/import-123 must not accept /tmp/import-123-evil
-    expect(resolveWithinDir(base, "../import-123-evil/x")).toBeNull();
-  });
+    expect(resolveWithinDir(base, '../import-123-evil/x')).toBeNull()
+  })
 
-  it("rejects an absolute input even when it lies within the base dir", () => {
+  it('rejects an absolute input even when it lies within the base dir', () => {
     // The contract is 'relative entries only' — absolute inputs are always null.
-    const inside = path.join(base, "x");
-    expect(resolveWithinDir(base, inside)).toBeNull();
-  });
+    const inside = path.join(base, 'x')
+    expect(resolveWithinDir(base, inside)).toBeNull()
+  })
 
-  it("does not reject everything when base is the filesystem root", () => {
+  it('does not reject everything when base is the filesystem root', () => {
     // Guards the `base + path.sep` === "//" edge: a normal relative entry must
     // still resolve when baseDir is the root.
-    const root = path.sep;
-    expect(resolveWithinDir(root, "srv/app/file")).toBe(path.join(root, "srv/app/file"));
+    const root = path.sep
+    expect(resolveWithinDir(root, 'srv/app/file')).toBe(path.join(root, 'srv/app/file'))
     // (Nothing can climb above the root, so a `..` there stays inside root by
     // definition; the meaningful negative case at root is an absolute input.)
-    expect(resolveWithinDir(root, path.join(root, "etc"))).toBeNull();
-  });
-});
+    expect(resolveWithinDir(root, path.join(root, 'etc'))).toBeNull()
+  })
+})

+ 107 - 107
src/__tests__/lib/stripe-config.test.ts

@@ -1,129 +1,129 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     systemSetting: { findMany: vi.fn() },
   },
-}));
+}))
 
-import { db } from "@/lib/db";
-import { getStripeConfig } from "@/lib/stripe-config";
+import { db } from '@/lib/db'
+import { getStripeConfig } from '@/lib/stripe-config'
 
-const mockFindMany = vi.mocked(db.systemSetting.findMany);
+const mockFindMany = vi.mocked(db.systemSetting.findMany)
 
 beforeEach(() => {
-  vi.resetAllMocks();
-  vi.unstubAllEnvs();
-});
+  vi.resetAllMocks()
+  vi.unstubAllEnvs()
+})
 
-describe("getStripeConfig", () => {
-  it("reads test mode keys from DB when mode is test", async () => {
+describe('getStripeConfig', () => {
+  it('reads test mode keys from DB when mode is test', async () => {
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.mode", value: "test" },
-      { id: "2", key: "stripe.test.secretKey", value: "sk_test_db" },
-      { id: "3", key: "stripe.test.webhookSecret", value: "whsec_test_db" },
-      { id: "4", key: "stripe.test.proPriceId", value: "price_pro_test" },
-      { id: "5", key: "stripe.test.enterprisePriceId", value: "price_ent_test" },
-    ] as any);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_test_db");
-    expect(config.webhookSecret).toBe("whsec_test_db");
-    expect(config.proPriceId).toBe("price_pro_test");
-    expect(config.enterprisePriceId).toBe("price_ent_test");
-  });
-
-  it("reads live mode keys from DB when mode is live", async () => {
+      { id: '1', key: 'stripe.mode', value: 'test' },
+      { id: '2', key: 'stripe.test.secretKey', value: 'sk_test_db' },
+      { id: '3', key: 'stripe.test.webhookSecret', value: 'whsec_test_db' },
+      { id: '4', key: 'stripe.test.proPriceId', value: 'price_pro_test' },
+      { id: '5', key: 'stripe.test.enterprisePriceId', value: 'price_ent_test' },
+    ] as any)
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_test_db')
+    expect(config.webhookSecret).toBe('whsec_test_db')
+    expect(config.proPriceId).toBe('price_pro_test')
+    expect(config.enterprisePriceId).toBe('price_ent_test')
+  })
+
+  it('reads live mode keys from DB when mode is live', async () => {
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.mode", value: "live" },
-      { id: "2", key: "stripe.live.secretKey", value: "sk_live_db" },
-      { id: "3", key: "stripe.live.webhookSecret", value: "whsec_live_db" },
-      { id: "4", key: "stripe.live.proPriceId", value: "price_pro_live" },
-      { id: "5", key: "stripe.live.enterprisePriceId", value: "price_ent_live" },
-    ] as any);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_live_db");
-    expect(config.webhookSecret).toBe("whsec_live_db");
-    expect(config.proPriceId).toBe("price_pro_live");
-    expect(config.enterprisePriceId).toBe("price_ent_live");
-  });
-
-  it("falls back to env vars when DB values are empty", async () => {
-    vi.stubEnv("STRIPE_SECRET_KEY", "sk_env");
-    vi.stubEnv("STRIPE_WEBHOOK_SECRET", "whsec_env");
-    vi.stubEnv("STRIPE_PRO_PRICE_ID", "price_pro_env");
-    vi.stubEnv("STRIPE_ENTERPRISE_PRICE_ID", "price_ent_env");
-
-    mockFindMany.mockResolvedValue([]);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_env");
-    expect(config.webhookSecret).toBe("whsec_env");
-    expect(config.proPriceId).toBe("price_pro_env");
-    expect(config.enterprisePriceId).toBe("price_ent_env");
-  });
-
-  it("defaults to live mode when no mode is set", async () => {
+      { id: '1', key: 'stripe.mode', value: 'live' },
+      { id: '2', key: 'stripe.live.secretKey', value: 'sk_live_db' },
+      { id: '3', key: 'stripe.live.webhookSecret', value: 'whsec_live_db' },
+      { id: '4', key: 'stripe.live.proPriceId', value: 'price_pro_live' },
+      { id: '5', key: 'stripe.live.enterprisePriceId', value: 'price_ent_live' },
+    ] as any)
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_live_db')
+    expect(config.webhookSecret).toBe('whsec_live_db')
+    expect(config.proPriceId).toBe('price_pro_live')
+    expect(config.enterprisePriceId).toBe('price_ent_live')
+  })
+
+  it('falls back to env vars when DB values are empty', async () => {
+    vi.stubEnv('STRIPE_SECRET_KEY', 'sk_env')
+    vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_env')
+    vi.stubEnv('STRIPE_PRO_PRICE_ID', 'price_pro_env')
+    vi.stubEnv('STRIPE_ENTERPRISE_PRICE_ID', 'price_ent_env')
+
+    mockFindMany.mockResolvedValue([])
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_env')
+    expect(config.webhookSecret).toBe('whsec_env')
+    expect(config.proPriceId).toBe('price_pro_env')
+    expect(config.enterprisePriceId).toBe('price_ent_env')
+  })
+
+  it('defaults to live mode when no mode is set', async () => {
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.live.secretKey", value: "sk_live_default" },
-      { id: "2", key: "stripe.test.secretKey", value: "sk_test_should_not_use" },
-    ] as any);
+      { id: '1', key: 'stripe.live.secretKey', value: 'sk_live_default' },
+      { id: '2', key: 'stripe.test.secretKey', value: 'sk_test_should_not_use' },
+    ] as any)
 
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_live_default");
-  });
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_live_default')
+  })
 
-  it("uses STRIPE_MODE env var when no DB mode is set", async () => {
-    vi.stubEnv("STRIPE_MODE", "test");
+  it('uses STRIPE_MODE env var when no DB mode is set', async () => {
+    vi.stubEnv('STRIPE_MODE', 'test')
 
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.test.secretKey", value: "sk_test_via_env_mode" },
-      { id: "2", key: "stripe.live.secretKey", value: "sk_live_should_not_use" },
-    ] as any);
+      { id: '1', key: 'stripe.test.secretKey', value: 'sk_test_via_env_mode' },
+      { id: '2', key: 'stripe.live.secretKey', value: 'sk_live_should_not_use' },
+    ] as any)
 
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_test_via_env_mode");
-  });
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_test_via_env_mode')
+  })
 
-  it("DB mode takes priority over STRIPE_MODE env var", async () => {
-    vi.stubEnv("STRIPE_MODE", "test");
+  it('DB mode takes priority over STRIPE_MODE env var', async () => {
+    vi.stubEnv('STRIPE_MODE', 'test')
 
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.mode", value: "live" },
-      { id: "2", key: "stripe.test.secretKey", value: "sk_test_no" },
-      { id: "3", key: "stripe.live.secretKey", value: "sk_live_yes" },
-    ] as any);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_live_yes");
-  });
-
-  it("returns empty strings when nothing is configured", async () => {
-    vi.stubEnv("STRIPE_SECRET_KEY", "");
-    vi.stubEnv("STRIPE_WEBHOOK_SECRET", "");
-    vi.stubEnv("STRIPE_PRO_PRICE_ID", "");
-    vi.stubEnv("STRIPE_ENTERPRISE_PRICE_ID", "");
-    vi.stubEnv("STRIPE_MODE", "");
-
-    mockFindMany.mockResolvedValue([]);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("");
-    expect(config.webhookSecret).toBe("");
-    expect(config.proPriceId).toBe("");
-    expect(config.enterprisePriceId).toBe("");
-  });
-
-  it("ignores DB rows with empty values and falls back to env", async () => {
-    vi.stubEnv("STRIPE_SECRET_KEY", "sk_fallback");
+      { id: '1', key: 'stripe.mode', value: 'live' },
+      { id: '2', key: 'stripe.test.secretKey', value: 'sk_test_no' },
+      { id: '3', key: 'stripe.live.secretKey', value: 'sk_live_yes' },
+    ] as any)
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_live_yes')
+  })
+
+  it('returns empty strings when nothing is configured', async () => {
+    vi.stubEnv('STRIPE_SECRET_KEY', '')
+    vi.stubEnv('STRIPE_WEBHOOK_SECRET', '')
+    vi.stubEnv('STRIPE_PRO_PRICE_ID', '')
+    vi.stubEnv('STRIPE_ENTERPRISE_PRICE_ID', '')
+    vi.stubEnv('STRIPE_MODE', '')
+
+    mockFindMany.mockResolvedValue([])
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('')
+    expect(config.webhookSecret).toBe('')
+    expect(config.proPriceId).toBe('')
+    expect(config.enterprisePriceId).toBe('')
+  })
+
+  it('ignores DB rows with empty values and falls back to env', async () => {
+    vi.stubEnv('STRIPE_SECRET_KEY', 'sk_fallback')
 
     mockFindMany.mockResolvedValue([
-      { id: "1", key: "stripe.mode", value: "test" },
-      { id: "2", key: "stripe.test.secretKey", value: "" },
-    ] as any);
-
-    const config = await getStripeConfig();
-    expect(config.secretKey).toBe("sk_fallback");
-  });
-});
+      { id: '1', key: 'stripe.mode', value: 'test' },
+      { id: '2', key: 'stripe.test.secretKey', value: '' },
+    ] as any)
+
+    const config = await getStripeConfig()
+    expect(config.secretKey).toBe('sk_fallback')
+  })
+})

+ 54 - 58
src/__tests__/lib/support.test.ts

@@ -8,88 +8,84 @@
  * relationship with that workshop.
  */
 
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     systemSetting: { findUnique: vi.fn() },
   },
-}));
+}))
 
-import { db } from "@/lib/db";
-import {
-  DEFAULT_SUPPORT_RECIPIENT,
-  getSupportRecipient,
-  isSupportEnabled,
-} from "@/lib/support";
+import { db } from '@/lib/db'
+import { DEFAULT_SUPPORT_RECIPIENT, getSupportRecipient, isSupportEnabled } from '@/lib/support'
 
-const mockFindUnique = vi.mocked(db.systemSetting.findUnique);
+const mockFindUnique = vi.mocked(db.systemSetting.findUnique)
 
 beforeEach(() => {
-  vi.resetAllMocks();
-  vi.unstubAllEnvs();
-});
+  vi.resetAllMocks()
+  vi.unstubAllEnvs()
+})
 
-describe("isSupportEnabled", () => {
-  it("is off on a self-hosted install even when the flag is set", async () => {
-    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
+describe('isSupportEnabled', () => {
+  it('is off on a self-hosted install even when the flag is set', async () => {
+    vi.stubEnv('TORQVOICE_MODE', 'self-hosted')
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mockFindUnique.mockResolvedValue({ value: "true" } as any);
-    expect(await isSupportEnabled()).toBe(false);
-  });
+    mockFindUnique.mockResolvedValue({ value: 'true' } as any)
+    expect(await isSupportEnabled()).toBe(false)
+  })
 
-  it("is off when no mode is configured at all", async () => {
+  it('is off when no mode is configured at all', async () => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mockFindUnique.mockResolvedValue({ value: "true" } as any);
-    expect(await isSupportEnabled()).toBe(false);
-  });
+    mockFindUnique.mockResolvedValue({ value: 'true' } as any)
+    expect(await isSupportEnabled()).toBe(false)
+  })
 
-  it("does not even read the setting outside cloud mode", async () => {
-    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
-    await isSupportEnabled();
-    expect(mockFindUnique).not.toHaveBeenCalled();
-  });
+  it('does not even read the setting outside cloud mode', async () => {
+    vi.stubEnv('TORQVOICE_MODE', 'self-hosted')
+    await isSupportEnabled()
+    expect(mockFindUnique).not.toHaveBeenCalled()
+  })
 
-  it("is off in cloud mode until someone turns it on", async () => {
-    vi.stubEnv("TORQVOICE_MODE", "cloud");
-    mockFindUnique.mockResolvedValue(null);
-    expect(await isSupportEnabled()).toBe(false);
-  });
+  it('is off in cloud mode until someone turns it on', async () => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    mockFindUnique.mockResolvedValue(null)
+    expect(await isSupportEnabled()).toBe(false)
+  })
 
   it("is on only in cloud mode with the flag set to exactly 'true'", async () => {
-    vi.stubEnv("TORQVOICE_MODE", "cloud");
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mockFindUnique.mockResolvedValue({ value: "true" } as any);
-    expect(await isSupportEnabled()).toBe(true);
-  });
+    mockFindUnique.mockResolvedValue({ value: 'true' } as any)
+    expect(await isSupportEnabled()).toBe(true)
+  })
 
-  it("treats any other stored value as off", async () => {
-    vi.stubEnv("TORQVOICE_MODE", "cloud");
-    for (const value of ["false", "TRUE", "1", "yes", "", " true "]) {
+  it('treats any other stored value as off', async () => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    for (const value of ['false', 'TRUE', '1', 'yes', '', ' true ']) {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      mockFindUnique.mockResolvedValue({ value } as any);
-      expect(await isSupportEnabled()).toBe(false);
+      mockFindUnique.mockResolvedValue({ value } as any)
+      expect(await isSupportEnabled()).toBe(false)
     }
-  });
-});
+  })
+})
 
-describe("getSupportRecipient", () => {
-  it("falls back to the published address when nothing is configured", async () => {
-    mockFindUnique.mockResolvedValue(null);
-    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT);
-  });
+describe('getSupportRecipient', () => {
+  it('falls back to the published address when nothing is configured', async () => {
+    mockFindUnique.mockResolvedValue(null)
+    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT)
+  })
 
-  it("falls back when the stored value is blank", async () => {
+  it('falls back when the stored value is blank', async () => {
     // A cleared field in the admin form saves an empty string rather than
     // deleting the row, and mail to "" goes nowhere.
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mockFindUnique.mockResolvedValue({ value: "   " } as any);
-    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT);
-  });
+    mockFindUnique.mockResolvedValue({ value: '   ' } as any)
+    expect(await getSupportRecipient()).toBe(DEFAULT_SUPPORT_RECIPIENT)
+  })
 
-  it("uses the configured address, trimmed", async () => {
+  it('uses the configured address, trimmed', async () => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mockFindUnique.mockResolvedValue({ value: " desk@example.com " } as any);
-    expect(await getSupportRecipient()).toBe("desk@example.com");
-  });
-});
+    mockFindUnique.mockResolvedValue({ value: ' desk@example.com ' } as any)
+    expect(await getSupportRecipient()).toBe('desk@example.com')
+  })
+})

+ 265 - 240
src/__tests__/lib/tax.test.ts

@@ -1,314 +1,314 @@
-import { describe, it, expect } from "vitest";
-import { calculateTotals, netLineTotal } from "@/lib/tax";
+import { describe, it, expect } from 'vitest'
+import { calculateTotals, netLineTotal } from '@/lib/tax'
 
-describe("calculateTotals", () => {
-  describe("exclusive mode", () => {
-    it("adds tax on top of subtotal", () => {
+describe('calculateTotals', () => {
+  describe('exclusive mode', () => {
+    it('adds tax on top of subtotal', () => {
       const r = calculateTotals({
         subtotal: 100,
         discountAmount: 0,
         taxRate: 10,
         taxInclusive: false,
-      });
-      expect(r.taxAmount).toBeCloseTo(10);
-      expect(r.totalAmount).toBeCloseTo(110);
-    });
+      })
+      expect(r.taxAmount).toBeCloseTo(10)
+      expect(r.totalAmount).toBeCloseTo(110)
+    })
 
-    it("applies discount before tax", () => {
+    it('applies discount before tax', () => {
       const r = calculateTotals({
         subtotal: 100,
         discountAmount: 20,
         taxRate: 10,
         taxInclusive: false,
-      });
+      })
       // base = 80, tax = 8, total = 88
-      expect(r.taxAmount).toBeCloseTo(8);
-      expect(r.totalAmount).toBeCloseTo(88);
-    });
+      expect(r.taxAmount).toBeCloseTo(8)
+      expect(r.totalAmount).toBeCloseTo(88)
+    })
 
-    it("returns zero tax when rate is zero", () => {
+    it('returns zero tax when rate is zero', () => {
       const r = calculateTotals({
         subtotal: 100,
         discountAmount: 0,
         taxRate: 0,
         taxInclusive: false,
-      });
-      expect(r.taxAmount).toBe(0);
-      expect(r.totalAmount).toBe(100);
-    });
-  });
-
-  describe("inclusive mode", () => {
-    it("reverse-calculates the net from a gross subtotal", () => {
+      })
+      expect(r.taxAmount).toBe(0)
+      expect(r.totalAmount).toBe(100)
+    })
+  })
+
+  describe('inclusive mode', () => {
+    it('reverse-calculates the net from a gross subtotal', () => {
       const r = calculateTotals({
         subtotal: 110,
         discountAmount: 0,
         taxRate: 10,
         taxInclusive: true,
-      });
+      })
       // gross = 110, net = 100, tax = 10
-      expect(r.taxAmount).toBeCloseTo(10);
-      expect(r.totalAmount).toBeCloseTo(110);
-    });
+      expect(r.taxAmount).toBeCloseTo(10)
+      expect(r.totalAmount).toBeCloseTo(110)
+    })
 
-    it("totalAmount equals gross subtotal minus discount in inclusive mode", () => {
+    it('totalAmount equals gross subtotal minus discount in inclusive mode', () => {
       const r = calculateTotals({
         subtotal: 110,
         discountAmount: 11,
         taxRate: 10,
         taxInclusive: true,
-      });
+      })
       // base (gross after discount) = 99, net = 90, tax = 9, total = 99
-      expect(r.taxAmount).toBeCloseTo(9);
-      expect(r.totalAmount).toBeCloseTo(99);
-    });
+      expect(r.taxAmount).toBeCloseTo(9)
+      expect(r.totalAmount).toBeCloseTo(99)
+    })
 
-    it("returns zero tax when rate is zero (inclusive)", () => {
+    it('returns zero tax when rate is zero (inclusive)', () => {
       const r = calculateTotals({
         subtotal: 100,
         discountAmount: 0,
         taxRate: 0,
         taxInclusive: true,
-      });
-      expect(r.taxAmount).toBe(0);
-      expect(r.totalAmount).toBe(100);
-    });
+      })
+      expect(r.taxAmount).toBe(0)
+      expect(r.totalAmount).toBe(100)
+    })
 
-    it("handles 25% VAT correctly (Norwegian rate)", () => {
+    it('handles 25% VAT correctly (Norwegian rate)', () => {
       // 125 gross with 25% VAT → 100 net + 25 tax
       const r = calculateTotals({
         subtotal: 125,
         discountAmount: 0,
         taxRate: 25,
         taxInclusive: true,
-      });
-      expect(r.taxAmount).toBeCloseTo(25);
-      expect(r.totalAmount).toBeCloseTo(125);
-    });
-  });
-
-  describe("edge cases", () => {
-    it("clamps base at zero when discount exceeds subtotal", () => {
+      })
+      expect(r.taxAmount).toBeCloseTo(25)
+      expect(r.totalAmount).toBeCloseTo(125)
+    })
+  })
+
+  describe('edge cases', () => {
+    it('clamps base at zero when discount exceeds subtotal', () => {
       const r = calculateTotals({
         subtotal: 50,
         discountAmount: 100,
         taxRate: 10,
         taxInclusive: false,
-      });
-      expect(r.taxAmount).toBe(0);
-      expect(r.totalAmount).toBe(0);
-    });
+      })
+      expect(r.taxAmount).toBe(0)
+      expect(r.totalAmount).toBe(0)
+    })
 
-    it("inclusive mode with discount: total equals gross-after-discount", () => {
+    it('inclusive mode with discount: total equals gross-after-discount', () => {
       // 2 line items at $50 gross each, $10 gross discount, 10% tax
       const r = calculateTotals({
         subtotal: 100,
         discountAmount: 10,
         taxRate: 10,
         taxInclusive: true,
-      });
+      })
       // base (gross after discount) = 90, net = 81.82, tax = 8.18, total = 90
-      expect(r.totalAmount).toBeCloseTo(90);
-      expect(r.taxAmount).toBeCloseTo(8.1818, 3);
-    });
+      expect(r.totalAmount).toBeCloseTo(90)
+      expect(r.taxAmount).toBeCloseTo(8.1818, 3)
+    })
 
-    it("net base equals totalAmount - taxAmount in both modes", () => {
+    it('net base equals totalAmount - taxAmount in both modes', () => {
       const exclusive = calculateTotals({
         subtotal: 200,
         discountAmount: 20,
         taxRate: 25,
         taxInclusive: false,
-      });
+      })
       const inclusive = calculateTotals({
         subtotal: 225,
         discountAmount: 25,
         taxRate: 25,
         taxInclusive: true,
-      });
+      })
       // The "taxable base" formula used by the tax report:
-      const exclusiveBase = exclusive.totalAmount - exclusive.taxAmount;
-      const inclusiveBase = inclusive.totalAmount - inclusive.taxAmount;
-      expect(exclusiveBase).toBeCloseTo(180); // 200 - 20
-      expect(inclusiveBase).toBeCloseTo(160); // (225 - 25) / 1.25
-    });
-  });
-});
-
-describe("convert exclusive → inclusive (round-trip)", () => {
+      const exclusiveBase = exclusive.totalAmount - exclusive.taxAmount
+      const inclusiveBase = inclusive.totalAmount - inclusive.taxAmount
+      expect(exclusiveBase).toBeCloseTo(180) // 200 - 20
+      expect(inclusiveBase).toBeCloseTo(160) // (225 - 25) / 1.25
+    })
+  })
+})
+
+describe('convert exclusive → inclusive (round-trip)', () => {
   // The convertRecordsToInclusive backfill scales line items by (1 + rate/100).
   // After scaling, calculateTotals in inclusive mode must produce the SAME
   // taxAmount and totalAmount that the original exclusive calculation produced
   // — i.e. the customer-facing total is preserved.
 
-  it("preserves totals for an exclusive record with no discount", () => {
-    const taxRate = 25;
+  it('preserves totals for an exclusive record with no discount', () => {
+    const taxRate = 25
     const exclusive = calculateTotals({
       subtotal: 100,
       discountAmount: 0,
       taxRate,
       taxInclusive: false,
-    });
+    })
     // Apply the conversion: scale subtotal by (1 + rate/100)
-    const factor = 1 + taxRate / 100;
+    const factor = 1 + taxRate / 100
     const inclusive = calculateTotals({
       subtotal: 100 * factor,
       discountAmount: 0,
       taxRate,
       taxInclusive: true,
-    });
-    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
-    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
-  });
+    })
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount)
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount)
+  })
 
-  it("preserves totals when converting a record with a fixed discount", () => {
-    const taxRate = 10;
+  it('preserves totals when converting a record with a fixed discount', () => {
+    const taxRate = 10
     const exclusive = calculateTotals({
       subtotal: 100,
       discountAmount: 20,
       taxRate,
       taxInclusive: false,
-    });
+    })
     // Both subtotal AND fixed discountAmount must scale to preserve the math
-    const factor = 1 + taxRate / 100;
+    const factor = 1 + taxRate / 100
     const inclusive = calculateTotals({
       subtotal: 100 * factor,
       discountAmount: 20 * factor,
       taxRate,
       taxInclusive: true,
-    });
-    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
-    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
-  });
+    })
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount)
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount)
+  })
 
-  it("preserves totals for a percentage-discount record", () => {
-    const taxRate = 25;
+  it('preserves totals for a percentage-discount record', () => {
+    const taxRate = 25
     // 10% discount: discountAmount = subtotal * 0.10
     const exclusive = calculateTotals({
       subtotal: 200,
       discountAmount: 20,
       taxRate,
       taxInclusive: false,
-    });
+    })
     // Both scale by the same factor; percentage stays the same so the
     // resulting discountAmount = (200 * factor) * 0.10 = 20 * factor.
-    const factor = 1 + taxRate / 100;
+    const factor = 1 + taxRate / 100
     const inclusive = calculateTotals({
       subtotal: 200 * factor,
       discountAmount: 20 * factor,
       taxRate,
       taxInclusive: true,
-    });
-    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
-    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
-  });
+    })
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount)
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount)
+  })
 
-  it("handles taxRate=0 (factor=1, no scaling needed)", () => {
+  it('handles taxRate=0 (factor=1, no scaling needed)', () => {
     const exclusive = calculateTotals({
       subtotal: 100,
       discountAmount: 0,
       taxRate: 0,
       taxInclusive: false,
-    });
+    })
     const inclusive = calculateTotals({
       subtotal: 100, // factor = 1
       discountAmount: 0,
       taxRate: 0,
       taxInclusive: true,
-    });
-    expect(inclusive.taxAmount).toBe(exclusive.taxAmount);
-    expect(inclusive.totalAmount).toBe(exclusive.totalAmount);
-  });
-});
+    })
+    expect(inclusive.taxAmount).toBe(exclusive.taxAmount)
+    expect(inclusive.totalAmount).toBe(exclusive.totalAmount)
+  })
+})
 
-describe("convert inclusive → exclusive (round-trip)", () => {
+describe('convert inclusive → exclusive (round-trip)', () => {
   // The convertRecordsToExclusive backfill divides line items by (1 + rate/100).
   // After scaling, calculateTotals in exclusive mode must produce the SAME
   // taxAmount and totalAmount the original inclusive record had.
 
-  it("preserves totals for an inclusive record with no discount", () => {
-    const taxRate = 25;
+  it('preserves totals for an inclusive record with no discount', () => {
+    const taxRate = 25
     const inclusive = calculateTotals({
       subtotal: 125,
       discountAmount: 0,
       taxRate,
       taxInclusive: true,
-    });
-    const factor = 1 + taxRate / 100;
+    })
+    const factor = 1 + taxRate / 100
     const exclusive = calculateTotals({
       subtotal: 125 / factor,
       discountAmount: 0,
       taxRate,
       taxInclusive: false,
-    });
-    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
-    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
-  });
+    })
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount)
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount)
+  })
 
-  it("preserves totals when converting an inclusive record with a fixed discount back to exclusive", () => {
-    const taxRate = 10;
+  it('preserves totals when converting an inclusive record with a fixed discount back to exclusive', () => {
+    const taxRate = 10
     const inclusive = calculateTotals({
       subtotal: 110,
       discountAmount: 22,
       taxRate,
       taxInclusive: true,
-    });
-    const factor = 1 + taxRate / 100;
+    })
+    const factor = 1 + taxRate / 100
     const exclusive = calculateTotals({
       subtotal: 110 / factor,
       discountAmount: 22 / factor,
       taxRate,
       taxInclusive: false,
-    });
-    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
-    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
-  });
+    })
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount)
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount)
+  })
 
-  it("preserves totals for an inclusive record with a percentage discount", () => {
-    const taxRate = 25;
+  it('preserves totals for an inclusive record with a percentage discount', () => {
+    const taxRate = 25
     // Inclusive 10% discount on 200 gross subtotal
     const inclusive = calculateTotals({
       subtotal: 200,
       discountAmount: 20,
       taxRate,
       taxInclusive: true,
-    });
-    const factor = 1 + taxRate / 100;
+    })
+    const factor = 1 + taxRate / 100
     const exclusive = calculateTotals({
       subtotal: 200 / factor,
       discountAmount: 20 / factor,
       taxRate,
       taxInclusive: false,
-    });
-    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
-    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
-  });
-});
-
-describe("netLineTotal", () => {
-  it("returns the line total unchanged for exclusive mode", () => {
-    expect(netLineTotal(100, 10, false)).toBe(100);
-    expect(netLineTotal(100, 25, false)).toBe(100);
-  });
-
-  it("returns the line total unchanged when tax rate is zero", () => {
-    expect(netLineTotal(100, 0, true)).toBe(100);
-  });
-
-  it("backs out tax for inclusive mode with positive rate", () => {
-    expect(netLineTotal(110, 10, true)).toBeCloseTo(100);
-    expect(netLineTotal(125, 25, true)).toBeCloseTo(100);
-  });
-
-  it("inclusive net + tax matches the original gross", () => {
-    const gross = 125;
-    const rate = 25;
-    const net = netLineTotal(gross, rate, true);
-    const tax = gross - net;
-    expect(net + tax).toBeCloseTo(gross);
-    expect(net).toBeCloseTo(100);
-    expect(tax).toBeCloseTo(25);
-  });
-});
+    })
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount)
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount)
+  })
+})
+
+describe('netLineTotal', () => {
+  it('returns the line total unchanged for exclusive mode', () => {
+    expect(netLineTotal(100, 10, false)).toBe(100)
+    expect(netLineTotal(100, 25, false)).toBe(100)
+  })
+
+  it('returns the line total unchanged when tax rate is zero', () => {
+    expect(netLineTotal(100, 0, true)).toBe(100)
+  })
+
+  it('backs out tax for inclusive mode with positive rate', () => {
+    expect(netLineTotal(110, 10, true)).toBeCloseTo(100)
+    expect(netLineTotal(125, 25, true)).toBeCloseTo(100)
+  })
+
+  it('inclusive net + tax matches the original gross', () => {
+    const gross = 125
+    const rate = 25
+    const net = netLineTotal(gross, rate, true)
+    const tax = gross - net
+    expect(net + tax).toBeCloseTo(gross)
+    expect(net).toBeCloseTo(100)
+    expect(tax).toBeCloseTo(25)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Universal display formula — used by all PDFs/share views/invoice summary.
@@ -317,15 +317,15 @@ describe("netLineTotal", () => {
 // These tests prove the same formulas balance for both modes and locked their
 // behaviour against future regressions.
 // ---------------------------------------------------------------------------
-describe("universal invoice display formulas", () => {
+describe('universal invoice display formulas', () => {
   function buildDisplay(args: {
-    subtotal: number;
-    discountAmount: number;
-    taxRate: number;
-    taxInclusive: boolean;
+    subtotal: number
+    discountAmount: number
+    taxRate: number
+    taxInclusive: boolean
   }) {
-    const { subtotal, discountAmount, taxRate, taxInclusive } = args;
-    const { taxAmount, totalAmount } = calculateTotals(args);
+    const { subtotal, discountAmount, taxRate, taxInclusive } = args
+    const { taxAmount, totalAmount } = calculateTotals(args)
     return {
       // Per-line and category subtotals are netted for display:
       displaySubtotal: netLineTotal(subtotal, taxRate, taxInclusive),
@@ -333,104 +333,129 @@ describe("universal invoice display formulas", () => {
       // Tax and total come straight from stored values (always correct).
       displayTax: taxAmount,
       displayTotal: totalAmount,
-    };
+    }
   }
 
-  it("exclusive mode: simple invoice", () => {
-    const d = buildDisplay({ subtotal: 100, discountAmount: 0, taxRate: 25, taxInclusive: false });
-    expect(d.displaySubtotal).toBeCloseTo(100);
-    expect(d.displayDiscount).toBeCloseTo(0);
-    expect(d.displayTax).toBeCloseTo(25);
-    expect(d.displayTotal).toBeCloseTo(125);
+  it('exclusive mode: simple invoice', () => {
+    const d = buildDisplay({ subtotal: 100, discountAmount: 0, taxRate: 25, taxInclusive: false })
+    expect(d.displaySubtotal).toBeCloseTo(100)
+    expect(d.displayDiscount).toBeCloseTo(0)
+    expect(d.displayTax).toBeCloseTo(25)
+    expect(d.displayTotal).toBeCloseTo(125)
     // The display equation must balance.
-    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
-  });
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal)
+  })
 
-  it("inclusive mode: simple invoice — net per-line is back-calculated", () => {
+  it('inclusive mode: simple invoice — net per-line is back-calculated', () => {
     // User typed 125 (gross), inclusive 25%
-    const d = buildDisplay({ subtotal: 125, discountAmount: 0, taxRate: 25, taxInclusive: true });
-    expect(d.displaySubtotal).toBeCloseTo(100); // back-calculated net
-    expect(d.displayDiscount).toBeCloseTo(0);
-    expect(d.displayTax).toBeCloseTo(25);
-    expect(d.displayTotal).toBeCloseTo(125);
-    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
-  });
-
-  it("exclusive mode: with fixed discount", () => {
-    const d = buildDisplay({ subtotal: 200, discountAmount: 20, taxRate: 25, taxInclusive: false });
+    const d = buildDisplay({ subtotal: 125, discountAmount: 0, taxRate: 25, taxInclusive: true })
+    expect(d.displaySubtotal).toBeCloseTo(100) // back-calculated net
+    expect(d.displayDiscount).toBeCloseTo(0)
+    expect(d.displayTax).toBeCloseTo(25)
+    expect(d.displayTotal).toBeCloseTo(125)
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal)
+  })
+
+  it('exclusive mode: with fixed discount', () => {
+    const d = buildDisplay({ subtotal: 200, discountAmount: 20, taxRate: 25, taxInclusive: false })
     // base = 180, tax = 45, total = 225
-    expect(d.displaySubtotal).toBeCloseTo(200);
-    expect(d.displayDiscount).toBeCloseTo(20);
-    expect(d.displayTax).toBeCloseTo(45);
-    expect(d.displayTotal).toBeCloseTo(225);
-    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
-  });
-
-  it("inclusive mode: with fixed discount — discount is also back-calculated", () => {
+    expect(d.displaySubtotal).toBeCloseTo(200)
+    expect(d.displayDiscount).toBeCloseTo(20)
+    expect(d.displayTax).toBeCloseTo(45)
+    expect(d.displayTotal).toBeCloseTo(225)
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal)
+  })
+
+  it('inclusive mode: with fixed discount — discount is also back-calculated', () => {
     // User typed 250 gross subtotal, 25 gross discount, inclusive 25%
     // base = 225 gross, net = 180, tax = 45, total = 225
-    const d = buildDisplay({ subtotal: 250, discountAmount: 25, taxRate: 25, taxInclusive: true });
-    expect(d.displaySubtotal).toBeCloseTo(200); // 250 / 1.25
-    expect(d.displayDiscount).toBeCloseTo(20); // 25 / 1.25
-    expect(d.displayTax).toBeCloseTo(45);
-    expect(d.displayTotal).toBeCloseTo(225);
-    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
-  });
-
-  it("inclusive mode: 0% tax — display equals stored values", () => {
-    const d = buildDisplay({ subtotal: 100, discountAmount: 10, taxRate: 0, taxInclusive: true });
-    expect(d.displaySubtotal).toBeCloseTo(100);
-    expect(d.displayDiscount).toBeCloseTo(10);
-    expect(d.displayTax).toBe(0);
-    expect(d.displayTotal).toBeCloseTo(90);
-  });
-
-  it("Norwegian VAT (25%) inclusive — exact 180 round-trip", () => {
+    const d = buildDisplay({ subtotal: 250, discountAmount: 25, taxRate: 25, taxInclusive: true })
+    expect(d.displaySubtotal).toBeCloseTo(200) // 250 / 1.25
+    expect(d.displayDiscount).toBeCloseTo(20) // 25 / 1.25
+    expect(d.displayTax).toBeCloseTo(45)
+    expect(d.displayTotal).toBeCloseTo(225)
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal)
+  })
+
+  it('inclusive mode: 0% tax — display equals stored values', () => {
+    const d = buildDisplay({ subtotal: 100, discountAmount: 10, taxRate: 0, taxInclusive: true })
+    expect(d.displaySubtotal).toBeCloseTo(100)
+    expect(d.displayDiscount).toBeCloseTo(10)
+    expect(d.displayTax).toBe(0)
+    expect(d.displayTotal).toBeCloseTo(90)
+  })
+
+  it('Norwegian VAT (25%) inclusive — exact 180 round-trip', () => {
     // The user's reported case: typed 180 → conversion stored 225 inclusive
     // The universal display should show 180 again on the invoice.
-    const d = buildDisplay({ subtotal: 225, discountAmount: 0, taxRate: 25, taxInclusive: true });
-    expect(d.displaySubtotal).toBeCloseTo(180);
-    expect(d.displayTax).toBeCloseTo(45);
-    expect(d.displayTotal).toBeCloseTo(225);
-  });
+    const d = buildDisplay({ subtotal: 225, discountAmount: 0, taxRate: 25, taxInclusive: true })
+    expect(d.displaySubtotal).toBeCloseTo(180)
+    expect(d.displayTax).toBeCloseTo(45)
+    expect(d.displayTotal).toBeCloseTo(225)
+  })
 
-  it("German VAT (19%) inclusive with discount", () => {
+  it('German VAT (19%) inclusive with discount', () => {
     // Subtotal 119 gross, 11.90 gross discount, inclusive 19%
     // base = 107.10, net = 90, tax = 17.10, total = 107.10
-    const d = buildDisplay({ subtotal: 119, discountAmount: 11.9, taxRate: 19, taxInclusive: true });
-    expect(d.displaySubtotal).toBeCloseTo(100);
-    expect(d.displayDiscount).toBeCloseTo(10);
-    expect(d.displayTax).toBeCloseTo(17.1);
-    expect(d.displayTotal).toBeCloseTo(107.1);
-    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
-  });
-
-  it("US sales tax (8.25%) exclusive — unchanged behaviour", () => {
-    const d = buildDisplay({ subtotal: 1000, discountAmount: 0, taxRate: 8.25, taxInclusive: false });
-    expect(d.displaySubtotal).toBeCloseTo(1000);
-    expect(d.displayTax).toBeCloseTo(82.5);
-    expect(d.displayTotal).toBeCloseTo(1082.5);
-  });
-
-  it("the same logical invoice produces identical display in both modes", () => {
+    const d = buildDisplay({ subtotal: 119, discountAmount: 11.9, taxRate: 19, taxInclusive: true })
+    expect(d.displaySubtotal).toBeCloseTo(100)
+    expect(d.displayDiscount).toBeCloseTo(10)
+    expect(d.displayTax).toBeCloseTo(17.1)
+    expect(d.displayTotal).toBeCloseTo(107.1)
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal)
+  })
+
+  it('US sales tax (8.25%) exclusive — unchanged behaviour', () => {
+    const d = buildDisplay({
+      subtotal: 1000,
+      discountAmount: 0,
+      taxRate: 8.25,
+      taxInclusive: false,
+    })
+    expect(d.displaySubtotal).toBeCloseTo(1000)
+    expect(d.displayTax).toBeCloseTo(82.5)
+    expect(d.displayTotal).toBeCloseTo(1082.5)
+  })
+
+  it('the same logical invoice produces identical display in both modes', () => {
     // An exclusive record with subtotal=1000, no discount, 25% tax
     // is logically equivalent to an inclusive record with subtotal=1250.
     // The universal display must produce the same numbers for both.
-    const exclusive = buildDisplay({ subtotal: 1000, discountAmount: 0, taxRate: 25, taxInclusive: false });
-    const inclusive = buildDisplay({ subtotal: 1250, discountAmount: 0, taxRate: 25, taxInclusive: true });
-    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal);
-    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax);
-    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal);
-  });
-
-  it("equivalent records with discount produce identical display in both modes", () => {
+    const exclusive = buildDisplay({
+      subtotal: 1000,
+      discountAmount: 0,
+      taxRate: 25,
+      taxInclusive: false,
+    })
+    const inclusive = buildDisplay({
+      subtotal: 1250,
+      discountAmount: 0,
+      taxRate: 25,
+      taxInclusive: true,
+    })
+    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal)
+    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax)
+    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal)
+  })
+
+  it('equivalent records with discount produce identical display in both modes', () => {
     // Exclusive: 200 net subtotal, 20 net discount, 25% tax
     // Inclusive equivalent: 250 gross subtotal, 25 gross discount, 25% tax
-    const exclusive = buildDisplay({ subtotal: 200, discountAmount: 20, taxRate: 25, taxInclusive: false });
-    const inclusive = buildDisplay({ subtotal: 250, discountAmount: 25, taxRate: 25, taxInclusive: true });
-    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal);
-    expect(exclusive.displayDiscount).toBeCloseTo(inclusive.displayDiscount);
-    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax);
-    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal);
-  });
-});
+    const exclusive = buildDisplay({
+      subtotal: 200,
+      discountAmount: 20,
+      taxRate: 25,
+      taxInclusive: false,
+    })
+    const inclusive = buildDisplay({
+      subtotal: 250,
+      discountAmount: 25,
+      taxRate: 25,
+      taxInclusive: true,
+    })
+    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal)
+    expect(exclusive.displayDiscount).toBeCloseTo(inclusive.displayDiscount)
+    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax)
+    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal)
+  })
+})

+ 213 - 211
src/__tests__/multitenancy/customer-inventory-isolation.test.ts

@@ -6,32 +6,34 @@
  *
  */
 
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("@/lib/resolve-upload-path", () => ({
+vi.mock('@/lib/resolve-upload-path', () => ({
   resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
-}));
+}))
 
-vi.mock("fs/promises", async (importOriginal) => {
-  const actual = await importOriginal<typeof import("fs/promises")>();
-  return { ...actual, default: actual, unlink: vi.fn().mockResolvedValue(undefined) };
-});
+vi.mock('fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('fs/promises')>()
+  return { ...actual, default: actual, unlink: vi.fn().mockResolvedValue(undefined) }
+})
 
-vi.mock("@/lib/features", () => ({
+vi.mock('@/lib/features', () => ({
   getFeatures: vi.fn().mockResolvedValue({ maxCustomers: 1000 }),
   FeatureGatedError: class FeatureGatedError extends Error {
-    constructor(feature: string, msg: string) { super(msg); }
+    constructor(feature: string, msg: string) {
+      super(msg)
+    }
   },
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     customer: {
@@ -53,409 +55,409 @@ vi.mock("@/lib/db", () => ({
       createMany: vi.fn().mockResolvedValue({ count: 0 }),
     },
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getCustomer,
   getCustomers,
   updateCustomer,
   deleteCustomer,
-} from "@/features/customers/Actions/customerActions";
+} from '@/features/customers/Actions/customerActions'
 import {
   getInventoryPart,
   updateInventoryPart,
   deleteInventoryPart,
   adjustInventoryStock,
-} from "@/features/inventory/Actions/inventoryActions";
+} from '@/features/inventory/Actions/inventoryActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
 
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 const ORG_A_CUSTOMER = {
-  id: "cust-a",
+  id: 'cust-a',
   organizationId: ORG_A,
-  name: "Alice Smith",
-  email: "alice@example.com",
+  name: 'Alice Smith',
+  email: 'alice@example.com',
   phone: null,
   address: null,
   company: null,
   notes: null,
   vehicles: [],
-};
+}
 
 const ORG_A_PART = {
-  id: "part-a",
+  id: 'part-a',
   organizationId: ORG_A,
-  name: "Oil Filter",
+  name: 'Oil Filter',
   quantity: 10,
   unitCost: 5,
   sellPrice: 10,
   isArchived: false,
   imageUrl: null,
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
+  vi.resetAllMocks()
   // Re-set storedImage defaults after reset (used by inventory gallery operations)
-  vi.mocked(db.storedImage.findMany).mockResolvedValue([]);
-  vi.mocked(db.storedImage.deleteMany).mockResolvedValue({ count: 0 } as any);
-  vi.mocked(db.storedImage.createMany).mockResolvedValue({ count: 0 } as any);
-});
+  vi.mocked(db.storedImage.findMany).mockResolvedValue([])
+  vi.mocked(db.storedImage.deleteMany).mockResolvedValue({ count: 0 } as any)
+  vi.mocked(db.storedImage.createMany).mockResolvedValue({ count: 0 } as any)
+})
 
 // ---------------------------------------------------------------------------
 // Customer read isolation
 // ---------------------------------------------------------------------------
 
-describe("getCustomer — cross-org isolation", () => {
+describe('getCustomer — cross-org isolation', () => {
   it("returns null (no data) when requesting another org's customer", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.customer.findFirst).mockResolvedValue(null)
 
-    const result = await getCustomer(`${ORG_B}-customer-id`);
+    const result = await getCustomer(`${ORG_B}-customer-id`)
 
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("returns customer data when requesting the caller's own customer", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.findFirst).mockResolvedValue(ORG_A_CUSTOMER as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.findFirst).mockResolvedValue(ORG_A_CUSTOMER as any)
 
-    const result = await getCustomer("cust-a");
+    const result = await getCustomer('cust-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("cust-a");
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('cust-a')
+  })
 
   it("read query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.findFirst).mockResolvedValue(ORG_A_CUSTOMER as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.findFirst).mockResolvedValue(ORG_A_CUSTOMER as any)
 
-    await getCustomer("cust-a");
+    await getCustomer('cust-a')
 
     expect(vi.mocked(db.customer.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Customer list isolation
 // ---------------------------------------------------------------------------
 
-describe("getCustomers — org scoping", () => {
+describe('getCustomers — org scoping', () => {
   it("list query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.findMany).mockResolvedValue([ORG_A_CUSTOMER] as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.findMany).mockResolvedValue([ORG_A_CUSTOMER] as any)
 
-    await getCustomers();
+    await getCustomers()
 
     expect(vi.mocked(db.customer.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("returns only the caller's own customers", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.findMany).mockResolvedValue([ORG_A_CUSTOMER] as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.findMany).mockResolvedValue([ORG_A_CUSTOMER] as any)
 
-    const result = await getCustomers();
+    const result = await getCustomers()
 
-    expect(result.success).toBe(true);
-    expect(result.data).toHaveLength(1);
-    expect((result.data as any)[0].organizationId).toBe(ORG_A);
-  });
-});
+    expect(result.success).toBe(true)
+    expect(result.data).toHaveLength(1)
+    expect((result.data as any)[0].organizationId).toBe(ORG_A)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Customer update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateCustomer — cross-org isolation", () => {
+describe('updateCustomer — cross-org isolation', () => {
   it("returns error when targeting another org's customer", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // updateMany with org filter matches 0 rows for a cross-org customer id
-    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 0 } as any);
+    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await updateCustomer({ id: `${ORG_B}-customer-id`, name: "Hacked" });
+    const result = await updateCustomer({ id: `${ORG_B}-customer-id`, name: 'Hacked' })
 
-    expect(result.success).toBe(false);
-  });
+    expect(result.success).toBe(false)
+  })
 
-  it("update query always includes organizationId to prevent cross-org writes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('update query always includes organizationId to prevent cross-org writes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await updateCustomer({ id: "cust-a", name: "Alice Updated" });
+    await updateCustomer({ id: 'cust-a', name: 'Alice Updated' })
 
     expect(vi.mocked(db.customer.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully updates the caller's own customer", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await updateCustomer({ id: "cust-a", name: "Alice Updated" });
+    const result = await updateCustomer({ id: 'cust-a', name: 'Alice Updated' })
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Customer delete isolation
 // ---------------------------------------------------------------------------
 
-describe("deleteCustomer — cross-org isolation", () => {
+describe('deleteCustomer — cross-org isolation', () => {
   it("returns error when deleting another org's customer", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // deleteMany with org filter matches 0 rows for a cross-org customer id
-    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 0 } as any);
+    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await deleteCustomer(`${ORG_B}-customer-id`);
+    const result = await deleteCustomer(`${ORG_B}-customer-id`)
 
-    expect(result.success).toBe(false);
-  });
+    expect(result.success).toBe(false)
+  })
 
-  it("delete query always includes organizationId to prevent cross-org deletes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query always includes organizationId to prevent cross-org deletes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteCustomer("cust-a");
+    await deleteCustomer('cust-a')
 
     expect(vi.mocked(db.customer.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully deletes the caller's own customer", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.customer.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteCustomer("cust-a");
+    const result = await deleteCustomer('cust-a')
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inventory read isolation
 // ---------------------------------------------------------------------------
 
-describe("getInventoryPart — cross-org isolation", () => {
+describe('getInventoryPart — cross-org isolation', () => {
   it("returns 'Part not found' error when requesting another org's inventory part", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null)
 
-    const result = await getInventoryPart(`${ORG_B}-part-id`);
+    const result = await getInventoryPart(`${ORG_B}-part-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Part not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Part not found')
+  })
 
   it("returns part data when requesting the caller's own part", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any)
 
-    const result = await getInventoryPart("part-a");
+    const result = await getInventoryPart('part-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("part-a");
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('part-a')
+  })
 
   it("read query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any)
 
-    await getInventoryPart("part-a");
+    await getInventoryPart('part-a')
 
     expect(vi.mocked(db.inventoryPart.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inventory update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateInventoryPart — cross-org isolation", () => {
+describe('updateInventoryPart — cross-org isolation', () => {
   it("returns 'Part not found' error when targeting another org's part (count check enforced)", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // updateMany with org filter matches 0 rows — action checks count and throws
-    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 0 } as any);
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await updateInventoryPart({ id: `${ORG_B}-part-id`, name: "Hacked" });
+    const result = await updateInventoryPart({ id: `${ORG_B}-part-id`, name: 'Hacked' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Part not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Part not found')
+  })
 
-  it("update query always includes organizationId to prevent cross-org writes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('update query always includes organizationId to prevent cross-org writes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await updateInventoryPart({ id: "part-a", name: "Updated Filter" });
+    await updateInventoryPart({ id: 'part-a', name: 'Updated Filter' })
 
     expect(vi.mocked(db.inventoryPart.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully updates the caller's own inventory part", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await updateInventoryPart({ id: "part-a", name: "Updated Filter" });
+    const result = await updateInventoryPart({ id: 'part-a', name: 'Updated Filter' })
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).updated).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).updated).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inventory delete isolation
 // ---------------------------------------------------------------------------
 
-describe("deleteInventoryPart — cross-org isolation", () => {
+describe('deleteInventoryPart — cross-org isolation', () => {
   it("returns 'Part not found' error when targeting another org's part (count check enforced)", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null); // no imageUrl to clean
-    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 0 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null) // no imageUrl to clean
+    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await deleteInventoryPart(`${ORG_B}-part-id`);
+    const result = await deleteInventoryPart(`${ORG_B}-part-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Part not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Part not found')
+  })
 
-  it("delete query always includes organizationId to prevent cross-org deletes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null } as any);
-    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query always includes organizationId to prevent cross-org deletes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null } as any)
+    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteInventoryPart("part-a");
+    await deleteInventoryPart('part-a')
 
     expect(vi.mocked(db.inventoryPart.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully deletes the caller's own inventory part", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null, gallery: [] } as any);
-    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null, gallery: [] } as any)
+    vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteInventoryPart("part-a");
+    const result = await deleteInventoryPart('part-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).deleted).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).deleted).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inventory stock adjustment isolation
 // ---------------------------------------------------------------------------
 
-describe("adjustInventoryStock — cross-org isolation", () => {
+describe('adjustInventoryStock — cross-org isolation', () => {
   it("returns 'Part not found' error when targeting another org's part", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // findFirst with organizationId filter returns null for cross-org part
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null);
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null)
 
-    const result = await adjustInventoryStock({ id: `${ORG_B}-part-id`, adjustment: 10 });
+    const result = await adjustInventoryStock({ id: `${ORG_B}-part-id`, adjustment: 10 })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Part not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Part not found')
+  })
 
-  it("ownership findFirst always includes organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null);
+  it('ownership findFirst always includes organizationId', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null)
 
-    await adjustInventoryStock({ id: "part-x", adjustment: 5 });
+    await adjustInventoryStock({ id: 'part-x', adjustment: 5 })
 
     expect(vi.mocked(db.inventoryPart.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
-  it("inventory update is never called when ownership check fails", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null);
+  it('inventory update is never called when ownership check fails', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(null)
 
-    await adjustInventoryStock({ id: `${ORG_B}-part-id`, adjustment: 99 });
+    await adjustInventoryStock({ id: `${ORG_B}-part-id`, adjustment: 99 })
 
-    expect(vi.mocked(db.inventoryPart.updateMany)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.inventoryPart.updateMany)).not.toHaveBeenCalled()
+  })
 
   it("successfully adjusts stock for the caller's own part", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any);
-    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any)
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await adjustInventoryStock({ id: "part-a", adjustment: 5 });
+    const result = await adjustInventoryStock({ id: 'part-a', adjustment: 5 })
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).quantity).toBe(15);
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).quantity).toBe(15)
+  })
 
-  it("stock adjustment query includes organizationId for defense-in-depth", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any);
-    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('stock adjustment query includes organizationId for defense-in-depth', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue(ORG_A_PART as any)
+    vi.mocked(db.inventoryPart.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await adjustInventoryStock({ id: "part-a", adjustment: 5 });
+    await adjustInventoryStock({ id: 'part-a', adjustment: 5 })
 
     expect(vi.mocked(db.inventoryPart.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("returns 'Insufficient stock' error when adjustment would make quantity negative", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // quantity is 10, adjustment is -15 → would go to -5
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ ...ORG_A_PART, quantity: 10 } as any);
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ ...ORG_A_PART, quantity: 10 } as any)
 
-    const result = await adjustInventoryStock({ id: "part-a", adjustment: -15 });
+    const result = await adjustInventoryStock({ id: 'part-a', adjustment: -15 })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Insufficient stock");
-  });
-});
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Insufficient stock')
+  })
+})

+ 96 - 93
src/__tests__/multitenancy/inspection-isolation.test.ts

@@ -4,20 +4,20 @@
  * Verifies that a user from Org A cannot read, update, or delete inspections
  * belonging to Org B.
  */
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("@/lib/notification-bus", () => ({
+vi.mock('@/lib/notification-bus', () => ({
   notificationBus: { emit: vi.fn() },
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     inspection: {
@@ -30,86 +30,86 @@ vi.mock("@/lib/db", () => ({
       update: vi.fn(),
     },
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getInspection,
   updateInspectionItem,
   completeInspection,
   deleteInspection,
-} from "@/features/inspections/Actions/inspectionActions";
+} from '@/features/inspections/Actions/inspectionActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
 
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
+  vi.resetAllMocks()
+})
 
-describe("getInspection — cross-org isolation", () => {
+describe('getInspection — cross-org isolation', () => {
   it("returns null (no data) when requesting another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await getInspection(`${ORG_B}-inspection-id`);
+    const result = await getInspection(`${ORG_B}-inspection-id`)
 
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
-  it("read query is scoped to organizationId", async () => {
-    setupOrgAOwner();
+  it('read query is scoped to organizationId', async () => {
+    setupOrgAOwner()
     vi.mocked(db.inspection.findFirst).mockResolvedValue({
-      id: "insp-a",
+      id: 'insp-a',
       organizationId: ORG_A,
-    } as any);
+    } as any)
 
-    await getInspection("insp-a");
+    await getInspection('insp-a')
 
     expect(vi.mocked(db.inspection.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
-describe("updateInspectionItem — cross-org isolation", () => {
+describe('updateInspectionItem — cross-org isolation', () => {
   it("returns error when targeting another org's inspection item", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null)
 
     const result = await updateInspectionItem(`${ORG_B}-item-id`, {
-      condition: "fail",
-    });
+      condition: 'fail',
+    })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection item not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection item not found')
+  })
 
-  it("update query includes organizationId via inspection relation", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue({ id: "item-a" } as any);
-    vi.mocked(db.inspectionItem.update).mockResolvedValue({ id: "item-a" } as any);
+  it('update query includes organizationId via inspection relation', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue({ id: 'item-a' } as any)
+    vi.mocked(db.inspectionItem.update).mockResolvedValue({ id: 'item-a' } as any)
 
-    await updateInspectionItem("item-a", { condition: "pass" });
+    await updateInspectionItem('item-a', { condition: 'pass' })
 
     expect(vi.mocked(db.inspectionItem.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
@@ -117,78 +117,81 @@ describe("updateInspectionItem — cross-org isolation", () => {
           inspection: expect.objectContaining({ organizationId: ORG_A }),
         }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully updates the caller's own inspection item", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue({ id: "item-a" } as any);
-    vi.mocked(db.inspectionItem.update).mockResolvedValue({ id: "item-a", condition: "pass" } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue({ id: 'item-a' } as any)
+    vi.mocked(db.inspectionItem.update).mockResolvedValue({
+      id: 'item-a',
+      condition: 'pass',
+    } as any)
 
-    const result = await updateInspectionItem("item-a", { condition: "pass" });
+    const result = await updateInspectionItem('item-a', { condition: 'pass' })
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
-describe("completeInspection — cross-org isolation", () => {
+describe('completeInspection — cross-org isolation', () => {
   it("returns error when targeting another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await completeInspection(`${ORG_B}-inspection-id`);
+    const result = await completeInspection(`${ORG_B}-inspection-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection not found')
+  })
 
-  it("update query includes organizationId", async () => {
-    setupOrgAOwner();
+  it('update query includes organizationId', async () => {
+    setupOrgAOwner()
     vi.mocked(db.inspection.findFirst).mockResolvedValue({
-      id: "insp-a",
-      vehicleId: "veh-a",
+      id: 'insp-a',
+      vehicleId: 'veh-a',
       organizationId: ORG_A,
       // Completion checks the items for anything the template made mandatory.
       items: [],
-    } as any);
-    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any);
+    } as any)
+    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await completeInspection("insp-a");
+    await completeInspection('insp-a')
 
     expect(vi.mocked(db.inspection.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
-describe("deleteInspection — cross-org isolation", () => {
+describe('deleteInspection — cross-org isolation', () => {
   it("returns error when deleting another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await deleteInspection(`${ORG_B}-inspection-id`);
+    const result = await deleteInspection(`${ORG_B}-inspection-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection not found')
+  })
 
-  it("delete query includes organizationId", async () => {
-    setupOrgAOwner();
+  it('delete query includes organizationId', async () => {
+    setupOrgAOwner()
     vi.mocked(db.inspection.findFirst).mockResolvedValue({
-      id: "insp-a",
-      vehicleId: "veh-a",
+      id: 'insp-a',
+      vehicleId: 'veh-a',
       organizationId: ORG_A,
-    } as any);
-    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any);
+    } as any)
+    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteInspection("insp-a");
+    await deleteInspection('insp-a')
 
     expect(vi.mocked(db.inspection.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})

+ 98 - 98
src/__tests__/multitenancy/labor-preset-isolation.test.ts

@@ -4,16 +4,16 @@
  * Verifies that a user from Org A cannot read, update, or delete labor presets
  * belonging to Org B.
  */
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     laborPreset: {
@@ -27,171 +27,171 @@ vi.mock("@/lib/db", () => ({
     },
     $transaction: vi.fn(),
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getLaborPreset,
   updateLaborPreset,
   deleteLaborPreset,
-} from "@/features/labor-presets/Actions/laborPresetActions";
+} from '@/features/labor-presets/Actions/laborPresetActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
 
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
+  vi.resetAllMocks()
+})
 
-describe("getLaborPreset — cross-org isolation", () => {
+describe('getLaborPreset — cross-org isolation', () => {
   it("returns error when requesting another org's preset", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(null)
 
-    const result = await getLaborPreset(`${ORG_B}-preset-id`);
+    const result = await getLaborPreset(`${ORG_B}-preset-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Preset not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Preset not found')
+  })
 
-  it("read query is scoped to organizationId", async () => {
-    setupOrgAOwner();
-    const preset = { id: "preset-a", organizationId: ORG_A, name: "Standard Labor", items: [] };
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(preset as any);
+  it('read query is scoped to organizationId', async () => {
+    setupOrgAOwner()
+    const preset = { id: 'preset-a', organizationId: ORG_A, name: 'Standard Labor', items: [] }
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(preset as any)
 
-    await getLaborPreset("preset-a");
+    await getLaborPreset('preset-a')
 
     expect(vi.mocked(db.laborPreset.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("returns preset data for the caller's own preset", async () => {
-    setupOrgAOwner();
-    const preset = { id: "preset-a", organizationId: ORG_A, name: "Standard Labor", items: [] };
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(preset as any);
+    setupOrgAOwner()
+    const preset = { id: 'preset-a', organizationId: ORG_A, name: 'Standard Labor', items: [] }
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(preset as any)
 
-    const result = await getLaborPreset("preset-a");
+    const result = await getLaborPreset('preset-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("preset-a");
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('preset-a')
+  })
+})
 
-describe("updateLaborPreset — cross-org isolation", () => {
+describe('updateLaborPreset — cross-org isolation', () => {
   it("returns error when targeting another org's preset", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // findFirst with organizationId filter returns null for cross-org preset
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(null);
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue(null)
 
     const result = await updateLaborPreset({
       id: `${ORG_B}-preset-id`,
-      name: "Hacked",
-      items: [{ description: "test", hours: 1, rate: 50 }],
-    });
+      name: 'Hacked',
+      items: [{ description: 'test', hours: 1, rate: 50 }],
+    })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Preset not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Preset not found')
+  })
 
-  it("update includes organizationId check via findFirst ownership lookup", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue({ id: "preset-a" } as any);
+  it('update includes organizationId check via findFirst ownership lookup', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue({ id: 'preset-a' } as any)
     const mockTx = {
       laborPresetItem: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
-      laborPreset: { update: vi.fn().mockResolvedValue({ id: "preset-a", items: [] }) },
-    };
-    vi.mocked(db.$transaction).mockImplementation(async (fn: any) => fn(mockTx));
+      laborPreset: { update: vi.fn().mockResolvedValue({ id: 'preset-a', items: [] }) },
+    }
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) => fn(mockTx))
 
     await updateLaborPreset({
-      id: "preset-a",
-      name: "Updated Labor",
-      items: [{ description: "Oil change", hours: 1, rate: 75 }],
-    });
+      id: 'preset-a',
+      name: 'Updated Labor',
+      items: [{ description: 'Oil change', hours: 1, rate: 75 }],
+    })
 
     expect(vi.mocked(db.laborPreset.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully updates the caller's own preset", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.findFirst).mockResolvedValue({ id: "preset-a" } as any);
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.findFirst).mockResolvedValue({ id: 'preset-a' } as any)
     const mockTx = {
       laborPresetItem: { deleteMany: vi.fn().mockResolvedValue({ count: 1 }) },
       laborPresetPart: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
       laborPreset: {
         update: vi.fn().mockResolvedValue({
-          id: "preset-a",
-          name: "Updated Labor",
-          items: [{ description: "Oil change", hours: 1, rate: 75 }],
+          id: 'preset-a',
+          name: 'Updated Labor',
+          items: [{ description: 'Oil change', hours: 1, rate: 75 }],
         }),
       },
-    };
-    vi.mocked(db.$transaction).mockImplementation(async (fn: any) => fn(mockTx));
+    }
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) => fn(mockTx))
 
     const result = await updateLaborPreset({
-      id: "preset-a",
-      name: "Updated Labor",
-      items: [{ description: "Oil change", hours: 1, rate: 75 }],
-    });
+      id: 'preset-a',
+      name: 'Updated Labor',
+      items: [{ description: 'Oil change', hours: 1, rate: 75 }],
+    })
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).updated).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).updated).toBe(true)
+  })
+})
 
-describe("deleteLaborPreset — cross-org isolation", () => {
+describe('deleteLaborPreset — cross-org isolation', () => {
   it("returns error when deleting another org's preset", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 0 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await deleteLaborPreset(`${ORG_B}-preset-id`);
+    const result = await deleteLaborPreset(`${ORG_B}-preset-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Preset not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Preset not found')
+  })
 
-  it("delete query includes organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query includes organizationId', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteLaborPreset("preset-a");
+    await deleteLaborPreset('preset-a')
 
     expect(vi.mocked(db.laborPreset.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully deletes the caller's own preset", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.laborPreset.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteLaborPreset("preset-a");
+    const result = await deleteLaborPreset('preset-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).deleted).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).deleted).toBe(true)
+  })
+})

+ 243 - 243
src/__tests__/multitenancy/quote-inspection-isolation.test.ts

@@ -6,20 +6,20 @@
  *
  */
 
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("@/lib/lib/invoice-utils", () => ({
+vi.mock('@/lib/lib/invoice-utils', () => ({
   resolveInvoicePrefix: vi.fn((p: string) => p),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
@@ -47,361 +47,361 @@ vi.mock("@/lib/db", () => ({
     },
     $transaction: vi.fn(),
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getQuote,
   getQuotesPaginated,
   updateQuote,
   updateQuoteStatus,
   deleteQuote,
-} from "@/features/quotes/Actions/quoteActions";
+} from '@/features/quotes/Actions/quoteActions'
 import {
   getInspection,
   getInspectionsPaginated,
   updateInspectionItem,
   completeInspection,
   deleteInspection,
-} from "@/features/inspections/Actions/inspectionActions";
+} from '@/features/inspections/Actions/inspectionActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
 
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 const ORG_A_QUOTE = {
-  id: "quote-a",
+  id: 'quote-a',
   organizationId: ORG_A,
-  title: "Brake Service",
-  quoteNumber: "QT-1001",
-  status: "sent",
+  title: 'Brake Service',
+  quoteNumber: 'QT-1001',
+  status: 'sent',
   partItems: [],
   laborItems: [],
   customer: null,
   vehicle: null,
   inspection: null,
-};
+}
 
 const ORG_A_INSPECTION = {
-  id: "insp-a",
+  id: 'insp-a',
   organizationId: ORG_A,
-  status: "in_progress",
-  vehicleId: "veh-a",
+  status: 'in_progress',
+  vehicleId: 'veh-a',
   vehicle: null,
   template: null,
   items: [],
   quotes: [],
   quoteRequests: [],
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
-});
+  vi.resetAllMocks()
+})
 
 // ---------------------------------------------------------------------------
 // Quote read isolation
 // ---------------------------------------------------------------------------
 
-describe("getQuote — cross-org isolation", () => {
+describe('getQuote — cross-org isolation', () => {
   it("returns null (no data) when requesting another org's quote ID", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    const result = await getQuote(`${ORG_B}-quote-id`);
+    const result = await getQuote(`${ORG_B}-quote-id`)
 
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("returns quote data when requesting the caller's own quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
 
-    const result = await getQuote("quote-a");
+    const result = await getQuote('quote-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("quote-a");
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('quote-a')
+  })
 
   it("read query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
 
-    await getQuote("quote-a");
+    await getQuote('quote-a')
 
     expect(vi.mocked(db.quote.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Quote list isolation
 // ---------------------------------------------------------------------------
 
-describe("getQuotesPaginated — org scoping", () => {
+describe('getQuotesPaginated — org scoping', () => {
   it("list query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findMany).mockResolvedValue([]);
-    vi.mocked(db.quote.count).mockResolvedValue(0);
-    vi.mocked(db.quote.groupBy).mockResolvedValue([]);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findMany).mockResolvedValue([])
+    vi.mocked(db.quote.count).mockResolvedValue(0)
+    vi.mocked(db.quote.groupBy).mockResolvedValue([])
 
-    await getQuotesPaginated({});
+    await getQuotesPaginated({})
 
     expect(vi.mocked(db.quote.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Quote update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateQuote — cross-org isolation", () => {
+describe('updateQuote — cross-org isolation', () => {
   it("returns 'Quote not found' error when targeting another org's quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    const result = await updateQuote({ id: `${ORG_B}-quote-id`, title: "Injected" });
+    const result = await updateQuote({ id: `${ORG_B}-quote-id`, title: 'Injected' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Quote not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Quote not found')
+  })
 
-  it("ownership is verified before the update transaction runs", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+  it('ownership is verified before the update transaction runs', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    await updateQuote({ id: `${ORG_B}-quote-id`, title: "Injected" });
+    await updateQuote({ id: `${ORG_B}-quote-id`, title: 'Injected' })
 
     // $transaction must never be called if ownership check fails
-    expect(vi.mocked(db.$transaction)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.$transaction)).not.toHaveBeenCalled()
+  })
 
   it("successfully updates the caller's own quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
-    const updatedQuote = { ...ORG_A_QUOTE, title: "Updated Title" };
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
+    const updatedQuote = { ...ORG_A_QUOTE, title: 'Updated Title' }
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
       fn({
         quote: { update: vi.fn().mockResolvedValue(updatedQuote) },
         quotePart: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn() },
         quoteLabor: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn() },
       })
-    );
+    )
 
-    const result = await updateQuote({ id: "quote-a", title: "Updated Title" });
+    const result = await updateQuote({ id: 'quote-a', title: 'Updated Title' })
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Quote status update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateQuoteStatus — cross-org isolation", () => {
+describe('updateQuoteStatus — cross-org isolation', () => {
   it("returns 'Quote not found' error when targeting another org's quote status", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    const result = await updateQuoteStatus(`${ORG_B}-quote-id`, "accepted");
+    const result = await updateQuoteStatus(`${ORG_B}-quote-id`, 'accepted')
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Quote not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Quote not found')
+  })
 
   it("successfully updates status of the caller's own quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
-    vi.mocked(db.quote.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
+    vi.mocked(db.quote.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await updateQuoteStatus("quote-a", "accepted");
+    const result = await updateQuoteStatus('quote-a', 'accepted')
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("status update query includes organizationId for defense-in-depth", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
-    vi.mocked(db.quote.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('status update query includes organizationId for defense-in-depth', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
+    vi.mocked(db.quote.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await updateQuoteStatus("quote-a", "accepted");
+    await updateQuoteStatus('quote-a', 'accepted')
 
     expect(vi.mocked(db.quote.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Quote delete isolation
 // ---------------------------------------------------------------------------
 
-describe("deleteQuote — cross-org isolation", () => {
+describe('deleteQuote — cross-org isolation', () => {
   it("returns 'Quote not found' error when targeting another org's quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    const result = await deleteQuote(`${ORG_B}-quote-id`);
+    const result = await deleteQuote(`${ORG_B}-quote-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Quote not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Quote not found')
+  })
 
-  it("delete is never called when ownership check fails", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+  it('delete is never called when ownership check fails', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
 
-    await deleteQuote(`${ORG_B}-quote-id`);
+    await deleteQuote(`${ORG_B}-quote-id`)
 
-    expect(vi.mocked(db.quote.deleteMany)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.quote.deleteMany)).not.toHaveBeenCalled()
+  })
 
   it("successfully deletes the caller's own quote", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
-    vi.mocked(db.quote.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
+    vi.mocked(db.quote.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteQuote("quote-a");
+    const result = await deleteQuote('quote-a')
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("delete query includes organizationId for defense-in-depth", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any);
-    vi.mocked(db.quote.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query includes organizationId for defense-in-depth', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.quote.findFirst).mockResolvedValue(ORG_A_QUOTE as any)
+    vi.mocked(db.quote.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteQuote("quote-a");
+    await deleteQuote('quote-a')
 
     expect(vi.mocked(db.quote.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inspection read isolation
 // ---------------------------------------------------------------------------
 
-describe("getInspection — cross-org isolation", () => {
+describe('getInspection — cross-org isolation', () => {
   it("returns null (no data) when requesting another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await getInspection(`${ORG_B}-insp-id`);
+    const result = await getInspection(`${ORG_B}-insp-id`)
 
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("returns inspection data when requesting the caller's own inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
 
-    const result = await getInspection("insp-a");
+    const result = await getInspection('insp-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("insp-a");
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('insp-a')
+  })
 
   it("read query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
 
-    await getInspection("insp-a");
+    await getInspection('insp-a')
 
     expect(vi.mocked(db.inspection.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inspection list isolation
 // ---------------------------------------------------------------------------
 
-describe("getInspectionsPaginated — org scoping", () => {
+describe('getInspectionsPaginated — org scoping', () => {
   it("list query always scopes to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
     // Mock the underlying db calls getInspectionsPaginated uses
-    const mockDb = vi.mocked(db) as any;
+    const mockDb = vi.mocked(db) as any
     if (!mockDb.inspection.findMany) {
-      mockDb.inspection.findMany = vi.fn().mockResolvedValue([]);
+      mockDb.inspection.findMany = vi.fn().mockResolvedValue([])
     } else {
-      vi.mocked(mockDb.inspection.findMany).mockResolvedValue([]);
+      vi.mocked(mockDb.inspection.findMany).mockResolvedValue([])
     }
     if (!mockDb.inspection.count) {
-      mockDb.inspection.count = vi.fn().mockResolvedValue(0);
+      mockDb.inspection.count = vi.fn().mockResolvedValue(0)
     } else {
-      vi.mocked(mockDb.inspection.count).mockResolvedValue(0);
+      vi.mocked(mockDb.inspection.count).mockResolvedValue(0)
     }
     if (!mockDb.inspection.groupBy) {
-      mockDb.inspection.groupBy = vi.fn().mockResolvedValue([]);
+      mockDb.inspection.groupBy = vi.fn().mockResolvedValue([])
     } else {
-      vi.mocked(mockDb.inspection.groupBy).mockResolvedValue([]);
+      vi.mocked(mockDb.inspection.groupBy).mockResolvedValue([])
     }
 
-    await getInspectionsPaginated({});
+    await getInspectionsPaginated({})
 
     // The findMany inside getInspectionsPaginated should use organizationId
     expect(mockDb.inspection.findMany).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Inspection item update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateInspectionItem — cross-org isolation", () => {
+describe('updateInspectionItem — cross-org isolation', () => {
   it("returns 'Inspection item not found' when updating an item from another org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null)
 
-    const result = await updateInspectionItem(`${ORG_B}-item-id`, { condition: "pass" });
+    const result = await updateInspectionItem(`${ORG_B}-item-id`, { condition: 'pass' })
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection item not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection item not found')
+  })
 
   it("item lookup always scopes through the inspection's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null)
 
-    await updateInspectionItem("item-x", { condition: "pass" });
+    await updateInspectionItem('item-x', { condition: 'pass' })
 
     expect(vi.mocked(db.inspectionItem.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
@@ -409,125 +409,125 @@ describe("updateInspectionItem — cross-org isolation", () => {
           inspection: expect.objectContaining({ organizationId: ORG_A }),
         }),
       })
-    );
-  });
+    )
+  })
 
-  it("update is never called when ownership check fails", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null);
+  it('update is never called when ownership check fails', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(null)
 
-    await updateInspectionItem(`${ORG_B}-item-id`, { condition: "fail" });
+    await updateInspectionItem(`${ORG_B}-item-id`, { condition: 'fail' })
 
-    expect(vi.mocked(db.inspectionItem.update)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.inspectionItem.update)).not.toHaveBeenCalled()
+  })
 
   it("successfully updates an inspection item belonging to the caller's org", async () => {
-    setupOrgAOwner();
-    const item = { id: "item-a", inspectionId: "insp-a", condition: "not_inspected" };
-    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(item as any);
-    vi.mocked(db.inspectionItem.update).mockResolvedValue({ ...item, condition: "pass" } as any);
+    setupOrgAOwner()
+    const item = { id: 'item-a', inspectionId: 'insp-a', condition: 'not_inspected' }
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue(item as any)
+    vi.mocked(db.inspectionItem.update).mockResolvedValue({ ...item, condition: 'pass' } as any)
 
-    const result = await updateInspectionItem("item-a", { condition: "pass" });
+    const result = await updateInspectionItem('item-a', { condition: 'pass' })
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).condition).toBe("pass");
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any).condition).toBe('pass')
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Complete inspection isolation
 // ---------------------------------------------------------------------------
 
-describe("completeInspection — cross-org isolation", () => {
+describe('completeInspection — cross-org isolation', () => {
   it("returns 'Inspection not found' when completing another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await completeInspection(`${ORG_B}-insp-id`);
+    const result = await completeInspection(`${ORG_B}-insp-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection not found')
+  })
 
-  it("inspection update is never called when ownership check fails", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+  it('inspection update is never called when ownership check fails', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    await completeInspection(`${ORG_B}-insp-id`);
+    await completeInspection(`${ORG_B}-insp-id`)
 
-    expect(vi.mocked(db.inspection.updateMany)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.inspection.updateMany)).not.toHaveBeenCalled()
+  })
 
   it("successfully completes the caller's own inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
-    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
+    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await completeInspection("insp-a");
+    const result = await completeInspection('insp-a')
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("complete query includes organizationId for defense-in-depth", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
-    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('complete query includes organizationId for defense-in-depth', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
+    vi.mocked(db.inspection.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await completeInspection("insp-a");
+    await completeInspection('insp-a')
 
     expect(vi.mocked(db.inspection.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Delete inspection isolation
 // ---------------------------------------------------------------------------
 
-describe("deleteInspection — cross-org isolation", () => {
+describe('deleteInspection — cross-org isolation', () => {
   it("returns 'Inspection not found' when deleting another org's inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    const result = await deleteInspection(`${ORG_B}-insp-id`);
+    const result = await deleteInspection(`${ORG_B}-insp-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Inspection not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Inspection not found')
+  })
 
-  it("inspection delete is never called when ownership check fails", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(null);
+  it('inspection delete is never called when ownership check fails', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(null)
 
-    await deleteInspection(`${ORG_B}-insp-id`);
+    await deleteInspection(`${ORG_B}-insp-id`)
 
-    expect(vi.mocked(db.inspection.deleteMany)).not.toHaveBeenCalled();
-  });
+    expect(vi.mocked(db.inspection.deleteMany)).not.toHaveBeenCalled()
+  })
 
   it("successfully deletes the caller's own inspection", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
-    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
+    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteInspection("insp-a");
+    const result = await deleteInspection('insp-a')
 
-    expect(result.success).toBe(true);
-  });
+    expect(result.success).toBe(true)
+  })
 
-  it("delete query includes organizationId for defense-in-depth", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any);
-    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query includes organizationId for defense-in-depth', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.inspection.findFirst).mockResolvedValue(ORG_A_INSPECTION as any)
+    vi.mocked(db.inspection.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteInspection("insp-a");
+    await deleteInspection('insp-a')
 
     expect(vi.mocked(db.inspection.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})

+ 157 - 157
src/__tests__/multitenancy/vehicle-service-isolation.test.ts

@@ -8,20 +8,20 @@
  *
  */
 
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from 'vitest'
 
-vi.mock("@/lib/cached-session", () => ({
+vi.mock('@/lib/cached-session', () => ({
   getCachedSession: vi.fn(),
   getCachedMembership: vi.fn(),
-}));
+}))
 
-vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
-vi.mock("@/lib/resolve-upload-path", () => ({
+vi.mock('@/lib/resolve-upload-path', () => ({
   resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
-}));
+}))
 
-vi.mock("@/lib/db", () => ({
+vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     vehicle: {
@@ -42,45 +42,45 @@ vi.mock("@/lib/db", () => ({
     inventoryPart: { updateMany: vi.fn() },
     $transaction: vi.fn(),
   },
-}));
+}))
 
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { db } from "@/lib/db";
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
 import {
   getVehicle,
   getVehicles,
   updateVehicle,
   deleteVehicle,
-} from "@/features/vehicles/Actions/vehicleActions";
+} from '@/features/vehicles/Actions/vehicleActions'
 import {
   getServiceRecord,
   deleteServiceRecord,
   deleteServiceAttachment,
-} from "@/features/vehicles/Actions/serviceActions";
+} from '@/features/vehicles/Actions/serviceActions'
 
-const mockSession = vi.mocked(getCachedSession);
-const mockMembership = vi.mocked(getCachedMembership);
-const mockUserFindUnique = vi.mocked(db.user.findUnique);
+const mockSession = vi.mocked(getCachedSession)
+const mockMembership = vi.mocked(getCachedMembership)
+const mockUserFindUnique = vi.mocked(db.user.findUnique)
 
-const ORG_A = "org-a";
-const ORG_B = "org-b";
+const ORG_A = 'org-a'
+const ORG_B = 'org-b'
 
 function setupOrgAOwner() {
-  mockSession.mockResolvedValue({ user: { id: "user-a", email: "a@example.com" } } as any);
+  mockSession.mockResolvedValue({ user: { id: 'user-a', email: 'a@example.com' } } as any)
   mockMembership.mockResolvedValue({
     organizationId: ORG_A,
-    role: "owner",
+    role: 'owner',
     roleId: null,
     customRole: null,
-  } as any);
-  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  } as any)
+  mockUserFindUnique.mockResolvedValue({ isSuperAdmin: false } as any)
 }
 
 const ORG_A_VEHICLE = {
-  id: "veh-a",
+  id: 'veh-a',
   organizationId: ORG_A,
-  make: "Toyota",
-  model: "Camry",
+  make: 'Toyota',
+  model: 'Camry',
   year: 2020,
   mileage: 50000,
   imageUrl: null,
@@ -90,180 +90,180 @@ const ORG_A_VEHICLE = {
   reminders: [],
   customer: null,
   _count: { serviceRecords: 0, notes: 0, reminders: 0 },
-};
+}
 
 beforeEach(() => {
-  vi.resetAllMocks();
+  vi.resetAllMocks()
   // deleteServiceRecord restocks inventory inside a transaction; run the
   // callback against the mock db and default to a record with no parts.
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db));
-  vi.mocked(db.servicePart.findMany).mockResolvedValue([] as any);
-});
+  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db))
+  vi.mocked(db.servicePart.findMany).mockResolvedValue([] as any)
+})
 
 // ---------------------------------------------------------------------------
 // Vehicle read isolation
 // ---------------------------------------------------------------------------
 
-describe("getVehicle — cross-org isolation", () => {
+describe('getVehicle — cross-org isolation', () => {
   it("returns null (no data) when requesting another org's vehicle ID", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
 
-    const result = await getVehicle(`${ORG_B}-vehicle-id`);
+    const result = await getVehicle(`${ORG_B}-vehicle-id`)
 
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("returns vehicle data for a vehicle that belongs to the caller's org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
 
-    const result = await getVehicle("veh-a");
+    const result = await getVehicle('veh-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any).id).toBe("veh-a");
-  });
+    expect(result.success).toBe(true)
+    expect((result.data as any).id).toBe('veh-a')
+  })
 
   it("db query always scopes vehicles to the caller's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
 
-    await getVehicle("veh-a");
+    await getVehicle('veh-a')
 
     expect(vi.mocked(db.vehicle.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Vehicle list isolation
 // ---------------------------------------------------------------------------
 
-describe("getVehicles — org scoping", () => {
+describe('getVehicles — org scoping', () => {
   it("list query always includes the caller's organizationId in the where clause", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findMany).mockResolvedValue([ORG_A_VEHICLE] as any);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findMany).mockResolvedValue([ORG_A_VEHICLE] as any)
 
-    await getVehicles();
+    await getVehicles()
 
     expect(vi.mocked(db.vehicle.findMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
-});
+    )
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Vehicle update isolation
 // ---------------------------------------------------------------------------
 
-describe("updateVehicle — cross-org isolation", () => {
+describe('updateVehicle — cross-org isolation', () => {
   it("returns error when targeting another org's vehicle", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // updateMany with org filter matches 0 rows for a cross-org id
-    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 0 } as any);
+    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await updateVehicle({ id: `${ORG_B}-vehicle-id`, make: "Hacked" });
+    const result = await updateVehicle({ id: `${ORG_B}-vehicle-id`, make: 'Hacked' })
 
-    expect(result.success).toBe(false);
-  });
+    expect(result.success).toBe(false)
+  })
 
-  it("update query always includes organizationId to prevent cross-org writes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any);
-    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 1 } as any);
+  it('update query always includes organizationId to prevent cross-org writes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
+    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    await updateVehicle({ id: "veh-a", make: "Toyota" });
+    await updateVehicle({ id: 'veh-a', make: 'Toyota' })
 
     expect(vi.mocked(db.vehicle.updateMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully updates a vehicle belonging to the caller's org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any);
-    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(ORG_A_VEHICLE as any)
+    vi.mocked(db.vehicle.updateMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await updateVehicle({ id: "veh-a", make: "Toyota", model: "Updated" });
+    const result = await updateVehicle({ id: 'veh-a', make: 'Toyota', model: 'Updated' })
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Vehicle delete isolation
 // ---------------------------------------------------------------------------
 
-describe("deleteVehicle — cross-org isolation", () => {
+describe('deleteVehicle — cross-org isolation', () => {
   it("returns error when deleting another org's vehicle", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     // findFirst returns null (cross-org vehicle invisible to caller)
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null);
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
     // deleteMany with org filter matches 0 rows
-    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 0 } as any);
+    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 0 } as any)
 
-    const result = await deleteVehicle(`${ORG_B}-vehicle-id`);
+    const result = await deleteVehicle(`${ORG_B}-vehicle-id`)
 
-    expect(result.success).toBe(false);
-  });
+    expect(result.success).toBe(false)
+  })
 
-  it("delete query always includes organizationId to prevent cross-org deletes", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ imageUrl: null, serviceRecords: [] } as any);
-    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 1 } as any);
+  it('delete query always includes organizationId to prevent cross-org deletes', async () => {
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ imageUrl: null, serviceRecords: [] } as any)
+    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    await deleteVehicle("veh-a");
+    await deleteVehicle('veh-a')
 
     expect(vi.mocked(db.vehicle.deleteMany)).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully deletes a vehicle belonging to the caller's org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ imageUrl: null, serviceRecords: [] } as any);
-    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 1 } as any);
+    setupOrgAOwner()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ imageUrl: null, serviceRecords: [] } as any)
+    vi.mocked(db.vehicle.deleteMany).mockResolvedValue({ count: 1 } as any)
 
-    const result = await deleteVehicle("veh-a");
+    const result = await deleteVehicle('veh-a')
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
 // ---------------------------------------------------------------------------
 // Service record isolation
 // ---------------------------------------------------------------------------
 
-describe("getServiceRecord — cross-org isolation", () => {
+describe('getServiceRecord — cross-org isolation', () => {
   it("returns null data (not an error) when querying another org's service record", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
 
-    const result = await getServiceRecord(`${ORG_B}-record-id`);
+    const result = await getServiceRecord(`${ORG_B}-record-id`)
 
     // Returns success:true with null data — caller must check for null.
     // Note: inconsistent with getVehicle / getQuote which throw. Not a
     // security issue (no cross-org data is returned) but worth noting.
-    expect(result.success).toBe(true);
-    expect(result.data).toBeNull();
-  });
+    expect(result.success).toBe(true)
+    expect(result.data).toBeNull()
+  })
 
   it("db query scopes service records to the caller's org", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
 
-    await getServiceRecord("sr-a");
+    await getServiceRecord('sr-a')
 
     // Records carry their own organizationId now (counter sales have no
     // vehicle), so scoping is on the record itself.
@@ -271,63 +271,63 @@ describe("getServiceRecord — cross-org isolation", () => {
       expect.objectContaining({
         where: expect.objectContaining({ organizationId: ORG_A }),
       })
-    );
-  });
+    )
+  })
 
   it("returns service record data when querying the caller's own record", async () => {
-    setupOrgAOwner();
-    const record = { id: "sr-a", vehicleId: "veh-a", title: "Oil Change" };
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(record as any);
+    setupOrgAOwner()
+    const record = { id: 'sr-a', vehicleId: 'veh-a', title: 'Oil Change' }
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(record as any)
 
-    const result = await getServiceRecord("sr-a");
+    const result = await getServiceRecord('sr-a')
 
-    expect(result.success).toBe(true);
-    expect((result.data as any)?.id).toBe("sr-a");
-  });
-});
+    expect(result.success).toBe(true)
+    expect((result.data as any)?.id).toBe('sr-a')
+  })
+})
 
-describe("deleteServiceRecord — cross-org isolation", () => {
+describe('deleteServiceRecord — cross-org isolation', () => {
   it("returns 'Record not found' error when targeting another org's service record", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
 
-    const result = await deleteServiceRecord(`${ORG_B}-record-id`);
+    const result = await deleteServiceRecord(`${ORG_B}-record-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Record not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Record not found')
+  })
 
   it("successfully deletes a service record belonging to the caller's org", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
-      id: "sr-a",
-      vehicleId: "veh-a",
+      id: 'sr-a',
+      vehicleId: 'veh-a',
       attachments: [],
-    } as any);
-    vi.mocked(db.serviceRecord.delete).mockResolvedValue({} as any);
+    } as any)
+    vi.mocked(db.serviceRecord.delete).mockResolvedValue({} as any)
 
-    const result = await deleteServiceRecord("sr-a");
+    const result = await deleteServiceRecord('sr-a')
 
-    expect(result.success).toBe(true);
-  });
-});
+    expect(result.success).toBe(true)
+  })
+})
 
-describe("deleteServiceAttachment — cross-org isolation", () => {
+describe('deleteServiceAttachment — cross-org isolation', () => {
   it("returns 'Attachment not found' error when targeting another org's attachment", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceAttachment.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.serviceAttachment.findFirst).mockResolvedValue(null)
 
-    const result = await deleteServiceAttachment(`${ORG_B}-attachment-id`);
+    const result = await deleteServiceAttachment(`${ORG_B}-attachment-id`)
 
-    expect(result.success).toBe(false);
-    expect(result.error).toBe("Attachment not found");
-  });
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Attachment not found')
+  })
 
   it("attachment lookup always scopes through the record's organizationId", async () => {
-    setupOrgAOwner();
-    vi.mocked(db.serviceAttachment.findFirst).mockResolvedValue(null);
+    setupOrgAOwner()
+    vi.mocked(db.serviceAttachment.findFirst).mockResolvedValue(null)
 
-    await deleteServiceAttachment("att-x");
+    await deleteServiceAttachment('att-x')
 
     expect(vi.mocked(db.serviceAttachment.findFirst)).toHaveBeenCalledWith(
       expect.objectContaining({
@@ -335,21 +335,21 @@ describe("deleteServiceAttachment — cross-org isolation", () => {
           serviceRecord: expect.objectContaining({ organizationId: ORG_A }),
         }),
       })
-    );
-  });
+    )
+  })
 
   it("successfully deletes an attachment belonging to the caller's org", async () => {
-    setupOrgAOwner();
+    setupOrgAOwner()
     vi.mocked(db.serviceAttachment.findFirst).mockResolvedValue({
-      id: "att-a",
-      fileUrl: "uploads/photo.jpg",
-      serviceRecord: { vehicleId: "veh-a", id: "sr-a" },
-    } as any);
-    vi.mocked(db.serviceAttachment.delete).mockResolvedValue({} as any);
-
-    const result = await deleteServiceAttachment("att-a");
-
-    expect(result.success).toBe(true);
-    expect((result.data as any)?.deleted).toBe(true);
-  });
-});
+      id: 'att-a',
+      fileUrl: 'uploads/photo.jpg',
+      serviceRecord: { vehicleId: 'veh-a', id: 'sr-a' },
+    } as any)
+    vi.mocked(db.serviceAttachment.delete).mockResolvedValue({} as any)
+
+    const result = await deleteServiceAttachment('att-a')
+
+    expect(result.success).toBe(true)
+    expect((result.data as any)?.deleted).toBe(true)
+  })
+})

+ 41 - 50
src/__tests__/setup.ts

@@ -1,81 +1,72 @@
-import "@testing-library/jest-dom";
-import { vi } from "vitest";
-import fs from "fs";
-import path from "path";
+import '@testing-library/jest-dom'
+import { vi } from 'vitest'
+import fs from 'fs'
+import path from 'path'
 
 // ---------------------------------------------------------------------------
 // Load all English translation files so the next-intl mock resolves keys to
 // real English strings. This lets existing test assertions (e.g.
 // getByText("Download PDF")) continue to work after the i18n migration.
 // ---------------------------------------------------------------------------
-const messagesDir = path.resolve(process.cwd(), "messages/en");
-const allMessages: Record<string, unknown> = {};
+const messagesDir = path.resolve(process.cwd(), 'messages/en')
+const allMessages: Record<string, unknown> = {}
 
 for (const file of fs.readdirSync(messagesDir)) {
-  if (file.endsWith(".json")) {
-    const ns = file.replace(".json", "");
-    allMessages[ns] = JSON.parse(
-      fs.readFileSync(path.join(messagesDir, file), "utf-8"),
-    );
+  if (file.endsWith('.json')) {
+    const ns = file.replace('.json', '')
+    allMessages[ns] = JSON.parse(fs.readFileSync(path.join(messagesDir, file), 'utf-8'))
   }
 }
 
 /** Resolve a dot-separated key path inside a nested object. */
 function resolve(obj: unknown, keyPath: string): string | undefined {
-  let cur = obj;
-  for (const seg of keyPath.split(".")) {
-    if (cur == null || typeof cur !== "object") return undefined;
-    cur = (cur as Record<string, unknown>)[seg];
+  let cur = obj
+  for (const seg of keyPath.split('.')) {
+    if (cur == null || typeof cur !== 'object') return undefined
+    cur = (cur as Record<string, unknown>)[seg]
   }
-  return typeof cur === "string" ? cur : undefined;
+  return typeof cur === 'string' ? cur : undefined
 }
 
 /** Replace `{name}` placeholders with values from the given record. */
-function interpolate(
-  tpl: string,
-  values?: Record<string, unknown>,
-): string {
-  if (!values) return tpl;
+function interpolate(tpl: string, values?: Record<string, unknown>): string {
+  if (!values) return tpl
   return Object.entries(values).reduce(
-    (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
-    tpl,
-  );
+    (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)),
+    tpl
+  )
 }
 
 // Mock next-intl globally so components using useTranslations can render in tests.
-vi.mock("next-intl", () => ({
+vi.mock('next-intl', () => ({
   useTranslations: (namespace: string) => {
     // Namespace examples: "share.invoice", "auth.resetPassword", "common"
-    const parts = namespace.split(".");
-    const file = parts[0];
-    const sub = parts.slice(1).join(".");
+    const parts = namespace.split('.')
+    const file = parts[0]
+    const sub = parts.slice(1).join('.')
 
-    let messages: unknown = allMessages[file];
-    if (sub && messages && typeof messages === "object") {
-      messages = (messages as Record<string, unknown>)[sub];
+    let messages: unknown = allMessages[file]
+    if (sub && messages && typeof messages === 'object') {
+      messages = (messages as Record<string, unknown>)[sub]
     }
 
     const t = (key: string, values?: Record<string, unknown>) => {
-      const tpl = resolve(messages, key);
-      if (tpl) return interpolate(tpl, values);
+      const tpl = resolve(messages, key)
+      if (tpl) return interpolate(tpl, values)
       // Fallback: return key (with values appended)
-      if (values) return `${key} ${JSON.stringify(values)}`;
-      return key;
-    };
-    t.raw = (key: string) => resolve(messages, key) ?? key;
+      if (values) return `${key} ${JSON.stringify(values)}`
+      return key
+    }
+    t.raw = (key: string) => resolve(messages, key) ?? key
     t.rich = (key: string, values?: Record<string, unknown>) => {
-      const tpl = resolve(messages, key);
-      return tpl ? interpolate(tpl, values) : key;
-    };
-    t.markup = (key: string) => resolve(messages, key) ?? key;
-    t.has = (key: string) => resolve(messages, key) !== undefined;
-    return t;
+      const tpl = resolve(messages, key)
+      return tpl ? interpolate(tpl, values) : key
+    }
+    t.markup = (key: string) => resolve(messages, key) ?? key
+    t.has = (key: string) => resolve(messages, key) !== undefined
+    return t
   },
-  useLocale: () => "en",
+  useLocale: () => 'en',
   useMessages: () => ({}),
-  NextIntlClientProvider: ({
-    children,
-  }: {
-    children: React.ReactNode;
-  }) => children,
-}));
+  NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
+}))

+ 28 - 37
src/app/(authenticated)/admin/admin-nav.tsx

@@ -1,77 +1,68 @@
-"use client";
+'use client'
 
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { cn } from "@/lib/utils";
-import {
-  Building2,
-  Settings,
-  ShieldCheck,
-  Users,
-} from "lucide-react";
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import { Building2, Settings, ShieldCheck, Users } from 'lucide-react'
 
 const adminNav = [
   {
-    titleKey: "nav.overview.title",
-    descriptionKey: "nav.overview.description",
-    href: "/admin",
+    titleKey: 'nav.overview.title',
+    descriptionKey: 'nav.overview.description',
+    href: '/admin',
     icon: ShieldCheck,
   },
   {
-    titleKey: "nav.users.title",
-    descriptionKey: "nav.users.description",
-    href: "/admin/users",
+    titleKey: 'nav.users.title',
+    descriptionKey: 'nav.users.description',
+    href: '/admin/users',
     icon: Users,
   },
   {
-    titleKey: "nav.organizations.title",
-    descriptionKey: "nav.organizations.description",
-    href: "/admin/organizations",
+    titleKey: 'nav.organizations.title',
+    descriptionKey: 'nav.organizations.description',
+    href: '/admin/organizations',
     icon: Building2,
   },
   {
-    titleKey: "nav.settings.title",
-    descriptionKey: "nav.settings.description",
-    href: "/admin/settings",
+    titleKey: 'nav.settings.title',
+    descriptionKey: 'nav.settings.description',
+    href: '/admin/settings',
     icon: Settings,
   },
-];
+]
 
 export function AdminNav() {
-  const pathname = usePathname();
-  const t = useTranslations("admin");
+  const pathname = usePathname()
+  const t = useTranslations('admin')
 
   return (
     <nav className="flex flex-col gap-1">
       {adminNav.map((item) => {
         const isActive =
-          item.href === "/admin"
-            ? pathname === "/admin"
-            : pathname.startsWith(item.href);
+          item.href === '/admin' ? pathname === '/admin' : pathname.startsWith(item.href)
         return (
           <Link
             key={item.href}
             href={item.href}
             className={cn(
-              "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
+              'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors',
               isActive
-                ? "bg-primary/10 text-primary font-medium"
-                : "text-muted-foreground hover:bg-muted hover:text-foreground"
+                ? 'bg-primary/10 text-primary font-medium'
+                : 'text-muted-foreground hover:bg-muted hover:text-foreground'
             )}
           >
             <item.icon className="h-4 w-4 shrink-0" />
             <div className="min-w-0">
-              <p className={cn("truncate", isActive && "font-medium")}>
-                {t(item.titleKey)}
-              </p>
+              <p className={cn('truncate', isActive && 'font-medium')}>{t(item.titleKey)}</p>
               <p className="hidden truncate text-xs text-muted-foreground lg:block">
                 {t(item.descriptionKey)}
               </p>
             </div>
           </Link>
-        );
+        )
       })}
     </nav>
-  );
+  )
 }

+ 14 - 20
src/app/(authenticated)/admin/layout.tsx

@@ -1,27 +1,23 @@
-import { redirect } from "next/navigation";
-import { getTranslations } from "next-intl/server";
-import { getCachedSession } from "@/lib/cached-session";
-import { db } from "@/lib/db";
-import { PageHeader } from "@/components/page-header";
-import { AdminNav } from "./admin-nav";
+import { redirect } from 'next/navigation'
+import { getTranslations } from 'next-intl/server'
+import { getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { PageHeader } from '@/components/page-header'
+import { AdminNav } from './admin-nav'
 
-export default async function AdminLayout({
-  children,
-}: {
-  children: React.ReactNode;
-}) {
-  const session = await getCachedSession();
+export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+  const session = await getCachedSession()
 
-  if (!session?.user?.id) redirect("/auth/sign-in");
+  if (!session?.user?.id) redirect('/auth/sign-in')
 
   const user = await db.user.findUnique({
     where: { id: session.user.id },
     select: { isSuperAdmin: true },
-  });
+  })
 
-  if (!user?.isSuperAdmin) redirect("/");
+  if (!user?.isSuperAdmin) redirect('/')
 
-  const t = await getTranslations("admin");
+  const t = await getTranslations('admin')
 
   return (
     <div className="flex h-svh flex-col">
@@ -29,9 +25,7 @@ export default async function AdminLayout({
       <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">
         <div className="shrink-0">
           <h1 className="text-3xl font-bold tracking-tight">{t('layout.title')}</h1>
-          <p className="mt-1 text-muted-foreground">
-            {t('layout.description')}
-          </p>
+          <p className="mt-1 text-muted-foreground">{t('layout.description')}</p>
         </div>
         <div className="flex min-h-0 flex-1 flex-col gap-6 md:flex-row">
           <aside className="w-full shrink-0 overflow-y-auto md:w-56 lg:w-64">
@@ -41,5 +35,5 @@ export default async function AdminLayout({
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 10 - 10
src/app/(authenticated)/admin/organizations/page.tsx

@@ -1,17 +1,17 @@
-import { getOrganizations } from "@/features/admin/Actions/getOrganizations";
-import { AdminOrganizations } from "@/features/admin/Components/admin-organizations";
+import { getOrganizations } from '@/features/admin/Actions/getOrganizations'
+import { AdminOrganizations } from '@/features/admin/Components/admin-organizations'
 
 export default async function AdminOrganizationsPage({
   searchParams,
 }: {
-  searchParams: Promise<{ search?: string; page?: string; pageSize?: string }>;
+  searchParams: Promise<{ search?: string; page?: string; pageSize?: string }>
 }) {
-  const params = await searchParams;
-  const page = Number(params.page) || 1;
-  const pageSize = Number(params.pageSize) || 20;
-  const search = params.search || "";
+  const params = await searchParams
+  const page = Number(params.page) || 1
+  const pageSize = Number(params.pageSize) || 20
+  const search = params.search || ''
 
-  const result = await getOrganizations({ search, page, pageSize });
+  const result = await getOrganizations({ search, page, pageSize })
 
   const data = result.data ?? {
     organizations: [],
@@ -19,7 +19,7 @@ export default async function AdminOrganizationsPage({
     page: 1,
     pageSize: 20,
     totalPages: 0,
-  };
+  }
 
-  return <AdminOrganizations data={data} search={search} />;
+  return <AdminOrganizations data={data} search={search} />
 }

+ 5 - 5
src/app/(authenticated)/admin/page.tsx

@@ -1,15 +1,15 @@
-import { getAdminStats } from "@/features/admin/Actions/getAdminStats";
-import { AdminOverview } from "@/features/admin/Components/admin-overview";
+import { getAdminStats } from '@/features/admin/Actions/getAdminStats'
+import { AdminOverview } from '@/features/admin/Components/admin-overview'
 
 export default async function AdminPage() {
-  const result = await getAdminStats();
+  const result = await getAdminStats()
 
   const stats = result.data ?? {
     totalUsers: 0,
     totalOrganizations: 0,
     totalActiveSubscriptions: 0,
     totalRevenue: 0,
-  };
+  }
 
-  return <AdminOverview stats={stats} />;
+  return <AdminOverview stats={stats} />
 }

+ 6 - 11
src/app/(authenticated)/admin/settings/page.tsx

@@ -1,15 +1,10 @@
-import { getSystemSettings } from "@/features/admin/Actions/getSystemSettings";
-import { AdminSettings } from "@/features/admin/Components/admin-settings";
-import { isCloudMode } from "@/lib/features";
+import { getSystemSettings } from '@/features/admin/Actions/getSystemSettings'
+import { AdminSettings } from '@/features/admin/Components/admin-settings'
+import { isCloudMode } from '@/lib/features'
 
 export default async function AdminSettingsPage() {
-  const result = await getSystemSettings();
-  const data = result.data ?? {};
+  const result = await getSystemSettings()
+  const data = result.data ?? {}
 
-  return (
-    <AdminSettings
-      initial={data}
-      mode={isCloudMode() ? "cloud" : "self-hosted"}
-    />
-  );
+  return <AdminSettings initial={data} mode={isCloudMode() ? 'cloud' : 'self-hosted'} />
 }

+ 26 - 26
src/app/(authenticated)/ai/_page_disabled.tsx

@@ -1,19 +1,19 @@
-import { getAuthContext } from "@/lib/get-auth-context";
-import { getFeatures } from "@/lib/features";
-import { db } from "@/lib/db";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import { AiChatPage } from "@/features/ai/Components/AiChatPage";
-import { getTranslations } from "next-intl/server";
-import { getCachedMembership } from "@/lib/cached-session";
-import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { getAuthContext } from '@/lib/get-auth-context'
+import { getFeatures } from '@/lib/features'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import { AiChatPage } from '@/features/ai/Components/AiChatPage'
+import { getTranslations } from 'next-intl/server'
+import { getCachedMembership } from '@/lib/cached-session'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 
 export default async function AiAssistantPage() {
-  const authContext = await getAuthContext();
-  const orgId = authContext?.organizationId;
-  const t = await getTranslations("aiChat");
+  const authContext = await getAuthContext()
+  const orgId = authContext?.organizationId
+  const t = await getTranslations('aiChat')
 
-  let aiEnabled = false;
+  let aiEnabled = false
   if (orgId) {
     const [features, aiSettings] = await Promise.all([
       getFeatures(orgId),
@@ -24,28 +24,28 @@ export default async function AiAssistantPage() {
         },
         select: { key: true, value: true },
       }),
-    ]);
-    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
+    ])
+    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]))
     aiEnabled =
       features?.ai === true &&
-      aiMap[SETTING_KEYS.AI_ENABLED] === "true" &&
-      !!aiMap[SETTING_KEYS.AI_API_KEY];
+      aiMap[SETTING_KEYS.AI_ENABLED] === 'true' &&
+      !!aiMap[SETTING_KEYS.AI_API_KEY]
   }
 
   // Check permission for custom role users
   if (aiEnabled && authContext?.userId) {
-    const role = authContext.role;
-    const isOwnerOrAdmin = role === "owner" || role === "admin" || role === "super_admin";
+    const role = authContext.role
+    const isOwnerOrAdmin = role === 'owner' || role === 'admin' || role === 'super_admin'
     if (!isOwnerOrAdmin) {
-      const membership = await getCachedMembership(authContext.userId);
+      const membership = await getCachedMembership(authContext.userId)
       if (membership?.roleId) {
-        const userPermissions = membership?.customRole?.permissions ?? [];
+        const userPermissions = membership?.customRole?.permissions ?? []
         const canAccessAi = hasPermission(userPermissions, {
           action: PermissionAction.READ,
           subject: PermissionSubject.AI_ASSISTANT,
-        });
+        })
         if (!canAccessAi) {
-          aiEnabled = false;
+          aiEnabled = false
         }
       }
     }
@@ -56,10 +56,10 @@ export default async function AiAssistantPage() {
       <div className="flex h-svh flex-col overflow-hidden">
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">{t("notEnabled")}</p>
+          <p className="text-muted-foreground">{t('notEnabled')}</p>
         </div>
       </div>
-    );
+    )
   }
 
   return (
@@ -67,5 +67,5 @@ export default async function AiAssistantPage() {
       <PageHeader />
       <AiChatPage />
     </div>
-  );
+  )
 }

+ 186 - 142
src/app/(authenticated)/audit-log/audit-log-client.tsx

@@ -1,19 +1,19 @@
-"use client";
+'use client'
 
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { interactiveRow } from "@/lib/interactive-row";
-import { useCallback, useState, useTransition } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useCallback, useState, useTransition } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
+} from '@/components/ui/select'
 import {
   Table,
   TableBody,
@@ -21,56 +21,58 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   Dialog,
   DialogContent,
   DialogHeader,
   DialogTitle,
   DialogDescription,
-} from "@/components/ui/dialog";
+} from '@/components/ui/dialog'
 import {
   ContextMenu,
   ContextMenuContent,
   ContextMenuItem,
   ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import { useFormatDate } from "@/lib/use-format-date";
-import { cn } from "@/lib/utils";
-import { ExternalLink, Search, Loader2 } from "lucide-react";
+} from '@/components/ui/context-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { ExternalLink, Search, Loader2 } from 'lucide-react'
 
 type AuditLogData = {
   logs: {
-    id: string;
-    timestamp: Date;
-    action: string;
-    entity: string | null;
-    entityId: string | null;
-    message: string | null;
+    id: string
+    timestamp: Date
+    action: string
+    entity: string | null
+    entityId: string | null
+    message: string | null
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    metadata: any;
-    ip: string | null;
-    userAgent: string | null;
-    user: { id: string; name: string | null; email: string | null } | null;
-  }[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
+    metadata: any
+    ip: string | null
+    userAgent: string | null
+    user: { id: string; name: string | null; email: string | null } | null
+  }[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
   filters: {
-    actions: string[];
-    entities: string[];
-    users: { id: string; name: string | null; email: string | null }[];
-  };
-};
+    actions: string[]
+    entities: string[]
+    users: { id: string; name: string | null; email: string | null }[]
+  }
+}
 
 function getActionColor(action: string): string {
-  if (action.includes("delete") || action.includes("remove")) return "destructive";
-  if (action.includes("create") || action.includes("invite") || action.includes("send")) return "default";
-  if (action.includes("update") || action.includes("status") || action.includes("complete")) return "secondary";
-  return "outline";
+  if (action.includes('delete') || action.includes('remove')) return 'destructive'
+  if (action.includes('create') || action.includes('invite') || action.includes('send'))
+    return 'default'
+  if (action.includes('update') || action.includes('status') || action.includes('complete'))
+    return 'secondary'
+  return 'outline'
 }
 
 export function AuditLogClient({
@@ -80,28 +82,28 @@ export function AuditLogClient({
   entityFilter,
   userFilter,
 }: {
-  data: AuditLogData;
-  search: string;
-  actionFilter: string;
-  entityFilter: string;
-  userFilter: string;
+  data: AuditLogData
+  search: string
+  actionFilter: string
+  entityFilter: string
+  userFilter: string
 }) {
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
-  const [selectedLog, setSelectedLog] = useState<AuditLogData["logs"][number] | null>(null);
-  const { formatDateTime } = useFormatDate();
-  const t = useTranslations("audit");
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
+  const [selectedLog, setSelectedLog] = useState<AuditLogData['logs'][number] | null>(null)
+  const { formatDateTime } = useFormatDate()
+  const t = useTranslations('audit')
 
   const getActionLabel = (action: string) => {
     // next-intl returns a placeholder for a missing message rather than
     // throwing, so this asks first instead of catching something that never
     // happens. replaceAll because an action can carry more than one separator.
-    const key = `actions.${action.replaceAll(".", "_")}`;
-    return t.has(key) ? t(key) : action;
-  };
+    const key = `actions.${action.replaceAll('.', '_')}`
+    return t.has(key) ? t(key) : action
+  }
 
   /**
    * The sentence, in the reader's language where we can manage it.
@@ -111,36 +113,39 @@ export function AuditLogClient({
    * the fallback rather than a blank cell: a log entry nobody can read is
    * worse than one in the wrong language.
    */
-  const getSummary = (log: AuditLogData["logs"][number]) => {
+  const getSummary = (log: AuditLogData['logs'][number]) => {
     const details = log.metadata?.details as
       | { key?: string; params?: Record<string, string | number> }
-      | undefined;
+      | undefined
     if (details?.key) {
-      const key = `summary.${details.key}`;
-      if (t.has(key)) return t(key, details.params);
+      const key = `summary.${details.key}`
+      if (t.has(key)) return t(key, details.params)
     }
-    return log.message;
-  };
+    return log.message
+  }
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "" || value === "all") {
-          newParams.delete(key);
+        if (value === undefined || value === '' || value === 'all') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && ("search" in params || "action" in params || "entity" in params || "userId" in params)) {
-        newParams.delete("page");
+      if (
+        !('page' in params) &&
+        ('search' in params || 'action' in params || 'entity' in params || 'userId' in params)
+      ) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   return (
     <div className="space-y-4">
@@ -149,25 +154,27 @@ export function AuditLogClient({
         <div className="relative flex-1">
           <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
-            placeholder={t("searchPlaceholder")}
+            placeholder={t('searchPlaceholder')}
             defaultValue={search}
             onChange={(e) => {
-              const value = e.target.value;
-              if (value === search) return;
-              navigate({ search: value || undefined });
+              const value = e.target.value
+              if (value === search) return
+              navigate({ search: value || undefined })
             }}
             className="h-9 pl-9 pr-9"
             {...tableNav.searchInputProps}
           />
-          {isPending && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />}
+          {isPending && (
+            <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
+          )}
         </div>
         <div className="flex flex-wrap gap-2">
           <Select value={actionFilter} onValueChange={(v) => navigate({ action: v })}>
             <SelectTrigger className="h-9 w-[180px]">
-              <SelectValue placeholder={t("allActions")} />
+              <SelectValue placeholder={t('allActions')} />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("allActions")}</SelectItem>
+              <SelectItem value="all">{t('allActions')}</SelectItem>
               {data.filters.actions.map((a) => (
                 <SelectItem key={a} value={a}>
                   {getActionLabel(a)}
@@ -177,10 +184,10 @@ export function AuditLogClient({
           </Select>
           <Select value={entityFilter} onValueChange={(v) => navigate({ entity: v })}>
             <SelectTrigger className="h-9 w-[160px]">
-              <SelectValue placeholder={t("allEntities")} />
+              <SelectValue placeholder={t('allEntities')} />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("allEntities")}</SelectItem>
+              <SelectItem value="all">{t('allEntities')}</SelectItem>
               {data.filters.entities.map((e) => (
                 <SelectItem key={e} value={e}>
                   {e}
@@ -190,10 +197,10 @@ export function AuditLogClient({
           </Select>
           <Select value={userFilter} onValueChange={(v) => navigate({ userId: v })}>
             <SelectTrigger className="h-9 w-[160px]">
-              <SelectValue placeholder={t("allUsers")} />
+              <SelectValue placeholder={t('allUsers')} />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("allUsers")}</SelectItem>
+              <SelectItem value="all">{t('allUsers')}</SelectItem>
               {data.filters.users.map((u) => (
                 <SelectItem key={u.id} value={u.id}>
                   {u.name || u.email || u.id.substring(0, 8)}
@@ -208,7 +215,7 @@ export function AuditLogClient({
       <div className="space-y-2 md:hidden">
         {data.logs.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {t("noLogsFound")}
+            {t('noLogsFound')}
           </div>
         ) : (
           data.logs.map((log) => (
@@ -219,19 +226,24 @@ export function AuditLogClient({
               className="w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50"
             >
               <div className="flex items-start justify-between gap-3">
-                <Badge variant={getActionColor(log.action) as "default" | "secondary" | "destructive" | "outline"}>
+                <Badge
+                  variant={
+                    getActionColor(log.action) as
+                      | 'default'
+                      | 'secondary'
+                      | 'destructive'
+                      | 'outline'
+                  }
+                >
                   {getActionLabel(log.action)}
                 </Badge>
-                <span
-                  className="shrink-0 text-xs text-muted-foreground"
-                  suppressHydrationWarning
-                >
+                <span className="shrink-0 text-xs text-muted-foreground" suppressHydrationWarning>
                   {formatDateTime(log.timestamp)}
                 </span>
               </div>
               {getSummary(log) && <p className="mt-1.5 text-sm">{getSummary(log)}</p>}
               <p className="mt-1 truncate text-xs text-muted-foreground">
-                {log.user?.name || log.user?.email || t("unknownUser")}
+                {log.user?.name || log.user?.email || t('unknownUser')}
                 {log.entityId && (
                   <span className="font-mono"> · {log.entityId.substring(0, 8)}</span>
                 )}
@@ -247,50 +259,64 @@ export function AuditLogClient({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead className="w-[170px]">{t("timestamp")}</TableHead>
-              <TableHead className="w-[140px]">{t("user")}</TableHead>
-              <TableHead className="w-[200px]">{t("action")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("details")}</TableHead>
-              <TableHead className="hidden lg:table-cell w-[100px]">{t("entityId")}</TableHead>
+              <TableHead className="w-[170px]">{t('timestamp')}</TableHead>
+              <TableHead className="w-[140px]">{t('user')}</TableHead>
+              <TableHead className="w-[200px]">{t('action')}</TableHead>
+              <TableHead className="hidden md:table-cell">{t('details')}</TableHead>
+              <TableHead className="hidden lg:table-cell w-[100px]">{t('entityId')}</TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
             {data.logs.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
-                  {t("noLogsFound")}
+                  {t('noLogsFound')}
                 </TableCell>
               </TableRow>
             ) : (
               data.logs.map((log) => (
                 <ContextMenu key={log.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow className="cursor-pointer" {...interactiveRow(() => setSelectedLog(log))}>
-                  <TableCell className="text-xs text-muted-foreground whitespace-nowrap" suppressHydrationWarning>
-                    {formatDateTime(log.timestamp)}
-                  </TableCell>
-                  <TableCell className="text-sm">
-                    {log.user?.name || log.user?.email || t("unknownUser")}
-                  </TableCell>
-                  <TableCell>
-                    <Badge variant={getActionColor(log.action) as "default" | "secondary" | "destructive" | "outline"}>
-                      {getActionLabel(log.action)}
-                    </Badge>
-                  </TableCell>
-                  <TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[300px]">
-                    {getSummary(log)}
-                  </TableCell>
-                  <TableCell className="hidden lg:table-cell font-mono text-xs text-muted-foreground">
-                    {log.entityId?.substring(0, 8)}
-                  </TableCell>
-                </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => setSelectedLog(log)}>
-                    <ExternalLink className="mr-2 h-4 w-4" />
-                    {t("logDetails")}
-                  </ContextMenuItem>
-                </ContextMenuContent>
+                  <ContextMenuTrigger asChild>
+                    <TableRow
+                      className="cursor-pointer"
+                      {...interactiveRow(() => setSelectedLog(log))}
+                    >
+                      <TableCell
+                        className="text-xs text-muted-foreground whitespace-nowrap"
+                        suppressHydrationWarning
+                      >
+                        {formatDateTime(log.timestamp)}
+                      </TableCell>
+                      <TableCell className="text-sm">
+                        {log.user?.name || log.user?.email || t('unknownUser')}
+                      </TableCell>
+                      <TableCell>
+                        <Badge
+                          variant={
+                            getActionColor(log.action) as
+                              | 'default'
+                              | 'secondary'
+                              | 'destructive'
+                              | 'outline'
+                          }
+                        >
+                          {getActionLabel(log.action)}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[300px]">
+                        {getSummary(log)}
+                      </TableCell>
+                      <TableCell className="hidden lg:table-cell font-mono text-xs text-muted-foreground">
+                        {log.entityId?.substring(0, 8)}
+                      </TableCell>
+                    </TableRow>
+                  </ContextMenuTrigger>
+                  <ContextMenuContent className="min-w-52">
+                    <ContextMenuItem onClick={() => setSelectedLog(log)}>
+                      <ExternalLink className="mr-2 h-4 w-4" />
+                      {t('logDetails')}
+                    </ContextMenuItem>
+                  </ContextMenuContent>
                 </ContextMenu>
               ))
             )}
@@ -311,26 +337,41 @@ export function AuditLogClient({
       <Dialog open={!!selectedLog} onOpenChange={(open) => !open && setSelectedLog(null)}>
         <DialogContent>
           <DialogHeader>
-            <DialogTitle>{t("logDetails")}</DialogTitle>
+            <DialogTitle>{t('logDetails')}</DialogTitle>
             <DialogDescription>
               {selectedLog && getActionLabel(selectedLog.action)}
             </DialogDescription>
           </DialogHeader>
           {selectedLog && (
             <div className="grid gap-3 text-sm">
-              <DetailRow label={t("timestamp")} value={formatDateTime(selectedLog.timestamp)} suppressHydrationWarning />
-              <DetailRow label={t("user")} value={selectedLog.user?.name || selectedLog.user?.email || t("unknownUser")} />
-              <DetailRow label={t("action")} value={getActionLabel(selectedLog.action)} />
+              <DetailRow
+                label={t('timestamp')}
+                value={formatDateTime(selectedLog.timestamp)}
+                suppressHydrationWarning
+              />
+              <DetailRow
+                label={t('user')}
+                value={selectedLog.user?.name || selectedLog.user?.email || t('unknownUser')}
+              />
+              <DetailRow label={t('action')} value={getActionLabel(selectedLog.action)} />
               {getSummary(selectedLog) && (
-                <DetailRow label={t("details")} value={getSummary(selectedLog) as string} />
+                <DetailRow label={t('details')} value={getSummary(selectedLog) as string} />
+              )}
+              {selectedLog.entity && <DetailRow label={t('entity')} value={selectedLog.entity} />}
+              {selectedLog.entityId && (
+                <DetailRow label={t('entityId')} value={selectedLog.entityId} mono />
+              )}
+              {selectedLog.ip && <DetailRow label={t('ipAddress')} value={selectedLog.ip} mono />}
+              {selectedLog.userAgent && (
+                <DetailRow
+                  label={t('userAgentLabel')}
+                  value={selectedLog.userAgent}
+                  className="break-all"
+                />
               )}
-              {selectedLog.entity && <DetailRow label={t("entity")} value={selectedLog.entity} />}
-              {selectedLog.entityId && <DetailRow label={t("entityId")} value={selectedLog.entityId} mono />}
-              {selectedLog.ip && <DetailRow label={t("ipAddress")} value={selectedLog.ip} mono />}
-              {selectedLog.userAgent && <DetailRow label={t("userAgentLabel")} value={selectedLog.userAgent} className="break-all" />}
               {selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0 && (
                 <div>
-                  <span className="font-medium text-muted-foreground">{t("metadata")}</span>
+                  <span className="font-medium text-muted-foreground">{t('metadata')}</span>
                   <pre className="mt-1 rounded-md bg-muted p-2 text-xs overflow-x-auto">
                     {JSON.stringify(selectedLog.metadata, null, 2)}
                   </pre>
@@ -341,7 +382,7 @@ export function AuditLogClient({
         </DialogContent>
       </Dialog>
     </div>
-  );
+  )
 }
 
 function DetailRow({
@@ -351,18 +392,21 @@ function DetailRow({
   className,
   suppressHydrationWarning,
 }: {
-  label: string;
-  value: string;
-  mono?: boolean;
-  className?: string;
-  suppressHydrationWarning?: boolean;
+  label: string
+  value: string
+  mono?: boolean
+  className?: string
+  suppressHydrationWarning?: boolean
 }) {
   return (
     <div className="flex flex-col gap-0.5">
       <span className="font-medium text-muted-foreground">{label}</span>
-      <span className={cn(mono ? "font-mono text-xs" : "", className)} suppressHydrationWarning={suppressHydrationWarning}>
+      <span
+        className={cn(mono ? 'font-mono text-xs' : '', className)}
+        suppressHydrationWarning={suppressHydrationWarning}
+      >
         {value}
       </span>
     </div>
-  );
+  )
 }

+ 19 - 21
src/app/(authenticated)/audit-log/page.tsx

@@ -1,20 +1,20 @@
-import { getAuditLogsPaginated } from "@/features/audit/Actions/auditActions";
-import { PageHeader } from "@/components/page-header";
-import { AuditLogClient } from "./audit-log-client";
+import { getAuditLogsPaginated } from '@/features/audit/Actions/auditActions'
+import { PageHeader } from '@/components/page-header'
+import { AuditLogClient } from './audit-log-client'
 
 export default async function AuditLogPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    action?: string;
-    entity?: string;
-    userId?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    action?: string
+    entity?: string
+    userId?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
 
   const result = await getAuditLogsPaginated({
     page: params.page ? parseInt(params.page) : 1,
@@ -23,19 +23,17 @@ export default async function AuditLogPage({
     action: params.action,
     entity: params.entity,
     userId: params.userId,
-  });
+  })
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load audit logs"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load audit logs'}</p>
         </div>
       </>
-    );
+    )
   }
 
   return (
@@ -44,12 +42,12 @@ export default async function AuditLogPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <AuditLogClient
           data={result.data}
-          search={params.search || ""}
-          actionFilter={params.action || "all"}
-          entityFilter={params.entity || "all"}
-          userFilter={params.userId || "all"}
+          search={params.search || ''}
+          actionFilter={params.action || 'all'}
+          entityFilter={params.entity || 'all'}
+          userFilter={params.userId || 'all'}
         />
       </div>
     </>
-  );
+  )
 }

+ 218 - 190
src/app/(authenticated)/billing/billing-client.tsx

@@ -1,18 +1,17 @@
-"use client";
+'use client'
 
-
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { interactiveRow } from '@/lib/interactive-row';
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
-import { useCallback, useTransition } from "react";
-import Link from "next/link";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { useFormatDate } from "@/lib/use-format-date";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent } from "@/components/ui/card";
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
+import { useCallback, useTransition } from 'react'
+import Link from 'next/link'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { useFormatDate } from '@/lib/use-format-date'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent } from '@/components/ui/card'
 import {
   Table,
   TableBody,
@@ -20,9 +19,9 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableCellLink } from "@/components/table-cell-link";
+} from '@/components/ui/table'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableCellLink } from '@/components/table-cell-link'
 import {
   Loader2,
   Search,
@@ -32,125 +31,127 @@ import {
   ArrowDown,
   ArrowUp,
   ArrowUpDown,
-} from "lucide-react";
-import { cn } from "@/lib/utils";
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 
 interface BillingRecord {
-  id: string;
-  title: string;
-  invoiceNumber: string | null;
-  serviceDate: Date;
-  startDateTime: Date | null;
-  totalAmount: number;
-  totalPaid: number;
-  status: string;
+  id: string
+  title: string
+  invoiceNumber: string | null
+  serviceDate: Date
+  startDateTime: Date | null
+  totalAmount: number
+  totalPaid: number
+  status: string
   vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  } | null;
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  } | null
   customer: {
-    id: string;
-    name: string;
-  } | null;
+    id: string
+    name: string
+  } | null
 }
 
 interface PaginatedBillingData {
-  records: BillingRecord[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
+  records: BillingRecord[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
   summary: {
-    totalRevenue: number;
-    totalPaid: number;
-    outstanding: number;
-    paidCount: number;
-    unpaidCount: number;
-    partialCount: number;
-  };
+    totalRevenue: number
+    totalPaid: number
+    outstanding: number
+    paidCount: number
+    unpaidCount: number
+    partialCount: number
+  }
 }
 
 interface BillingClientProps {
-  data: PaginatedBillingData;
-  currencyCode?: string;
-  search: string;
-  statusFilter: string;
-  sortBy?: string;
-  sortOrder?: "asc" | "desc";
+  data: PaginatedBillingData
+  currencyCode?: string
+  search: string
+  statusFilter: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
 }
 
 const STATUS_TABS = [
-  { titleKey: "history.statusAll", value: "all" },
-  { titleKey: "history.statusPaid", value: "paid" },
-  { titleKey: "history.statusPartial", value: "partial" },
-  { titleKey: "history.statusUnpaid", value: "unpaid" },
-] as const;
+  { titleKey: 'history.statusAll', value: 'all' },
+  { titleKey: 'history.statusPaid', value: 'paid' },
+  { titleKey: 'history.statusPartial', value: 'partial' },
+  { titleKey: 'history.statusUnpaid', value: 'unpaid' },
+] as const
 
 export default function BillingClient({
   data,
-  currencyCode = "USD",
+  currencyCode = 'USD',
   search,
   statusFilter,
-  sortBy = "",
-  sortOrder = "desc",
+  sortBy = '',
+  sortOrder = 'desc',
 }: BillingClientProps) {
-  const formatCurrency = useFormatCurrency();
-  const router = useRouter();
-  const t = useTranslations("billing");
-  const { formatDate } = useFormatDate();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
+  const formatCurrency = useFormatCurrency()
+  const router = useRouter()
+  const t = useTranslations('billing')
+  const { formatDate } = useFormatDate()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
 
   const createQueryString = useCallback(
     (params: Record<string, string>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
         if (value) {
-          newParams.set(key, value);
+          newParams.set(key, value)
         } else {
-          newParams.delete(key);
+          newParams.delete(key)
         }
       }
-      return newParams.toString();
+      return newParams.toString()
     },
     [searchParams]
-  );
+  )
 
   const handleSort = useCallback(
     (column: string) => {
-      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
       startTransition(() => {
         router.push(
-          `${pathname}?${createQueryString({ sortBy: column, sortOrder: newOrder, page: "1" })}`
-        );
-      });
+          `${pathname}?${createQueryString({ sortBy: column, sortOrder: newOrder, page: '1' })}`
+        )
+      })
     },
     [createQueryString, pathname, router, sortBy, sortOrder]
-  );
+  )
 
   const SortIcon = ({ column }: { column: string }) => {
-    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
-    return sortOrder === "asc"
-      ? <ArrowUp className="ml-1 h-3 w-3" />
-      : <ArrowDown className="ml-1 h-3 w-3" />;
-  };
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
 
   const handleStatusChange = (status: string) => {
     startTransition(() => {
       router.push(
         `${pathname}?${createQueryString({
-          status: status === "all" ? "" : status,
-          page: "1",
+          status: status === 'all' ? '' : status,
+          page: '1',
         })}`
-      );
-    });
-  };
+      )
+    })
+  }
 
   // Live search: filters as you type, no Enter required. Submitting the
   // form (Enter) commits immediately, bypassing the debounce.
@@ -162,68 +163,82 @@ export default function BillingClient({
     startTransition(() => {
       router.push(
         `${pathname}?${createQueryString({
-          search: term ?? "",
-          page: "1",
+          search: term ?? '',
+          page: '1',
         })}`
-      );
-    });
-  });
+      )
+    })
+  })
 
   const handleNavigate = (params: Record<string, string | number | undefined>) => {
-    const merged: Record<string, string> = {};
+    const merged: Record<string, string> = {}
     for (const [key, value] of Object.entries(params)) {
-      if (value !== undefined && value !== "") {
-        merged[key] = String(value);
+      if (value !== undefined && value !== '') {
+        merged[key] = String(value)
       }
     }
     startTransition(() => {
-      router.push(`${pathname}?${createQueryString(merged)}`);
-    });
-  };
+      router.push(`${pathname}?${createQueryString(merged)}`)
+    })
+  }
 
   const handleRowClick = (record: BillingRecord) => {
     router.push(
-      record.vehicle
-        ? `/vehicles/${record.vehicle.id}/service/${record.id}`
-        : `/sales/${record.id}`
-    );
-  };
+      record.vehicle ? `/vehicles/${record.vehicle.id}/service/${record.id}` : `/sales/${record.id}`
+    )
+  }
 
-  const fmt = (amount: number) => formatCurrency(amount, currencyCode);
+  const fmt = (amount: number) => formatCurrency(amount, currencyCode)
 
   const getStatusBadge = (status: string) => {
     switch (status.toLowerCase()) {
-      case "paid":
-        return <Badge className="bg-emerald-100 text-emerald-800 hover:bg-emerald-100">{t("history.statusPaid")}</Badge>;
-      case "partial":
-        return <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">{t("history.statusPartial")}</Badge>;
-      case "unpaid":
-        return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{t("history.statusUnpaid")}</Badge>;
+      case 'paid':
+        return (
+          <Badge className="bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
+            {t('history.statusPaid')}
+          </Badge>
+        )
+      case 'partial':
+        return (
+          <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">
+            {t('history.statusPartial')}
+          </Badge>
+        )
+      case 'unpaid':
+        return (
+          <Badge className="bg-red-100 text-red-800 hover:bg-red-100">
+            {t('history.statusUnpaid')}
+          </Badge>
+        )
       default:
-        return <Badge variant="secondary">{status}</Badge>;
+        return <Badge variant="secondary">{status}</Badge>
     }
-  };
+  }
 
   const getBalanceColor = (status: string) => {
     switch (status.toLowerCase()) {
-      case "paid":
-        return "text-emerald-600";
-      case "partial":
-        return "text-amber-600";
-      case "unpaid":
-        return "text-red-600";
+      case 'paid':
+        return 'text-emerald-600'
+      case 'partial':
+        return 'text-amber-600'
+      case 'unpaid':
+        return 'text-red-600'
       default:
-        return "";
+        return ''
     }
-  };
+  }
 
   return (
     <div className="space-y-6">
       {/* Navigation */}
       <div className="flex items-center gap-3">
-        <Button variant="outline" size="sm" disabled>{t("history.title")}</Button>
+        <Button variant="outline" size="sm" disabled>
+          {t('history.title')}
+        </Button>
         <Link href="/billing/recurring">
-          <Button variant="outline" size="sm">{t("history.recurring")}</Button>
+          <Button variant="outline" size="sm">
+            {t('history.recurring')}
+          </Button>
         </Link>
       </div>
 
@@ -236,10 +251,8 @@ export default function BillingClient({
               <DollarSign className="h-4 w-4 text-blue-600" />
             </div>
             <div className="min-w-0">
-              <p className="text-xs text-muted-foreground">{t("history.totalRevenue")}</p>
-              <p className="text-lg font-bold leading-tight">
-                {fmt(data.summary.totalRevenue)}
-              </p>
+              <p className="text-xs text-muted-foreground">{t('history.totalRevenue')}</p>
+              <p className="text-lg font-bold leading-tight">{fmt(data.summary.totalRevenue)}</p>
             </div>
           </CardContent>
         </Card>
@@ -250,10 +263,8 @@ export default function BillingClient({
               <TrendingUp className="h-4 w-4 text-emerald-600" />
             </div>
             <div className="min-w-0">
-              <p className="text-xs text-muted-foreground">{t("history.collected")}</p>
-              <p className="text-lg font-bold leading-tight">
-                {fmt(data.summary.totalPaid)}
-              </p>
+              <p className="text-xs text-muted-foreground">{t('history.collected')}</p>
+              <p className="text-lg font-bold leading-tight">{fmt(data.summary.totalPaid)}</p>
             </div>
           </CardContent>
         </Card>
@@ -264,10 +275,8 @@ export default function BillingClient({
               <AlertCircle className="h-4 w-4 text-red-600" />
             </div>
             <div className="min-w-0">
-              <p className="text-xs text-muted-foreground">{t("history.outstanding")}</p>
-              <p className="text-lg font-bold leading-tight">
-                {fmt(data.summary.outstanding)}
-              </p>
+              <p className="text-xs text-muted-foreground">{t('history.outstanding')}</p>
+              <p className="text-lg font-bold leading-tight">{fmt(data.summary.outstanding)}</p>
             </div>
           </CardContent>
         </Card>
@@ -281,10 +290,9 @@ export default function BillingClient({
             <Button
               key={tab.value}
               variant={
-                statusFilter === tab.value ||
-                (tab.value === "all" && !statusFilter)
-                  ? "default"
-                  : "outline"
+                statusFilter === tab.value || (tab.value === 'all' && !statusFilter)
+                  ? 'default'
+                  : 'outline'
               }
               size="sm"
               className="h-9 shrink-0 sm:h-8"
@@ -292,13 +300,13 @@ export default function BillingClient({
               disabled={isPending}
             >
               {t(tab.titleKey)}
-              {tab.value === "paid" && (
+              {tab.value === 'paid' && (
                 <span className="ml-1 text-xs">({data.summary.paidCount})</span>
               )}
-              {tab.value === "partial" && (
+              {tab.value === 'partial' && (
                 <span className="ml-1 text-xs">({data.summary.partialCount})</span>
               )}
-              {tab.value === "unpaid" && (
+              {tab.value === 'unpaid' && (
                 <span className="ml-1 text-xs">({data.summary.unpaidCount})</span>
               )}
             </Button>
@@ -309,7 +317,7 @@ export default function BillingClient({
           <div className="relative flex-1">
             <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
             <Input
-              placeholder={t("history.searchPlaceholder")}
+              placeholder={t('history.searchPlaceholder')}
               value={searchValue}
               onChange={(e) => setSearchValue(e.target.value)}
               className="h-9 w-full pl-9 sm:w-[250px]"
@@ -320,8 +328,8 @@ export default function BillingClient({
             type="submit"
             size="sm"
             disabled={isPending}
-            aria-label={t("history.search")}
-            title={t("history.search")}
+            aria-label={t('history.search')}
+            title={t('history.search')}
             className="h-9 w-9 shrink-0 p-0 md:h-8 md:w-auto md:px-3"
           >
             {isPending ? (
@@ -329,7 +337,7 @@ export default function BillingClient({
             ) : (
               <>
                 <Search className="h-4 w-4 md:hidden" />
-                <span className="hidden md:inline">{t("history.search")}</span>
+                <span className="hidden md:inline">{t('history.search')}</span>
               </>
             )}
           </Button>
@@ -340,11 +348,11 @@ export default function BillingClient({
       <div className="space-y-2 md:hidden">
         {data.records.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {t("history.noRecords")}
+            {t('history.noRecords')}
           </div>
         ) : (
           data.records.map((record) => {
-            const balance = record.totalAmount - record.totalPaid;
+            const balance = record.totalAmount - record.totalPaid
             return (
               <button
                 key={record.id}
@@ -367,20 +375,18 @@ export default function BillingClient({
                 </div>
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
                   {getStatusBadge(record.status)}
-                  <span className={cn("font-medium", getBalanceColor(record.status))}>
-                    {t("history.columnBalance")}: {fmt(balance)}
+                  <span className={cn('font-medium', getBalanceColor(record.status))}>
+                    {t('history.columnBalance')}: {fmt(balance)}
                   </span>
                   <span className="font-mono text-muted-foreground">
                     {formatDate(new Date(record.serviceDate))}
                   </span>
                   {record.invoiceNumber && (
-                    <span className="font-mono text-muted-foreground">
-                      {record.invoiceNumber}
-                    </span>
+                    <span className="font-mono text-muted-foreground">{record.invoiceNumber}</span>
                   )}
                 </div>
               </button>
-            );
+            )
           })
         )}
       </div>
@@ -391,49 +397,79 @@ export default function BillingClient({
           <TableHeader>
             <TableRow>
               <TableHead className="w-[110px]">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("invoiceNumber")}>
-                  {t("history.columnInvoice")}<SortIcon column="invoiceNumber" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('invoiceNumber')}
+                >
+                  {t('history.columnInvoice')}
+                  <SortIcon column="invoiceNumber" />
                 </button>
               </TableHead>
               <TableHead>
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("title")}>
-                  {t("history.columnTitle")}<SortIcon column="title" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('title')}
+                >
+                  {t('history.columnTitle')}
+                  <SortIcon column="title" />
                 </button>
               </TableHead>
               <TableHead className="w-[16%]">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("vehicle")}>
-                  {t("history.columnVehicle")}<SortIcon column="vehicle" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('vehicle')}
+                >
+                  {t('history.columnVehicle')}
+                  <SortIcon column="vehicle" />
                 </button>
               </TableHead>
               <TableHead className="w-[14%]">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("customer")}>
-                  {t("history.columnCustomer")}<SortIcon column="customer" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('customer')}
+                >
+                  {t('history.columnCustomer')}
+                  <SortIcon column="customer" />
                 </button>
               </TableHead>
               <TableHead className="w-[110px]">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("date")}>
-                  {t("history.columnDate")}<SortIcon column="date" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('date')}
+                >
+                  {t('history.columnDate')}
+                  <SortIcon column="date" />
                 </button>
               </TableHead>
               <TableHead className="w-[100px]">
-                <button type="button" className="ml-auto flex items-center hover:text-foreground" onClick={() => handleSort("total")}>
-                  {t("history.columnTotal")}<SortIcon column="total" />
+                <button
+                  type="button"
+                  className="ml-auto flex items-center hover:text-foreground"
+                  onClick={() => handleSort('total')}
+                >
+                  {t('history.columnTotal')}
+                  <SortIcon column="total" />
                 </button>
               </TableHead>
-              <TableHead className="w-[100px] text-right">{t("history.columnPaid")}</TableHead>
-              <TableHead className="w-[180px] text-right">{t("history.columnBalance")}</TableHead>
+              <TableHead className="w-[100px] text-right">{t('history.columnPaid')}</TableHead>
+              <TableHead className="w-[180px] text-right">{t('history.columnBalance')}</TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
             {data.records.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={8} className="h-24 text-center">
-                  {t("history.noRecords")}
+                  {t('history.noRecords')}
                 </TableCell>
               </TableRow>
             ) : (
               data.records.map((record) => {
-                const balance = record.totalAmount - record.totalPaid;
+                const balance = record.totalAmount - record.totalPaid
                 return (
                   <TableRow
                     key={record.id}
@@ -441,7 +477,7 @@ export default function BillingClient({
                     {...interactiveRow(() => handleRowClick(record))}
                   >
                     <TableCell className="truncate font-medium">
-                      {record.invoiceNumber || "\u2014"}
+                      {record.invoiceNumber || '\u2014'}
                     </TableCell>
                     <TableCell className="truncate">{record.title}</TableCell>
                     <TableCell className="truncate">
@@ -450,7 +486,7 @@ export default function BillingClient({
                           {record.vehicle.year} {record.vehicle.make} {record.vehicle.model}
                         </TableCellLink>
                       ) : (
-                        "\u2014"
+                        '\u2014'
                       )}
                     </TableCell>
                     <TableCell className="truncate">
@@ -459,31 +495,23 @@ export default function BillingClient({
                           {record.customer.name}
                         </TableCellLink>
                       ) : (
-                        "\u2014"
+                        '\u2014'
                       )}
                     </TableCell>
-                    <TableCell>
-                      {formatDate(new Date(record.serviceDate))}
-                    </TableCell>
-                    <TableCell className="text-right">
-                      {fmt(record.totalAmount)}
-                    </TableCell>
-                    <TableCell className="text-right">
-                      {fmt(record.totalPaid)}
-                    </TableCell>
+                    <TableCell>{formatDate(new Date(record.serviceDate))}</TableCell>
+                    <TableCell className="text-right">{fmt(record.totalAmount)}</TableCell>
+                    <TableCell className="text-right">{fmt(record.totalPaid)}</TableCell>
                     <TableCell
                       className={cn(
-                        "whitespace-nowrap text-right font-medium",
+                        'whitespace-nowrap text-right font-medium',
                         getBalanceColor(record.status)
                       )}
                     >
                       {fmt(balance)}
-                      <span className="ml-2 inline-block">
-                        {getStatusBadge(record.status)}
-                      </span>
+                      <span className="ml-2 inline-block">{getStatusBadge(record.status)}</span>
                     </TableCell>
                   </TableRow>
-                );
+                )
               })
             )}
           </TableBody>
@@ -508,5 +536,5 @@ export default function BillingClient({
         </div>
       )}
     </div>
-  );
+  )
 }

+ 2 - 2
src/app/(authenticated)/billing/loading.tsx

@@ -1,4 +1,4 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { Skeleton } from '@/components/ui/skeleton'
 
 export default function BillingLoading() {
   return (
@@ -32,5 +32,5 @@ export default function BillingLoading() {
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 25 - 25
src/app/(authenticated)/billing/page.tsx

@@ -1,46 +1,46 @@
-import { getBillingHistory } from "@/features/billing/Actions/billingActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import BillingClient from "./billing-client";
+import { getBillingHistory } from '@/features/billing/Actions/billingActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import BillingClient from './billing-client'
 
 export default async function BillingPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    status?: string;
-    sortBy?: string;
-    sortOrder?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    status?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
-  const page = Number(params.page) || 1;
-  const pageSize = Number(params.pageSize) || 20;
-  const search = params.search || "";
-  const statusFilter = params.status || "all";
-  const sortBy = params.sortBy || "";
-  const sortOrder = (params.sortOrder as "asc" | "desc") || "desc";
+  const params = await searchParams
+  const page = Number(params.page) || 1
+  const pageSize = Number(params.pageSize) || 20
+  const search = params.search || ''
+  const statusFilter = params.status || 'all'
+  const sortBy = params.sortBy || ''
+  const sortOrder = (params.sortOrder as 'asc' | 'desc') || 'desc'
 
   const [result, settingsResult] = await Promise.all([
     getBillingHistory({ page, pageSize, search, status: statusFilter, sortBy, sortOrder }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
-  ]);
+  ])
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">{result.error || "Failed to load billing data"}</p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load billing data'}</p>
         </div>
       </>
-    );
+    )
   }
 
   return (
@@ -57,5 +57,5 @@ export default async function BillingPage({
         />
       </div>
     </>
-  );
+  )
 }

+ 18 - 18
src/app/(authenticated)/billing/recurring/page.tsx

@@ -1,18 +1,18 @@
-import { getRecurringInvoices } from "@/features/billing/Actions/recurringInvoiceActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import RecurringInvoicesClient from "@/features/billing/Components/RecurringInvoicesClient";
-import { db } from "@/lib/db";
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { redirect } from "next/navigation";
+import { getRecurringInvoices } from '@/features/billing/Actions/recurringInvoiceActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import RecurringInvoicesClient from '@/features/billing/Components/RecurringInvoicesClient'
+import { db } from '@/lib/db'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { redirect } from 'next/navigation'
 
 export default async function RecurringInvoicesPage() {
-  const session = await getCachedSession();
-  if (!session?.user?.id) redirect("/auth/sign-in");
+  const session = await getCachedSession()
+  if (!session?.user?.id) redirect('/auth/sign-in')
 
-  const membership = await getCachedMembership(session.user.id);
-  if (!membership?.organizationId) redirect("/onboarding");
+  const membership = await getCachedMembership(session.user.id)
+  if (!membership?.organizationId) redirect('/onboarding')
 
   const [result, settingsResult, vehicles] = await Promise.all([
     getRecurringInvoices(),
@@ -26,13 +26,13 @@ export default async function RecurringInvoicesPage() {
         year: true,
         customer: { select: { id: true, name: true } },
       },
-      orderBy: [{ make: "asc" }, { model: "asc" }],
+      orderBy: [{ make: 'asc' }, { model: 'asc' }],
     }),
-  ]);
+  ])
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const invoices = result.success && result.data ? result.data : [];
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const invoices = result.success && result.data ? result.data : []
 
   return (
     <>
@@ -45,5 +45,5 @@ export default async function RecurringInvoicesPage() {
         />
       </div>
     </>
-  );
+  )
 }

+ 35 - 36
src/app/(authenticated)/calendar/page.tsx

@@ -1,27 +1,27 @@
-import { getCalendarEvents } from "@/features/calendar/Actions/calendarActions";
-import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
-import { getCustomersList } from "@/features/customers/Actions/customerActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { PageHeader } from "@/components/page-header";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { getAvailableChannels } from "@/features/scheduled-messages/Lib/availableChannels";
-import CalendarClient from "@/features/calendar/Components/CalendarClient";
+import { getCalendarEvents } from '@/features/calendar/Actions/calendarActions'
+import { getVehicles } from '@/features/vehicles/Actions/vehicleActions'
+import { getCustomersList } from '@/features/customers/Actions/customerActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PageHeader } from '@/components/page-header'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availableChannels'
+import CalendarClient from '@/features/calendar/Components/CalendarClient'
 
 function toLocalDateStr(d: Date): string {
-  const y = d.getFullYear();
-  const m = String(d.getMonth() + 1).padStart(2, "0");
-  const day = String(d.getDate()).padStart(2, "0");
-  return `${y}-${m}-${day}`;
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
 }
 
 export default async function CalendarPage() {
-  const now = new Date();
-  const start = new Date(now.getFullYear(), now.getMonth(), 1);
-  const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+  const now = new Date()
+  const start = new Date(now.getFullYear(), now.getMonth(), 1)
+  const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
 
-  const ctx = await getAuthContext();
-  const messageChannels = ctx ? await getAvailableChannels(ctx.organizationId) : [];
+  const ctx = await getAuthContext()
+  const messageChannels = ctx ? await getAvailableChannels(ctx.organizationId) : []
 
   const [eventsResult, settingsResult, vehiclesResult, customersResult] = await Promise.all([
     getCalendarEvents({
@@ -31,24 +31,23 @@ export default async function CalendarPage() {
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
     getVehicles(),
     getCustomersList(),
-  ]);
+  ])
 
-  const events = eventsResult.success && eventsResult.data ? eventsResult.data : [];
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const vehicles = vehiclesResult.success && vehiclesResult.data
-    ? vehiclesResult.data.map((v) => ({
-        id: v.id,
-        make: v.make,
-        model: v.model,
-        year: v.year,
-        licensePlate: v.licensePlate,
-        customer: v.customer,
-      }))
-    : [];
-  const customers = customersResult.success && customersResult.data
-    ? customersResult.data
-    : [];
+  const events = eventsResult.success && eventsResult.data ? eventsResult.data : []
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const vehicles =
+    vehiclesResult.success && vehiclesResult.data
+      ? vehiclesResult.data.map((v) => ({
+          id: v.id,
+          make: v.make,
+          model: v.model,
+          year: v.year,
+          licensePlate: v.licensePlate,
+          customer: v.customer,
+        }))
+      : []
+  const customers = customersResult.success && customersResult.data ? customersResult.data : []
 
   return (
     <>
@@ -67,5 +66,5 @@ export default async function CalendarPage() {
         />
       </div>
     </>
-  );
+  )
 }

+ 325 - 293
src/app/(authenticated)/customers/customers-client.tsx

@@ -1,14 +1,14 @@
-"use client";
+'use client'
 
-import { interactiveRow } from '@/lib/interactive-row';
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { interactiveRow } from '@/lib/interactive-row'
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
-import { useState, useCallback, useTransition, useEffect } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+import { useState, useCallback, useTransition, useEffect } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import {
   Table,
   TableBody,
@@ -16,29 +16,29 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
   DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+} from '@/components/ui/dropdown-menu'
 import {
   ContextMenu,
   ContextMenuContent,
   ContextMenuItem,
   ContextMenuSeparator,
   ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import { useGlassModal } from "@/components/glass-modal";
-import { useConfirm } from "@/components/confirm-dialog";
-import { CustomerForm } from "@/features/customers/Components/CustomerForm";
-import { ImportCustomersDialog } from "@/features/customers/Components/ImportCustomersDialog";
-import { Checkbox } from "@/components/ui/checkbox";
-import { deleteCustomer, deleteCustomers } from "@/features/customers/Actions/customerActions";
-import { toast } from "sonner";
+} from '@/components/ui/context-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
+import { useGlassModal } from '@/components/glass-modal'
+import { useConfirm } from '@/components/confirm-dialog'
+import { CustomerForm } from '@/features/customers/Components/CustomerForm'
+import { ImportCustomersDialog } from '@/features/customers/Components/ImportCustomersDialog'
+import { Checkbox } from '@/components/ui/checkbox'
+import { deleteCustomer, deleteCustomers } from '@/features/customers/Actions/customerActions'
+import { toast } from 'sonner'
 import {
   ArrowDown,
   ArrowUp,
@@ -53,28 +53,28 @@ import {
   Trash2,
   Upload,
   Users,
-} from "lucide-react";
+} from 'lucide-react'
 
 interface Customer {
-  customerNumber: string | null;
-  id: string;
-  name: string;
-  email: string | null;
-  phone: string | null;
-  address: string | null;
-  company: string | null;
-  taxId: string | null;
-  taxExempt: boolean;
-  notes: string | null;
-  _count: { vehicles: number };
+  customerNumber: string | null
+  id: string
+  name: string
+  email: string | null
+  phone: string | null
+  address: string | null
+  company: string | null
+  taxId: string | null
+  taxExempt: boolean
+  notes: string | null
+  _count: { vehicles: number }
 }
 
 interface PaginatedData {
-  customers: Customer[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
+  customers: Customer[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
 }
 
 export function CustomersClient({
@@ -83,71 +83,73 @@ export function CustomersClient({
   sortBy,
   sortOrder,
 }: {
-  data: PaginatedData;
-  search: string;
-  sortBy: string;
-  sortOrder: "asc" | "desc";
+  data: PaginatedData
+  search: string
+  sortBy: string
+  sortOrder: 'asc' | 'desc'
 }) {
-  const t = useTranslations("customers.list");
-  const tc = useTranslations("common");
-  const tcm = useTranslations("common.contextMenu");
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
-  const [showForm, setShowForm] = useState(false);
-  const [showImport, setShowImport] = useState(false);
-  const [editCustomer, setEditCustomer] = useState<Customer | null>(null);
-  const [selected, setSelected] = useState<Set<string>>(new Set());
-  const [isDeleting, setIsDeleting] = useState(false);
-  const modal = useGlassModal();
-  const confirm = useConfirm();
+  const t = useTranslations('customers.list')
+  const tc = useTranslations('common')
+  const tcm = useTranslations('common.contextMenu')
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
+  const [showForm, setShowForm] = useState(false)
+  const [showImport, setShowImport] = useState(false)
+  const [editCustomer, setEditCustomer] = useState<Customer | null>(null)
+  const [selected, setSelected] = useState<Set<string>>(new Set())
+  const [isDeleting, setIsDeleting] = useState(false)
+  const modal = useGlassModal()
+  const confirm = useConfirm()
 
   useEffect(() => {
-    if (searchParams.get("create") === "true") {
-      setShowForm(true);
-      const params = new URLSearchParams(searchParams.toString());
-      params.delete("create");
-      const cleanUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
-      window.history.replaceState(null, "", cleanUrl);
+    if (searchParams.get('create') === 'true') {
+      setShowForm(true)
+      const params = new URLSearchParams(searchParams.toString())
+      params.delete('create')
+      const cleanUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
+      window.history.replaceState(null, '', cleanUrl)
     }
-  }, [searchParams, pathname]);
+  }, [searchParams, pathname])
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && ("search" in params || "sortBy" in params)) {
-        newParams.delete("page");
+      if (!('page' in params) && ('search' in params || 'sortBy' in params)) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
     [router, pathname, searchParams]
-  );
+  )
 
   const handleSort = useCallback(
     (column: string) => {
-      const newOrder = sortBy === column && sortOrder === "asc" ? "desc" : "asc";
-      navigate({ sortBy: column, sortOrder: newOrder });
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder })
     },
     [navigate, sortBy, sortOrder]
-  );
+  )
 
   const SortIcon = ({ column }: { column: string }) => {
-    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />;
-    return sortOrder === "asc"
-      ? <ArrowUp className="ml-1 h-3 w-3" />
-      : <ArrowDown className="ml-1 h-3 w-3" />;
-  };
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
 
   // Live search: filters as you type, no Enter required. Submitting the
   // form (Enter) commits immediately, bypassing the debounce.
@@ -155,61 +157,61 @@ export function CustomersClient({
     value: searchInput,
     setValue: setSearchInput,
     commitNow: handleSearch,
-  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }))
 
   const toggleSelect = (id: string) => {
     setSelected((prev) => {
-      const next = new Set(prev);
-      if (next.has(id)) next.delete(id);
-      else next.add(id);
-      return next;
-    });
-  };
+      const next = new Set(prev)
+      if (next.has(id)) next.delete(id)
+      else next.add(id)
+      return next
+    })
+  }
 
   const toggleSelectAll = () => {
     if (selected.size === data.customers.length) {
-      setSelected(new Set());
+      setSelected(new Set())
     } else {
-      setSelected(new Set(data.customers.map((c) => c.id)));
+      setSelected(new Set(data.customers.map((c) => c.id)))
     }
-  };
+  }
 
   const handleDelete = async (id: string, name: string) => {
     const ok = await confirm({
-      title: t("deleteTitle"),
-      description: t("deleteDescription", { name }),
-      confirmLabel: tc("buttons.delete"),
+      title: t('deleteTitle'),
+      description: t('deleteDescription', { name }),
+      confirmLabel: tc('buttons.delete'),
       destructive: true,
-    });
-    if (!ok) return;
-    const result = await deleteCustomer(id);
+    })
+    if (!ok) return
+    const result = await deleteCustomer(id)
     if (result.success) {
-      router.refresh();
+      router.refresh()
     } else {
-      modal.open("error", tc("errors.error"), result.error || t("deleteError"));
+      modal.open('error', tc('errors.error'), result.error || t('deleteError'))
     }
-  };
+  }
 
   const handleBatchDelete = async () => {
-    if (selected.size === 0) return;
+    if (selected.size === 0) return
     const ok = await confirm({
-      title: t("batchDeleteTitle"),
-      description: t("batchDeleteDescription", { count: selected.size }),
-      confirmLabel: tc("buttons.delete"),
+      title: t('batchDeleteTitle'),
+      description: t('batchDeleteDescription', { count: selected.size }),
+      confirmLabel: tc('buttons.delete'),
       destructive: true,
-    });
-    if (!ok) return;
-    setIsDeleting(true);
-    const result = await deleteCustomers(Array.from(selected));
+    })
+    if (!ok) return
+    setIsDeleting(true)
+    const result = await deleteCustomers(Array.from(selected))
     if (result.success) {
-      toast.success(t("batchDeleteSuccess", { count: result.data?.deleted ?? selected.size }));
-      setSelected(new Set());
-      router.refresh();
+      toast.success(t('batchDeleteSuccess', { count: result.data?.deleted ?? selected.size }))
+      setSelected(new Set())
+      router.refresh()
     } else {
-      modal.open("error", tc("errors.error"), result.error || t("deleteError"));
+      modal.open('error', tc('errors.error'), result.error || t('deleteError'))
     }
-    setIsDeleting(false);
-  };
+    setIsDeleting(false)
+  }
 
   return (
     <div className="space-y-4">
@@ -218,9 +220,9 @@ export function CustomersClient({
         {selected.size > 0 ? (
           <>
             <div className="flex items-center gap-2 text-sm text-muted-foreground">
-              <span>{t("selectedCount", { count: selected.size })}</span>
+              <span>{t('selectedCount', { count: selected.size })}</span>
               <Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
-                {t("clearSelection")}
+                {t('clearSelection')}
               </Button>
             </div>
             <Button
@@ -231,7 +233,7 @@ export function CustomersClient({
             >
               {isDeleting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
               <Trash2 className="mr-1 h-3.5 w-3.5" />
-              {t("batchDelete", { count: selected.size })}
+              {t('batchDelete', { count: selected.size })}
             </Button>
           </>
         ) : (
@@ -240,7 +242,7 @@ export function CustomersClient({
               <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
                 <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
                 <Input
-                  placeholder={t("searchPlaceholder")}
+                  placeholder={t('searchPlaceholder')}
                   value={searchInput}
                   onChange={(e) => setSearchInput(e.target.value)}
                   className="h-9 pl-9"
@@ -254,22 +256,22 @@ export function CustomersClient({
                 size="sm"
                 variant="outline"
                 onClick={() => setShowImport(true)}
-                aria-label={t("importCustomers")}
-                title={t("importCustomers")}
+                aria-label={t('importCustomers')}
+                title={t('importCustomers')}
                 className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
               >
                 <Upload className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
-                <span className="hidden md:inline">{t("importCustomers")}</span>
+                <span className="hidden md:inline">{t('importCustomers')}</span>
               </Button>
               <Button
                 size="sm"
                 onClick={() => setShowForm(true)}
-                aria-label={t("addCustomer")}
-                title={t("addCustomer")}
+                aria-label={t('addCustomer')}
+                title={t('addCustomer')}
                 className="h-9 w-9 p-0 md:h-8 md:w-auto md:px-3"
               >
                 <Plus className="h-4 w-4 md:mr-1 md:h-3.5 md:w-3.5" />
-                <span className="hidden md:inline">{t("addCustomer")}</span>
+                <span className="hidden md:inline">{t('addCustomer')}</span>
               </Button>
             </div>
           </>
@@ -280,14 +282,14 @@ export function CustomersClient({
       <div className="space-y-2 md:hidden">
         {data.customers.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {search ? t("emptySearch") : t("empty")}
+            {search ? t('emptySearch') : t('empty')}
           </div>
         ) : (
           data.customers.map((c) => (
             <div
               key={c.id}
               className={`flex items-start gap-3 rounded-lg border bg-card p-3 ${
-                selected.has(c.id) ? "bg-muted/50" : ""
+                selected.has(c.id) ? 'bg-muted/50' : ''
               }`}
             >
               <Checkbox
@@ -307,9 +309,7 @@ export function CustomersClient({
                     {c._count.vehicles}
                   </span>
                 </div>
-                {c.company && (
-                  <p className="truncate text-xs text-muted-foreground">{c.company}</p>
-                )}
+                {c.company && <p className="truncate text-xs text-muted-foreground">{c.company}</p>}
                 <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
                   {c.customerNumber && <span className="font-mono">{c.customerNumber}</span>}
                   {c.phone && <span>{c.phone}</span>}
@@ -322,7 +322,7 @@ export function CustomersClient({
                     variant="ghost"
                     size="icon"
                     className="-mr-1 h-9 w-9 shrink-0"
-                    aria-label={t("openMenu")}
+                    aria-label={t('openMenu')}
                   >
                     <MoreVertical className="h-4 w-4" />
                   </Button>
@@ -330,19 +330,19 @@ export function CustomersClient({
                 <DropdownMenuContent align="end">
                   <DropdownMenuItem
                     onClick={() => {
-                      setEditCustomer(c);
-                      setShowForm(true);
+                      setEditCustomer(c)
+                      setShowForm(true)
                     }}
                   >
                     <Pencil className="mr-2 h-4 w-4" />
-                    {tc("buttons.edit")}
+                    {tc('buttons.edit')}
                   </DropdownMenuItem>
                   <DropdownMenuItem
                     className="text-destructive"
                     onClick={() => handleDelete(c.id, c.name)}
                   >
                     <Trash2 className="mr-2 h-4 w-4" />
-                    {tc("buttons.delete")}
+                    {tc('buttons.delete')}
                   </DropdownMenuItem>
                 </DropdownMenuContent>
               </DropdownMenu>
@@ -355,181 +355,213 @@ export function CustomersClient({
       <div className="hidden rounded-lg border md:block" {...tableNav.containerProps}>
         <TableContextMenuHint />
         <div className="overflow-auto max-h-[calc(100vh-220px)]">
-        <Table className="table-fixed">
-          <TableHeader className="sticky top-0 z-10 bg-background">
-            <TableRow>
-              <TableHead className="w-[40px]">
-                <Checkbox
-                  checked={
-                    data.customers.length > 0 && selected.size === data.customers.length
-                      ? true
-                      : selected.size > 0
-                        ? "indeterminate"
-                        : false
-                  }
-                  onCheckedChange={toggleSelectAll}
-                  onClick={(e) => e.stopPropagation()}
-                />
-              </TableHead>
-              <TableHead className="w-[80px]">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("number")}>
-                  {t("table.number")}<SortIcon column="number" />
-                </button>
-              </TableHead>
-              <TableHead>
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("name")}>
-                  {t("table.name")}<SortIcon column="name" />
-                </button>
-              </TableHead>
-              <TableHead className="hidden w-[20%] sm:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("company")}>
-                  {t("table.company")}<SortIcon column="company" />
-                </button>
-              </TableHead>
-              <TableHead className="hidden w-[16%] md:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("phone")}>
-                  {t("table.phone")}<SortIcon column="phone" />
-                </button>
-              </TableHead>
-              <TableHead className="hidden w-[22%] lg:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("email")}>
-                  {t("table.email")}<SortIcon column="email" />
-                </button>
-              </TableHead>
-              <TableHead className="w-[80px]">
-                <button type="button" className="mx-auto flex items-center hover:text-foreground" onClick={() => handleSort("vehicles")}>
-                  {t("table.vehicles")}<SortIcon column="vehicles" />
-                </button>
-              </TableHead>
-              <TableHead className="w-[50px]" />
-            </TableRow>
-          </TableHeader>
-          <TableBody>
-            {data.customers.length === 0 ? (
+          <Table className="table-fixed">
+            <TableHeader className="sticky top-0 z-10 bg-background">
               <TableRow>
-                <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
-                  {search ? t("emptySearch") : t("empty")}
-                </TableCell>
+                <TableHead className="w-[40px]">
+                  <Checkbox
+                    checked={
+                      data.customers.length > 0 && selected.size === data.customers.length
+                        ? true
+                        : selected.size > 0
+                          ? 'indeterminate'
+                          : false
+                    }
+                    onCheckedChange={toggleSelectAll}
+                    onClick={(e) => e.stopPropagation()}
+                  />
+                </TableHead>
+                <TableHead className="w-[80px]">
+                  <button
+                    type="button"
+                    className="flex items-center hover:text-foreground"
+                    onClick={() => handleSort('number')}
+                  >
+                    {t('table.number')}
+                    <SortIcon column="number" />
+                  </button>
+                </TableHead>
+                <TableHead>
+                  <button
+                    type="button"
+                    className="flex items-center hover:text-foreground"
+                    onClick={() => handleSort('name')}
+                  >
+                    {t('table.name')}
+                    <SortIcon column="name" />
+                  </button>
+                </TableHead>
+                <TableHead className="hidden w-[20%] sm:table-cell">
+                  <button
+                    type="button"
+                    className="flex items-center hover:text-foreground"
+                    onClick={() => handleSort('company')}
+                  >
+                    {t('table.company')}
+                    <SortIcon column="company" />
+                  </button>
+                </TableHead>
+                <TableHead className="hidden w-[16%] md:table-cell">
+                  <button
+                    type="button"
+                    className="flex items-center hover:text-foreground"
+                    onClick={() => handleSort('phone')}
+                  >
+                    {t('table.phone')}
+                    <SortIcon column="phone" />
+                  </button>
+                </TableHead>
+                <TableHead className="hidden w-[22%] lg:table-cell">
+                  <button
+                    type="button"
+                    className="flex items-center hover:text-foreground"
+                    onClick={() => handleSort('email')}
+                  >
+                    {t('table.email')}
+                    <SortIcon column="email" />
+                  </button>
+                </TableHead>
+                <TableHead className="w-[80px]">
+                  <button
+                    type="button"
+                    className="mx-auto flex items-center hover:text-foreground"
+                    onClick={() => handleSort('vehicles')}
+                  >
+                    {t('table.vehicles')}
+                    <SortIcon column="vehicles" />
+                  </button>
+                </TableHead>
+                <TableHead className="w-[50px]" />
               </TableRow>
-            ) : (
-              data.customers.map((c) => (
-                <ContextMenu key={c.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow
-                  className={`cursor-pointer ${selected.has(c.id) ? "bg-muted/50" : ""}`}
-                  {...interactiveRow(() => router.push(`/customers/${c.id}`))}
-                >
-                  <TableCell className="w-[40px]">
-                    <Checkbox
-                      checked={selected.has(c.id)}
-                      onCheckedChange={() => toggleSelect(c.id)}
-                      onClick={(e) => e.stopPropagation()}
-                    />
-                  </TableCell>
-                  <TableCell className="font-mono text-xs text-muted-foreground">
-                    {c.customerNumber || "-"}
-                  </TableCell>
-                  <TableCell>
-                    <div className="flex items-center gap-2">
-                      <Users className="h-4 w-4 text-muted-foreground shrink-0" />
-                      <span className="truncate font-medium">{c.name}</span>
-                    </div>
-                  </TableCell>
-                  <TableCell className="hidden truncate sm:table-cell text-muted-foreground">
-                    {c.company || "-"}
-                  </TableCell>
-                  <TableCell className="hidden truncate md:table-cell text-muted-foreground">
-                    {c.phone || "-"}
-                  </TableCell>
-                  <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
-                    {c.email || "-"}
-                  </TableCell>
-                  <TableCell className="text-center">{c._count.vehicles}</TableCell>
-                  <TableCell>
-                    <DropdownMenu>
-                      <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
-                        <Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t("openMenu")}>
-                          <MoreVertical className="h-4 w-4" />
-                        </Button>
-                      </DropdownMenuTrigger>
-                      <DropdownMenuContent align="end">
-                        <DropdownMenuItem
-                          onClick={(e) => {
-                            e.stopPropagation();
-                            setEditCustomer(c);
-                            setShowForm(true);
-                          }}
-                        >
-                          <Pencil className="mr-2 h-4 w-4" />
-                          {tc("buttons.edit")}
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                          className="text-destructive"
-                          onClick={(e) => {
-                            e.stopPropagation();
-                            handleDelete(c.id, c.name);
-                          }}
-                        >
-                          <Trash2 className="mr-2 h-4 w-4" />
-                          {tc("buttons.delete")}
-                        </DropdownMenuItem>
-                      </DropdownMenuContent>
-                    </DropdownMenu>
+            </TableHeader>
+            <TableBody>
+              {data.customers.length === 0 ? (
+                <TableRow>
+                  <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
+                    {search ? t('emptySearch') : t('empty')}
                   </TableCell>
                 </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => router.push(`/customers/${c.id}`)}>
-                    <ExternalLink className="mr-2 h-4 w-4" />
-                    {tcm("open")}
-                  </ContextMenuItem>
-                  <ContextMenuSeparator />
-                  <ContextMenuItem
-                    onClick={() => {
-                      setEditCustomer(c);
-                      setShowForm(true);
-                    }}
-                  >
-                    <Pencil className="mr-2 h-4 w-4" />
-                    {tc("buttons.edit")}
-                  </ContextMenuItem>
-                  <ContextMenuItem
-                    variant="destructive"
-                    onClick={() => handleDelete(c.id, c.name)}
-                  >
-                    <Trash2 className="mr-2 h-4 w-4" />
-                    {tc("buttons.delete")}
-                  </ContextMenuItem>
-                </ContextMenuContent>
-                </ContextMenu>
-              ))
-            )}
-          </TableBody>
-        </Table>
+              ) : (
+                data.customers.map((c) => (
+                  <ContextMenu key={c.id} modal={false}>
+                    <ContextMenuTrigger asChild>
+                      <TableRow
+                        className={`cursor-pointer ${selected.has(c.id) ? 'bg-muted/50' : ''}`}
+                        {...interactiveRow(() => router.push(`/customers/${c.id}`))}
+                      >
+                        <TableCell className="w-[40px]">
+                          <Checkbox
+                            checked={selected.has(c.id)}
+                            onCheckedChange={() => toggleSelect(c.id)}
+                            onClick={(e) => e.stopPropagation()}
+                          />
+                        </TableCell>
+                        <TableCell className="font-mono text-xs text-muted-foreground">
+                          {c.customerNumber || '-'}
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex items-center gap-2">
+                            <Users className="h-4 w-4 text-muted-foreground shrink-0" />
+                            <span className="truncate font-medium">{c.name}</span>
+                          </div>
+                        </TableCell>
+                        <TableCell className="hidden truncate sm:table-cell text-muted-foreground">
+                          {c.company || '-'}
+                        </TableCell>
+                        <TableCell className="hidden truncate md:table-cell text-muted-foreground">
+                          {c.phone || '-'}
+                        </TableCell>
+                        <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
+                          {c.email || '-'}
+                        </TableCell>
+                        <TableCell className="text-center">{c._count.vehicles}</TableCell>
+                        <TableCell>
+                          <DropdownMenu>
+                            <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
+                              <Button
+                                variant="ghost"
+                                size="icon"
+                                className="h-8 w-8"
+                                aria-label={t('openMenu')}
+                              >
+                                <MoreVertical className="h-4 w-4" />
+                              </Button>
+                            </DropdownMenuTrigger>
+                            <DropdownMenuContent align="end">
+                              <DropdownMenuItem
+                                onClick={(e) => {
+                                  e.stopPropagation()
+                                  setEditCustomer(c)
+                                  setShowForm(true)
+                                }}
+                              >
+                                <Pencil className="mr-2 h-4 w-4" />
+                                {tc('buttons.edit')}
+                              </DropdownMenuItem>
+                              <DropdownMenuItem
+                                className="text-destructive"
+                                onClick={(e) => {
+                                  e.stopPropagation()
+                                  handleDelete(c.id, c.name)
+                                }}
+                              >
+                                <Trash2 className="mr-2 h-4 w-4" />
+                                {tc('buttons.delete')}
+                              </DropdownMenuItem>
+                            </DropdownMenuContent>
+                          </DropdownMenu>
+                        </TableCell>
+                      </TableRow>
+                    </ContextMenuTrigger>
+                    <ContextMenuContent className="min-w-52">
+                      <ContextMenuItem onClick={() => router.push(`/customers/${c.id}`)}>
+                        <ExternalLink className="mr-2 h-4 w-4" />
+                        {tcm('open')}
+                      </ContextMenuItem>
+                      <ContextMenuSeparator />
+                      <ContextMenuItem
+                        onClick={() => {
+                          setEditCustomer(c)
+                          setShowForm(true)
+                        }}
+                      >
+                        <Pencil className="mr-2 h-4 w-4" />
+                        {tc('buttons.edit')}
+                      </ContextMenuItem>
+                      <ContextMenuItem
+                        variant="destructive"
+                        onClick={() => handleDelete(c.id, c.name)}
+                      >
+                        <Trash2 className="mr-2 h-4 w-4" />
+                        {tc('buttons.delete')}
+                      </ContextMenuItem>
+                    </ContextMenuContent>
+                  </ContextMenu>
+                ))
+              )}
+            </TableBody>
+          </Table>
         </div>
       </div>
 
       <DataTablePagination
-          total={data.total}
-          page={data.page}
-          pageSize={data.pageSize}
-          totalPages={data.totalPages}
-          onNavigate={navigate}
+        total={data.total}
+        page={data.page}
+        pageSize={data.pageSize}
+        totalPages={data.totalPages}
+        onNavigate={navigate}
       />
 
       <CustomerForm
         open={showForm}
         onOpenChange={(open) => {
-          setShowForm(open);
-          if (!open) setEditCustomer(null);
+          setShowForm(open)
+          if (!open) setEditCustomer(null)
         }}
         customer={editCustomer ?? undefined}
       />
 
-      <ImportCustomersDialog
-        open={showImport}
-        onOpenChange={setShowImport}
-      />
+      <ImportCustomersDialog open={showImport} onOpenChange={setShowImport} />
     </div>
-  );
+  )
 }

+ 2 - 2
src/app/(authenticated)/customers/loading.tsx

@@ -1,4 +1,4 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { Skeleton } from '@/components/ui/skeleton'
 
 export default function CustomersLoading() {
   return (
@@ -32,5 +32,5 @@ export default function CustomersLoading() {
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 20 - 22
src/app/(authenticated)/customers/page.tsx

@@ -1,40 +1,38 @@
-import { getTranslations } from "next-intl/server";
-import { getCustomersPaginated } from "@/features/customers/Actions/customerActions";
-import { CustomersClient } from "./customers-client";
-import { PageHeader } from "@/components/page-header";
+import { getTranslations } from 'next-intl/server'
+import { getCustomersPaginated } from '@/features/customers/Actions/customerActions'
+import { CustomersClient } from './customers-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function CustomersPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    sortBy?: string;
-    sortOrder?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
   const result = await getCustomersPaginated({
     page: params.page ? parseInt(params.page) : 1,
     pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
     search: params.search,
     sortBy: params.sortBy,
-    sortOrder: params.sortOrder as "asc" | "desc" | undefined,
-  });
+    sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
+  })
 
   if (!result.success || !result.data) {
-    const t = await getTranslations("customers.list");
+    const t = await getTranslations('customers.list')
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || t("error")}
-          </p>
+          <p className="text-muted-foreground">{result.error || t('error')}</p>
         </div>
       </>
-    );
+    )
   }
 
   return (
@@ -43,11 +41,11 @@ export default async function CustomersPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <CustomersClient
           data={result.data}
-          search={params.search || ""}
-          sortBy={params.sortBy || ""}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          search={params.search || ''}
+          sortBy={params.sortBy || ''}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
         />
       </div>
     </>
-  );
+  )
 }

+ 18 - 18
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -2,29 +2,29 @@ import {
   getCommonDefectNotes,
   getInspection,
   getInspectionTechnicians,
-} from "@/features/inspections/Actions/inspectionActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { InspectionPageClient, type InspectionData } from "@/features/inspections/Components/InspectionPageClient";
-import { PageHeader } from "@/components/page-header";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { getFeatures } from "@/lib/features";
-import { redirect } from "next/navigation";
+} from '@/features/inspections/Actions/inspectionActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  InspectionPageClient,
+  type InspectionData,
+} from '@/features/inspections/Components/InspectionPageClient'
+import { PageHeader } from '@/components/page-header'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { getFeatures } from '@/lib/features'
+import { redirect } from 'next/navigation'
 
 export default async function InspectionDetailPage({
   params,
 }: {
-  params: Promise<{ id: string }>;
+  params: Promise<{ id: string }>
 }) {
-  const { id } = await params;
+  const { id } = await params
 
-  const [result, authContext] = await Promise.all([
-    getInspection(id),
-    getAuthContext(),
-  ]);
+  const [result, authContext] = await Promise.all([getInspection(id), getAuthContext()])
 
   if (!result.success || !result.data) {
-    redirect("/inspections");
+    redirect('/inspections')
   }
 
   const [features, defectHistory, technicians, settings] = await Promise.all([
@@ -32,7 +32,7 @@ export default async function InspectionDetailPage({
     getCommonDefectNotes(id),
     getInspectionTechnicians(),
     getSettings([SETTING_KEYS.WORKSHOP_ADDRESS]),
-  ]);
+  ])
 
   return (
     <>
@@ -45,10 +45,10 @@ export default async function InspectionDetailPage({
           defectHistory={defectHistory.success ? defectHistory.data : {}}
           technicians={technicians.success ? technicians.data : []}
           workshopAddress={
-            (settings.success && settings.data?.[SETTING_KEYS.WORKSHOP_ADDRESS]) || ""
+            (settings.success && settings.data?.[SETTING_KEYS.WORKSHOP_ADDRESS]) || ''
           }
         />
       </div>
     </>
-  );
+  )
 }

+ 23 - 25
src/app/(authenticated)/inspections/page.tsx

@@ -1,47 +1,45 @@
-import { getInspectionsPaginated } from "@/features/inspections/Actions/inspectionActions";
-import { getTemplates } from "@/features/inspections/Actions/templateActions";
-import { InspectionsClient } from "./inspections-client";
-import { PageHeader } from "@/components/page-header";
+import { getInspectionsPaginated } from '@/features/inspections/Actions/inspectionActions'
+import { getTemplates } from '@/features/inspections/Actions/templateActions'
+import { InspectionsClient } from './inspections-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function InspectionsPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    status?: string;
-    sortBy?: string;
-    sortOrder?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    status?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
   const [result, templatesResult] = await Promise.all([
     getInspectionsPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
-      status: params.status || "all",
+      status: params.status || 'all',
       sortBy: params.sortBy,
-      sortOrder: params.sortOrder as "asc" | "desc" | undefined,
+      sortOrder: params.sortOrder as 'asc' | 'desc' | undefined,
     }),
     getTemplates(),
-  ]);
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load inspections"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load inspections'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const templates = templatesResult.success && templatesResult.data ? templatesResult.data : [];
+  const templates = templatesResult.success && templatesResult.data ? templatesResult.data : []
 
   return (
     <>
@@ -50,12 +48,12 @@ export default async function InspectionsPage({
         <InspectionsClient
           data={result.data}
           templates={templates}
-          search={params.search || ""}
-          statusFilter={params.status || "all"}
-          sortBy={params.sortBy || ""}
-          sortOrder={(params.sortOrder as "asc" | "desc") || "desc"}
+          search={params.search || ''}
+          statusFilter={params.status || 'all'}
+          sortBy={params.sortBy || ''}
+          sortOrder={(params.sortOrder as 'asc' | 'desc') || 'desc'}
         />
       </div>
     </>
-  );
+  )
 }

+ 2 - 2
src/app/(authenticated)/inventory/loading.tsx

@@ -1,4 +1,4 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { Skeleton } from '@/components/ui/skeleton'
 
 export default function InventoryLoading() {
   return (
@@ -32,5 +32,5 @@ export default function InventoryLoading() {
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 2 - 2
src/app/(authenticated)/loading.tsx

@@ -1,4 +1,4 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { Skeleton } from '@/components/ui/skeleton'
 
 export default function DashboardLoading() {
   return (
@@ -27,5 +27,5 @@ export default function DashboardLoading() {
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 173 - 140
src/app/(authenticated)/observations/observations-client.tsx

@@ -1,19 +1,19 @@
-"use client";
+'use client'
 
-import { useTableKeyboardNav } from "@/hooks/use-table-keyboard-nav";
-import { interactiveRow } from "@/lib/interactive-row";
-import { useCallback, useTransition } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { interactiveRow } from '@/lib/interactive-row'
+import { useCallback, useTransition } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
 import {
   Select,
   SelectContent,
   SelectItem,
   SelectTrigger,
   SelectValue,
-} from "@/components/ui/select";
+} from '@/components/ui/select'
 import {
   Table,
   TableBody,
@@ -21,53 +21,53 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   ContextMenu,
   ContextMenuContent,
   ContextMenuItem,
   ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { TableContextMenuHint } from "@/components/table-context-menu-hint";
-import { TableCellLink } from "@/components/table-cell-link";
-import { useFormatDate } from "@/lib/use-format-date";
-import { Car, ExternalLink, Search, Loader2 } from "lucide-react";
+} from '@/components/ui/context-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { TableContextMenuHint } from '@/components/table-context-menu-hint'
+import { TableCellLink } from '@/components/table-cell-link'
+import { useFormatDate } from '@/lib/use-format-date'
+import { Car, ExternalLink, Search, Loader2 } from 'lucide-react'
 
 type ObservationsData = {
   records: {
-    id: string;
-    description: string;
-    severity: string;
-    status: string;
-    notes: string | null;
-    createdAt: Date;
+    id: string
+    description: string
+    severity: string
+    status: string
+    notes: string | null
+    createdAt: Date
     vehicle: {
-      id: string;
-      make: string;
-      model: string;
-      year: number;
-      licensePlate: string | null;
-    };
-    serviceRecord: { id: string; title: string } | null;
-    resolvedServiceRecord: { id: string; title: string } | null;
-  }[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-};
+      id: string
+      make: string
+      model: string
+      year: number
+      licensePlate: string | null
+    }
+    serviceRecord: { id: string; title: string } | null
+    resolvedServiceRecord: { id: string; title: string } | null
+  }[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
 
 const severityColors: Record<string, string> = {
-  urgent: "bg-red-500/10 text-red-500 border-red-500/20",
-  needs_work: "bg-amber-500/10 text-amber-600 border-amber-500/20",
-  monitor: "bg-blue-500/10 text-blue-500 border-blue-500/20",
-};
+  urgent: 'bg-red-500/10 text-red-500 border-red-500/20',
+  needs_work: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+  monitor: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
+}
 
 const statusColors: Record<string, string> = {
-  open: "bg-amber-500/10 text-amber-600 border-amber-500/20",
-  resolved: "bg-green-500/10 text-green-600 border-green-500/20",
-};
+  open: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+  resolved: 'bg-green-500/10 text-green-600 border-green-500/20',
+}
 
 export function ObservationsClient({
   data,
@@ -75,40 +75,43 @@ export function ObservationsClient({
   statusFilter,
   severityFilter,
 }: {
-  data: ObservationsData;
-  search: string;
-  statusFilter: string;
-  severityFilter: string;
+  data: ObservationsData
+  search: string
+  statusFilter: string
+  severityFilter: string
 }) {
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const [isPending, startTransition] = useTransition();
-  const tableNav = useTableKeyboardNav();
-  const { formatDate } = useFormatDate();
-  const t = useTranslations("vehicles.observationsPage");
-  const tf = useTranslations("vehicles.findings");
-  const tcm = useTranslations("common.contextMenu");
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const [isPending, startTransition] = useTransition()
+  const tableNav = useTableKeyboardNav()
+  const { formatDate } = useFormatDate()
+  const t = useTranslations('vehicles.observationsPage')
+  const tf = useTranslations('vehicles.findings')
+  const tcm = useTranslations('common.contextMenu')
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "" || value === "all") {
-          newParams.delete(key);
+        if (value === undefined || value === '' || value === 'all') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && ("search" in params || "status" in params || "severity" in params)) {
-        newParams.delete("page");
+      if (
+        !('page' in params) &&
+        ('search' in params || 'status' in params || 'severity' in params)
+      ) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   return (
     <div className="space-y-4">
@@ -117,17 +120,19 @@ export function ObservationsClient({
         <div className="relative flex-1">
           <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
-            placeholder={t("searchPlaceholder")}
+            placeholder={t('searchPlaceholder')}
             defaultValue={search}
             onChange={(e) => {
-              const value = e.target.value;
-              if (value === search) return;
-              navigate({ search: value || undefined });
+              const value = e.target.value
+              if (value === search) return
+              navigate({ search: value || undefined })
             }}
             className="h-9 pl-9 pr-9"
             {...tableNav.searchInputProps}
           />
-          {isPending && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />}
+          {isPending && (
+            <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
+          )}
         </div>
         <div className="flex gap-2">
           <Select value={statusFilter} onValueChange={(v) => navigate({ status: v })}>
@@ -135,9 +140,9 @@ export function ObservationsClient({
               <SelectValue />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("allStatuses")}</SelectItem>
-              <SelectItem value="open">{tf("status.open")}</SelectItem>
-              <SelectItem value="resolved">{tf("status.resolved")}</SelectItem>
+              <SelectItem value="all">{t('allStatuses')}</SelectItem>
+              <SelectItem value="open">{tf('status.open')}</SelectItem>
+              <SelectItem value="resolved">{tf('status.resolved')}</SelectItem>
             </SelectContent>
           </Select>
           <Select value={severityFilter} onValueChange={(v) => navigate({ severity: v })}>
@@ -145,10 +150,10 @@ export function ObservationsClient({
               <SelectValue />
             </SelectTrigger>
             <SelectContent>
-              <SelectItem value="all">{t("allSeverities")}</SelectItem>
-              <SelectItem value="urgent">{tf("severity.urgent")}</SelectItem>
-              <SelectItem value="needs_work">{tf("severity.needs_work")}</SelectItem>
-              <SelectItem value="monitor">{tf("severity.monitor")}</SelectItem>
+              <SelectItem value="all">{t('allSeverities')}</SelectItem>
+              <SelectItem value="urgent">{tf('severity.urgent')}</SelectItem>
+              <SelectItem value="needs_work">{tf('severity.needs_work')}</SelectItem>
+              <SelectItem value="monitor">{tf('severity.monitor')}</SelectItem>
             </SelectContent>
           </Select>
         </div>
@@ -158,7 +163,7 @@ export function ObservationsClient({
       <div className="space-y-2 md:hidden">
         {data.records.length === 0 ? (
           <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
-            {t("empty")}
+            {t('empty')}
           </div>
         ) : (
           data.records.map((obs) => (
@@ -175,7 +180,7 @@ export function ObservationsClient({
               <p className="mt-1 truncate text-xs text-muted-foreground">
                 {obs.vehicle.licensePlate && (
                   <span className="font-mono font-medium text-foreground">
-                    {obs.vehicle.licensePlate}{" "}
+                    {obs.vehicle.licensePlate}{' '}
                   </span>
                 )}
                 {obs.vehicle.year} {obs.vehicle.make} {obs.vehicle.model}
@@ -183,12 +188,19 @@ export function ObservationsClient({
               <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
                 <Badge
                   variant="outline"
-                  className={`text-xs ${severityColors[obs.severity] || ""}`}
+                  className={`text-xs ${severityColors[obs.severity] || ''}`}
                 >
-                  {tf(`severity.${obs.severity}` as "severity.urgent" | "severity.needs_work" | "severity.monitor")}
+                  {tf(
+                    `severity.${obs.severity}` as
+                      | 'severity.urgent'
+                      | 'severity.needs_work'
+                      | 'severity.monitor'
+                  )}
                 </Badge>
-                <Badge variant="outline" className={`text-xs ${statusColors[obs.status] || ""}`}>
-                  {tf(`status.${obs.status}` as "status.open" | "status.quoted" | "status.resolved")}
+                <Badge variant="outline" className={`text-xs ${statusColors[obs.status] || ''}`}>
+                  {tf(
+                    `status.${obs.status}` as 'status.open' | 'status.quoted' | 'status.resolved'
+                  )}
                 </Badge>
                 <span className="font-mono text-xs text-muted-foreground">
                   {formatDate(new Date(obs.createdAt))}
@@ -205,75 +217,96 @@ export function ObservationsClient({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead className="w-[110px]">{t("columns.severity")}</TableHead>
-              <TableHead className="w-[100px]">{t("columns.status")}</TableHead>
-              <TableHead className="w-[160px]">{t("columns.vehicle")}</TableHead>
-              <TableHead>{t("columns.description")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("columns.notes")}</TableHead>
-              <TableHead className="w-[110px] text-right">{t("columns.created")}</TableHead>
+              <TableHead className="w-[110px]">{t('columns.severity')}</TableHead>
+              <TableHead className="w-[100px]">{t('columns.status')}</TableHead>
+              <TableHead className="w-[160px]">{t('columns.vehicle')}</TableHead>
+              <TableHead>{t('columns.description')}</TableHead>
+              <TableHead className="hidden md:table-cell">{t('columns.notes')}</TableHead>
+              <TableHead className="w-[110px] text-right">{t('columns.created')}</TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
             {data.records.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
-                  {t("empty")}
+                  {t('empty')}
                 </TableCell>
               </TableRow>
             ) : (
               data.records.map((obs) => {
-                const vehicleLabel = obs.vehicle.licensePlate
-                  ?? `${obs.vehicle.year} ${obs.vehicle.make} ${obs.vehicle.model}`;
+                const vehicleLabel =
+                  obs.vehicle.licensePlate ??
+                  `${obs.vehicle.year} ${obs.vehicle.make} ${obs.vehicle.model}`
                 return (
                   <ContextMenu key={obs.id} modal={false}>
-                  <ContextMenuTrigger asChild>
-                  <TableRow
-                    className="cursor-pointer"
-                    {...interactiveRow(() => router.push(`/vehicles/${obs.vehicle.id}?tab=findings`))}
-                  >
-                    <TableCell>
-                      <Badge variant="outline" className={`text-xs ${severityColors[obs.severity] || ""}`}>
-                        {tf(`severity.${obs.severity}` as "severity.urgent" | "severity.needs_work" | "severity.monitor")}
-                      </Badge>
-                    </TableCell>
-                    <TableCell>
-                      <Badge variant="outline" className={`text-xs ${statusColors[obs.status] || ""}`}>
-                        {tf(`status.${obs.status}` as "status.open" | "status.quoted" | "status.resolved")}
-                      </Badge>
-                    </TableCell>
-                    <TableCell>
-                      <TableCellLink href={`/vehicles/${obs.vehicle.id}`} block>
-                        <span className="block font-mono text-xs font-medium">{vehicleLabel}</span>
-                        {obs.vehicle.licensePlate && (
-                          <span className="block text-[11px] text-muted-foreground">
-                            {obs.vehicle.year} {obs.vehicle.make} {obs.vehicle.model}
-                          </span>
+                    <ContextMenuTrigger asChild>
+                      <TableRow
+                        className="cursor-pointer"
+                        {...interactiveRow(() =>
+                          router.push(`/vehicles/${obs.vehicle.id}?tab=findings`)
                         )}
-                      </TableCellLink>
-                    </TableCell>
-                    <TableCell className="text-sm font-medium">{obs.description}</TableCell>
-                    <TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[300px]">
-                      {obs.notes}
-                    </TableCell>
-                    <TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
-                      {formatDate(new Date(obs.createdAt))}
-                    </TableCell>
-                  </TableRow>
-                  </ContextMenuTrigger>
-                  <ContextMenuContent className="min-w-52">
-                    <ContextMenuItem
-                      onClick={() => router.push(`/vehicles/${obs.vehicle.id}?tab=findings`)}
-                    >
-                      <ExternalLink className="mr-2 h-4 w-4" />
-                      {tcm("open")}
-                    </ContextMenuItem>
-                    <ContextMenuItem onClick={() => router.push(`/vehicles/${obs.vehicle.id}`)}>
-                      <Car className="mr-2 h-4 w-4" />
-                      {tcm("openVehicle")}
-                    </ContextMenuItem>
-                  </ContextMenuContent>
+                      >
+                        <TableCell>
+                          <Badge
+                            variant="outline"
+                            className={`text-xs ${severityColors[obs.severity] || ''}`}
+                          >
+                            {tf(
+                              `severity.${obs.severity}` as
+                                | 'severity.urgent'
+                                | 'severity.needs_work'
+                                | 'severity.monitor'
+                            )}
+                          </Badge>
+                        </TableCell>
+                        <TableCell>
+                          <Badge
+                            variant="outline"
+                            className={`text-xs ${statusColors[obs.status] || ''}`}
+                          >
+                            {tf(
+                              `status.${obs.status}` as
+                                | 'status.open'
+                                | 'status.quoted'
+                                | 'status.resolved'
+                            )}
+                          </Badge>
+                        </TableCell>
+                        <TableCell>
+                          <TableCellLink href={`/vehicles/${obs.vehicle.id}`} block>
+                            <span className="block font-mono text-xs font-medium">
+                              {vehicleLabel}
+                            </span>
+                            {obs.vehicle.licensePlate && (
+                              <span className="block text-[11px] text-muted-foreground">
+                                {obs.vehicle.year} {obs.vehicle.make} {obs.vehicle.model}
+                              </span>
+                            )}
+                          </TableCellLink>
+                        </TableCell>
+                        <TableCell className="text-sm font-medium">{obs.description}</TableCell>
+                        <TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[300px]">
+                          {obs.notes}
+                        </TableCell>
+                        <TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
+                          {formatDate(new Date(obs.createdAt))}
+                        </TableCell>
+                      </TableRow>
+                    </ContextMenuTrigger>
+                    <ContextMenuContent className="min-w-52">
+                      <ContextMenuItem
+                        onClick={() => router.push(`/vehicles/${obs.vehicle.id}?tab=findings`)}
+                      >
+                        <ExternalLink className="mr-2 h-4 w-4" />
+                        {tcm('open')}
+                      </ContextMenuItem>
+                      <ContextMenuItem onClick={() => router.push(`/vehicles/${obs.vehicle.id}`)}>
+                        <Car className="mr-2 h-4 w-4" />
+                        {tcm('openVehicle')}
+                      </ContextMenuItem>
+                    </ContextMenuContent>
                   </ContextMenu>
-                );
+                )
               })
             )}
           </TableBody>
@@ -289,5 +322,5 @@ export function ObservationsClient({
         onNavigate={navigate}
       />
     </div>
-  );
+  )
 }

+ 17 - 19
src/app/(authenticated)/observations/page.tsx

@@ -1,19 +1,19 @@
-import { getObservationsPaginated } from "@/features/vehicles/Actions/findingActions";
-import { PageHeader } from "@/components/page-header";
-import { ObservationsClient } from "./observations-client";
+import { getObservationsPaginated } from '@/features/vehicles/Actions/findingActions'
+import { PageHeader } from '@/components/page-header'
+import { ObservationsClient } from './observations-client'
 
 export default async function ObservationsPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    status?: string;
-    severity?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    status?: string
+    severity?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
 
   const result = await getObservationsPaginated({
     page: params.page ? parseInt(params.page) : 1,
@@ -21,19 +21,17 @@ export default async function ObservationsPage({
     search: params.search,
     status: params.status,
     severity: params.severity,
-  });
+  })
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load observations"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load observations'}</p>
         </div>
       </>
-    );
+    )
   }
 
   return (
@@ -42,11 +40,11 @@ export default async function ObservationsPage({
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
         <ObservationsClient
           data={result.data}
-          search={params.search || ""}
-          statusFilter={params.status || "open"}
-          severityFilter={params.severity || "all"}
+          search={params.search || ''}
+          statusFilter={params.status || 'open'}
+          severityFilter={params.severity || 'all'}
         />
       </div>
     </>
-  );
+  )
 }

+ 1 - 2
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -22,8 +22,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
     getInventoryPartsList(),
     getAuthContext(),
   ])
-  const inventoryParts =
-    inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
+  const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
 
   const orgId = authContext?.organizationId
   const features = orgId ? await getFeatures(orgId) : null

+ 2 - 2
src/app/(authenticated)/quotes/loading.tsx

@@ -1,4 +1,4 @@
-import { Skeleton } from "@/components/ui/skeleton";
+import { Skeleton } from '@/components/ui/skeleton'
 
 export default function QuotesLoading() {
   return (
@@ -32,5 +32,5 @@ export default function QuotesLoading() {
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 128 - 78
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -1,8 +1,8 @@
 'use client'
 
-import { interactiveRow } from '@/lib/interactive-row';
-import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav';
-import { useDebouncedSearch } from '@/hooks/use-debounced-search';
+import { interactiveRow } from '@/lib/interactive-row'
+import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
 import { useState, useCallback, useTransition, useEffect } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
@@ -28,7 +28,17 @@ import {
 import { DataTablePagination } from '@/components/data-table-pagination'
 import { TableContextMenuHint } from '@/components/table-context-menu-hint'
 import { TableCellLink } from '@/components/table-cell-link'
-import { ArrowDown, ArrowUp, ArrowUpDown, Car, ExternalLink, Loader2, Plus, Search, User } from 'lucide-react'
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  Car,
+  ExternalLink,
+  Loader2,
+  Plus,
+  Search,
+  User,
+} from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 
@@ -139,7 +149,7 @@ export function QuotesClient({
     value: searchInput,
     setValue: setSearchInput,
     commitNow: handleSearch,
-  } = useDebouncedSearch(search, (term) => navigate({ search: term }));
+  } = useDebouncedSearch(search, (term) => navigate({ search: term }))
 
   const openNewDialog = () => setShowNewDialog(true)
 
@@ -153,9 +163,11 @@ export function QuotesClient({
 
   const SortIcon = ({ column }: { column: string }) => {
     if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
-    return sortOrder === 'asc'
-      ? <ArrowUp className="ml-1 h-3 w-3" />
-      : <ArrowDown className="ml-1 h-3 w-3" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
   }
 
   return (
@@ -266,38 +278,73 @@ export function QuotesClient({
           <TableHeader>
             <TableRow>
               <TableHead className="w-25">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('quoteNumber')}>
-                  {t('list.columnQuoteNumber')}<SortIcon column="quoteNumber" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('quoteNumber')}
+                >
+                  {t('list.columnQuoteNumber')}
+                  <SortIcon column="quoteNumber" />
                 </button>
               </TableHead>
               <TableHead>
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('title')}>
-                  {t('list.columnTitle')}<SortIcon column="title" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('title')}
+                >
+                  {t('list.columnTitle')}
+                  <SortIcon column="title" />
                 </button>
               </TableHead>
               <TableHead className="hidden w-[18%] md:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('customer')}>
-                  {t('list.columnCustomer')}<SortIcon column="customer" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('customer')}
+                >
+                  {t('list.columnCustomer')}
+                  <SortIcon column="customer" />
                 </button>
               </TableHead>
               <TableHead className="hidden w-[18%] lg:table-cell">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('vehicle')}>
-                  {t('list.columnVehicle')}<SortIcon column="vehicle" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('vehicle')}
+                >
+                  {t('list.columnVehicle')}
+                  <SortIcon column="vehicle" />
                 </button>
               </TableHead>
               <TableHead className="w-27.5">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('status')}>
-                  {t('list.columnStatus')}<SortIcon column="status" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('status')}
+                >
+                  {t('list.columnStatus')}
+                  <SortIcon column="status" />
                 </button>
               </TableHead>
               <TableHead className="w-22.5">
-                <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort('createdAt')}>
-                  {t('list.columnDate')}<SortIcon column="createdAt" />
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('createdAt')}
+                >
+                  {t('list.columnDate')}
+                  <SortIcon column="createdAt" />
                 </button>
               </TableHead>
               <TableHead className="w-22.5">
-                <button type="button" className="ml-auto flex items-center hover:text-foreground" onClick={() => handleSort('totalAmount')}>
-                  {t('list.columnTotal')}<SortIcon column="totalAmount" />
+                <button
+                  type="button"
+                  className="ml-auto flex items-center hover:text-foreground"
+                  onClick={() => handleSort('totalAmount')}
+                >
+                  {t('list.columnTotal')}
+                  <SortIcon column="totalAmount" />
                 </button>
               </TableHead>
             </TableRow>
@@ -312,64 +359,67 @@ export function QuotesClient({
             ) : (
               data.records.map((q) => (
                 <ContextMenu key={q.id} modal={false}>
-                <ContextMenuTrigger asChild>
-                <TableRow
-                  className="cursor-pointer"
-                  {...interactiveRow(() => router.push(`/quotes/${q.id}`))}
-                >
-                  <TableCell className="font-mono text-xs text-muted-foreground">
-                    {q.quoteNumber || '-'}
-                  </TableCell>
-                  <TableCell className="truncate font-medium">{q.title}</TableCell>
-                  <TableCell className="hidden truncate md:table-cell text-muted-foreground">
-                    {q.customer ? (
-                      <TableCellLink href={`/customers/${q.customer.id}`}>
-                        {q.customer.name}
-                      </TableCellLink>
-                    ) : (
-                      '-'
+                  <ContextMenuTrigger asChild>
+                    <TableRow
+                      className="cursor-pointer"
+                      {...interactiveRow(() => router.push(`/quotes/${q.id}`))}
+                    >
+                      <TableCell className="font-mono text-xs text-muted-foreground">
+                        {q.quoteNumber || '-'}
+                      </TableCell>
+                      <TableCell className="truncate font-medium">{q.title}</TableCell>
+                      <TableCell className="hidden truncate md:table-cell text-muted-foreground">
+                        {q.customer ? (
+                          <TableCellLink href={`/customers/${q.customer.id}`}>
+                            {q.customer.name}
+                          </TableCellLink>
+                        ) : (
+                          '-'
+                        )}
+                      </TableCell>
+                      <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
+                        {q.vehicle ? (
+                          <TableCellLink href={`/vehicles/${q.vehicle.id}`}>
+                            {q.vehicle.year} {q.vehicle.make} {q.vehicle.model}
+                          </TableCellLink>
+                        ) : (
+                          '-'
+                        )}
+                      </TableCell>
+                      <TableCell>
+                        <Badge
+                          variant="outline"
+                          className={`text-xs ${statusColors[q.status] || ''}`}
+                        >
+                          {q.status}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="font-mono text-xs">
+                        {formatDate(new Date(q.createdAt))}
+                      </TableCell>
+                      <TableCell className="text-right font-semibold">
+                        {formatCurrency(q.totalAmount, currencyCode)}
+                      </TableCell>
+                    </TableRow>
+                  </ContextMenuTrigger>
+                  <ContextMenuContent className="min-w-52">
+                    <ContextMenuItem onClick={() => router.push(`/quotes/${q.id}`)}>
+                      <ExternalLink className="mr-2 h-4 w-4" />
+                      {tcm('open')}
+                    </ContextMenuItem>
+                    {q.vehicle && (
+                      <ContextMenuItem onClick={() => router.push(`/vehicles/${q.vehicle?.id}`)}>
+                        <Car className="mr-2 h-4 w-4" />
+                        {tcm('openVehicle')}
+                      </ContextMenuItem>
                     )}
-                  </TableCell>
-                  <TableCell className="hidden truncate lg:table-cell text-muted-foreground">
-                    {q.vehicle ? (
-                      <TableCellLink href={`/vehicles/${q.vehicle.id}`}>
-                        {q.vehicle.year} {q.vehicle.make} {q.vehicle.model}
-                      </TableCellLink>
-                    ) : (
-                      '-'
+                    {q.customer && (
+                      <ContextMenuItem onClick={() => router.push(`/customers/${q.customer?.id}`)}>
+                        <User className="mr-2 h-4 w-4" />
+                        {tcm('openCustomer')}
+                      </ContextMenuItem>
                     )}
-                  </TableCell>
-                  <TableCell>
-                    <Badge variant="outline" className={`text-xs ${statusColors[q.status] || ''}`}>
-                      {q.status}
-                    </Badge>
-                  </TableCell>
-                  <TableCell className="font-mono text-xs">
-                    {formatDate(new Date(q.createdAt))}
-                  </TableCell>
-                  <TableCell className="text-right font-semibold">
-                    {formatCurrency(q.totalAmount, currencyCode)}
-                  </TableCell>
-                </TableRow>
-                </ContextMenuTrigger>
-                <ContextMenuContent className="min-w-52">
-                  <ContextMenuItem onClick={() => router.push(`/quotes/${q.id}`)}>
-                    <ExternalLink className="mr-2 h-4 w-4" />
-                    {tcm('open')}
-                  </ContextMenuItem>
-                  {q.vehicle && (
-                    <ContextMenuItem onClick={() => router.push(`/vehicles/${q.vehicle?.id}`)}>
-                      <Car className="mr-2 h-4 w-4" />
-                      {tcm('openVehicle')}
-                    </ContextMenuItem>
-                  )}
-                  {q.customer && (
-                    <ContextMenuItem onClick={() => router.push(`/customers/${q.customer?.id}`)}>
-                      <User className="mr-2 h-4 w-4" />
-                      {tcm('openCustomer')}
-                    </ContextMenuItem>
-                  )}
-                </ContextMenuContent>
+                  </ContextMenuContent>
                 </ContextMenu>
               ))
             )}

Некоторые файлы не были показаны из-за большого количества измененных файлов