Переглянути джерело

Point people at the app now that it is on Google Play (#275)

* Bill a job as one numbered list, not two tables

A workshop that quotes work as a sequence of positions has had no way to
print one: the invoice always split into a parts table above a labor
table. This adds `items_table`, a section that puts every line on a single
numbered list with the unit of measure in its own column, and the
Itemized preset that switches to it.

Presets carry a layout now, not just a color. Ones that don't 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.

While there, the quotation tab loads the quote layout instead of
previewing quotes through the invoice's.

The combined table drops the parts and labor subtotal rows, which would
otherwise break a sum back into halves the reader never saw split. It is
hidden by default, so nothing changes for anyone who does not pick it.

* Point people at the app now that it is on Google Play
Bernt Christian Egeland 1 місяць тому
батько
коміт
f76f7e6508
36 змінених файлів з 1247 додано та 687 видалено
  1. 3 0
      messages/de/pdf.json
  2. 3 1
      messages/de/settings.json
  3. 3 0
      messages/en/pdf.json
  4. 3 1
      messages/en/settings.json
  5. 3 0
      messages/es/pdf.json
  6. 3 1
      messages/es/settings.json
  7. 3 0
      messages/fr/pdf.json
  8. 3 1
      messages/fr/settings.json
  9. 3 0
      messages/it/pdf.json
  10. 3 1
      messages/it/settings.json
  11. 3 0
      messages/lt/pdf.json
  12. 3 1
      messages/lt/settings.json
  13. 3 0
      messages/nb/pdf.json
  14. 3 1
      messages/nb/settings.json
  15. 3 0
      messages/nl/pdf.json
  16. 3 1
      messages/nl/settings.json
  17. 3 0
      messages/pl/pdf.json
  18. 3 1
      messages/pl/settings.json
  19. 3 0
      messages/pt-BR/pdf.json
  20. 3 1
      messages/pt-BR/settings.json
  21. 3 0
      messages/ru/pdf.json
  22. 3 1
      messages/ru/settings.json
  23. 3 0
      messages/tr/pdf.json
  24. 3 1
      messages/tr/settings.json
  25. 123 0
      src/__tests__/features/settings/itemized-template-preset.test.tsx
  26. 63 54
      src/app/(authenticated)/settings/templates/page.tsx
  27. 432 396
      src/app/(authenticated)/settings/templates/template-settings.tsx
  28. 15 1
      src/app/(public)/app-setup/app-setup-landing.tsx
  29. 48 4
      src/features/quotes/Components/QuotePDF.tsx
  30. 230 216
      src/features/settings/Schema/invoiceLayoutSchema.ts
  31. 42 0
      src/features/settings/Schema/templatePresets.ts
  32. 20 1
      src/features/team/Components/SetupCodeHandoff.tsx
  33. 37 1
      src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx
  34. 143 0
      src/features/vehicles/Components/invoice-pdf/ItemsTable.tsx
  35. 9 2
      src/features/vehicles/Components/invoice-pdf/Totals.tsx
  36. 13 0
      src/lib/app-links.ts

+ 3 - 0
messages/de/pdf.json

@@ -19,6 +19,9 @@
     "description": "Beschreibung",
     "description": "Beschreibung",
     "qty": "Menge",
     "qty": "Menge",
     "unitPrice": "Einzelpreis",
     "unitPrice": "Einzelpreis",
+    "items": "Positionen",
+    "pos": "Pos.",
+    "unitOfMeasure": "Einheit",
     "total": "Gesamt",
     "total": "Gesamt",
     "labor": "Arbeitsleistung",
     "labor": "Arbeitsleistung",
     "hours": "Stunden",
     "hours": "Stunden",

+ 3 - 1
messages/de/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Diese Rolle kann keine Aufträge bearbeiten. Geben Sie ihr zuerst Techniker oder eine Rolle mit denselben Rechten.",
     "cannotUseApp": "Diese Rolle kann keine Aufträge bearbeiten. Geben Sie ihr zuerst Techniker oder eine Rolle mit denselben Rechten.",
     "revokeTechnicianDone": "{name} wurde aus der App abgemeldet",
     "revokeTechnicianDone": "{name} wurde aus der App abgemeldet",
     "noAccessTitle": "Noch kein Zugriff",
     "noAccessTitle": "Noch kein Zugriff",
-    "noAccessBody": "Ihr Konto gehört zu {workshop}, hat aber noch keine Rolle, deshalb gibt es hier nichts zu öffnen. Bitten Sie einen Inhaber oder Administrator Ihrer Werkstatt um eine Rolle."
+    "noAccessBody": "Ihr Konto gehört zu {workshop}, hat aber noch keine Rolle, deshalb gibt es hier nichts zu öffnen. Bitten Sie einen Inhaber oder Administrator Ihrer Werkstatt um eine Rolle.",
+    "setupPageInstall": "Bei Google Play laden"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Rechnungslayout",
     "title": "Rechnungslayout",
@@ -569,6 +570,7 @@
       "customer": "Kunde",
       "customer": "Kunde",
       "vehicle": "Fahrzeug",
       "vehicle": "Fahrzeug",
       "service": "Service",
       "service": "Service",
+      "items_table": "Positionstabelle",
       "parts_table": "Teile",
       "parts_table": "Teile",
       "labor_table": "Arbeit",
       "labor_table": "Arbeit",
       "totals": "Summen",
       "totals": "Summen",

+ 3 - 0
messages/en/pdf.json

@@ -19,6 +19,9 @@
     "description": "Description",
     "description": "Description",
     "qty": "Qty",
     "qty": "Qty",
     "unitPrice": "Unit Price",
     "unitPrice": "Unit Price",
+    "items": "Items",
+    "pos": "#",
+    "unitOfMeasure": "Unit",
     "total": "Total",
     "total": "Total",
     "labor": "Labor & Services",
     "labor": "Labor & Services",
     "hours": "Hours",
     "hours": "Hours",

+ 3 - 1
messages/en/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Their role cannot work on jobs. Give them Technician, or a role with the same permissions, first.",
     "cannotUseApp": "Their role cannot work on jobs. Give them Technician, or a role with the same permissions, first.",
     "revokeTechnicianDone": "{name} has been signed out of the app",
     "revokeTechnicianDone": "{name} has been signed out of the app",
     "noAccessTitle": "No access yet",
     "noAccessTitle": "No access yet",
-    "noAccessBody": "Your account is part of {workshop}, but it has not been given a role, so there is nothing here you can open yet. Ask an owner or an admin at your workshop to give you one."
+    "noAccessBody": "Your account is part of {workshop}, but it has not been given a role, so there is nothing here you can open yet. Ask an owner or an admin at your workshop to give you one.",
+    "setupPageInstall": "Get it on Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Invoice & Quotes",
     "title": "Invoice & Quotes",
@@ -569,6 +570,7 @@
       "customer": "Customer",
       "customer": "Customer",
       "vehicle": "Vehicle",
       "vehicle": "Vehicle",
       "service": "Service",
       "service": "Service",
+      "items_table": "Items Table",
       "parts_table": "Parts Table",
       "parts_table": "Parts Table",
       "labor_table": "Labor Table",
       "labor_table": "Labor Table",
       "totals": "Totals",
       "totals": "Totals",

+ 3 - 0
messages/es/pdf.json

@@ -19,6 +19,9 @@
     "description": "Descripción",
     "description": "Descripción",
     "qty": "Cant.",
     "qty": "Cant.",
     "unitPrice": "Precio unitario",
     "unitPrice": "Precio unitario",
+    "items": "Artículos",
+    "pos": "N.º",
+    "unitOfMeasure": "Unidad",
     "total": "Total",
     "total": "Total",
     "labor": "Mano de obra",
     "labor": "Mano de obra",
     "hours": "Horas",
     "hours": "Horas",

+ 3 - 1
messages/es/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Su rol no puede trabajar en los trabajos. Déle primero Técnico, o un rol con los mismos permisos.",
     "cannotUseApp": "Su rol no puede trabajar en los trabajos. Déle primero Técnico, o un rol con los mismos permisos.",
     "revokeTechnicianDone": "{name} ha salido de la aplicación",
     "revokeTechnicianDone": "{name} ha salido de la aplicación",
     "noAccessTitle": "Sin acceso todavía",
     "noAccessTitle": "Sin acceso todavía",
-    "noAccessBody": "Su cuenta pertenece a {workshop}, pero no tiene un rol asignado, así que aquí no hay nada que pueda abrir. Pida un rol a un propietario o administrador de su taller."
+    "noAccessBody": "Su cuenta pertenece a {workshop}, pero no tiene un rol asignado, así que aquí no hay nada que pueda abrir. Pida un rol a un propietario o administrador de su taller.",
+    "setupPageInstall": "Consíguelo en Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Diseño de factura",
     "title": "Diseño de factura",
@@ -569,6 +570,7 @@
       "customer": "Cliente",
       "customer": "Cliente",
       "vehicle": "Vehículo",
       "vehicle": "Vehículo",
       "service": "Servicio",
       "service": "Servicio",
+      "items_table": "Tabla de artículos",
       "parts_table": "Piezas",
       "parts_table": "Piezas",
       "labor_table": "Mano de obra",
       "labor_table": "Mano de obra",
       "totals": "Totales",
       "totals": "Totales",

+ 3 - 0
messages/fr/pdf.json

@@ -19,6 +19,9 @@
     "description": "Description",
     "description": "Description",
     "qty": "Qté",
     "qty": "Qté",
     "unitPrice": "Prix unitaire",
     "unitPrice": "Prix unitaire",
+    "items": "Articles",
+    "pos": "N°",
+    "unitOfMeasure": "Unité",
     "total": "Total",
     "total": "Total",
     "labor": "Main-d'œuvre",
     "labor": "Main-d'œuvre",
     "hours": "Heures",
     "hours": "Heures",

+ 3 - 1
messages/fr/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Son rôle ne permet pas de travailler sur les travaux. Donnez-lui d’abord Technicien, ou un rôle ayant les mêmes permissions.",
     "cannotUseApp": "Son rôle ne permet pas de travailler sur les travaux. Donnez-lui d’abord Technicien, ou un rôle ayant les mêmes permissions.",
     "revokeTechnicianDone": "{name} a été déconnecté de l’application",
     "revokeTechnicianDone": "{name} a été déconnecté de l’application",
     "noAccessTitle": "Pas encore d’accès",
     "noAccessTitle": "Pas encore d’accès",
-    "noAccessBody": "Votre compte fait partie de {workshop}, mais aucun rôle ne lui a été attribué, donc il n’y a rien à ouvrir ici. Demandez un rôle à un propriétaire ou un administrateur de votre atelier."
+    "noAccessBody": "Votre compte fait partie de {workshop}, mais aucun rôle ne lui a été attribué, donc il n’y a rien à ouvrir ici. Demandez un rôle à un propriétaire ou un administrateur de votre atelier.",
+    "setupPageInstall": "Télécharger sur Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Mise en page de facture",
     "title": "Mise en page de facture",
@@ -569,6 +570,7 @@
       "customer": "Client",
       "customer": "Client",
       "vehicle": "Véhicule",
       "vehicle": "Véhicule",
       "service": "Service",
       "service": "Service",
+      "items_table": "Tableau des articles",
       "parts_table": "Pièces",
       "parts_table": "Pièces",
       "labor_table": "Main-d'œuvre",
       "labor_table": "Main-d'œuvre",
       "totals": "Totaux",
       "totals": "Totaux",

+ 3 - 0
messages/it/pdf.json

@@ -19,6 +19,9 @@
     "description": "Descrizione",
     "description": "Descrizione",
     "qty": "Qtà",
     "qty": "Qtà",
     "unitPrice": "Prezzo unitario",
     "unitPrice": "Prezzo unitario",
+    "items": "Voci",
+    "pos": "N.",
+    "unitOfMeasure": "Unità",
     "total": "Totale",
     "total": "Totale",
     "labor": "Manodopera",
     "labor": "Manodopera",
     "hours": "Ore",
     "hours": "Ore",

+ 3 - 1
messages/it/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Il suo ruolo non può lavorare sui lavori. Assegnagli prima Tecnico, o un ruolo con gli stessi permessi.",
     "cannotUseApp": "Il suo ruolo non può lavorare sui lavori. Assegnagli prima Tecnico, o un ruolo con gli stessi permessi.",
     "revokeTechnicianDone": "{name} è stato disconnesso dall’app",
     "revokeTechnicianDone": "{name} è stato disconnesso dall’app",
     "noAccessTitle": "Ancora nessun accesso",
     "noAccessTitle": "Ancora nessun accesso",
-    "noAccessBody": "Il tuo account fa parte di {workshop}, ma non ha ancora un ruolo, quindi qui non c’è nulla da aprire. Chiedi un ruolo a un titolare o a un amministratore della tua officina."
+    "noAccessBody": "Il tuo account fa parte di {workshop}, ma non ha ancora un ruolo, quindi qui non c’è nulla da aprire. Chiedi un ruolo a un titolare o a un amministratore della tua officina.",
+    "setupPageInstall": "Scaricala su Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Layout fattura",
     "title": "Layout fattura",
@@ -569,6 +570,7 @@
       "customer": "Cliente",
       "customer": "Cliente",
       "vehicle": "Veicolo",
       "vehicle": "Veicolo",
       "service": "Servizio",
       "service": "Servizio",
+      "items_table": "Tabella voci",
       "parts_table": "Ricambi",
       "parts_table": "Ricambi",
       "labor_table": "Manodopera",
       "labor_table": "Manodopera",
       "totals": "Totali",
       "totals": "Totali",

+ 3 - 0
messages/lt/pdf.json

@@ -19,6 +19,9 @@
     "description": "Aprašymas",
     "description": "Aprašymas",
     "qty": "Kiekis",
     "qty": "Kiekis",
     "unitPrice": "Vnt. kaina",
     "unitPrice": "Vnt. kaina",
+    "items": "Pozicijos",
+    "pos": "Nr.",
+    "unitOfMeasure": "Mato vnt.",
     "total": "Suma",
     "total": "Suma",
     "labor": "Darbai ir paslaugos",
     "labor": "Darbai ir paslaugos",
     "hours": "Valandos",
     "hours": "Valandos",

+ 3 - 1
messages/lt/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Jo rolė neleidžia dirbti su užsakymais. Pirma suteikite meistro rolę arba rolę su tomis pačiomis teisėmis.",
     "cannotUseApp": "Jo rolė neleidžia dirbti su užsakymais. Pirma suteikite meistro rolę arba rolę su tomis pačiomis teisėmis.",
     "revokeTechnicianDone": "{name} atjungtas nuo programėlės",
     "revokeTechnicianDone": "{name} atjungtas nuo programėlės",
     "noAccessTitle": "Prieigos kol kas nėra",
     "noAccessTitle": "Prieigos kol kas nėra",
-    "noAccessBody": "Jūsų paskyra priklauso {workshop}, bet jai dar nesuteikta rolė, todėl čia nėra ką atidaryti. Paprašykite dirbtuvių savininko arba administratoriaus suteikti jums rolę."
+    "noAccessBody": "Jūsų paskyra priklauso {workshop}, bet jai dar nesuteikta rolė, todėl čia nėra ką atidaryti. Paprašykite dirbtuvių savininko arba administratoriaus suteikti jums rolę.",
+    "setupPageInstall": "Atsisiųsti iš „Google Play“"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Sąskaitos ir pasiūlymai",
     "title": "Sąskaitos ir pasiūlymai",
@@ -569,6 +570,7 @@
       "customer": "Klientas",
       "customer": "Klientas",
       "vehicle": "Transporto priemonė",
       "vehicle": "Transporto priemonė",
       "service": "Paslauga",
       "service": "Paslauga",
+      "items_table": "Pozicijų lentelė",
       "parts_table": "Dalių lentelė",
       "parts_table": "Dalių lentelė",
       "labor_table": "Darbų lentelė",
       "labor_table": "Darbų lentelė",
       "totals": "Sumos",
       "totals": "Sumos",

+ 3 - 0
messages/nb/pdf.json

@@ -19,6 +19,9 @@
     "description": "Beskrivelse",
     "description": "Beskrivelse",
     "qty": "Ant.",
     "qty": "Ant.",
     "unitPrice": "Enhetspris",
     "unitPrice": "Enhetspris",
+    "items": "Poster",
+    "pos": "Nr.",
+    "unitOfMeasure": "Enhet",
     "total": "Totalt",
     "total": "Totalt",
     "labor": "Arbeid",
     "labor": "Arbeid",
     "hours": "Timer",
     "hours": "Timer",

+ 3 - 1
messages/nb/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Rollen deres kan ikke jobbe på jobber. Gi dem Tekniker, eller en rolle med de samme tillatelsene, først.",
     "cannotUseApp": "Rollen deres kan ikke jobbe på jobber. Gi dem Tekniker, eller en rolle med de samme tillatelsene, først.",
     "revokeTechnicianDone": "{name} er logget ut av appen",
     "revokeTechnicianDone": "{name} er logget ut av appen",
     "noAccessTitle": "Ingen tilgang ennå",
     "noAccessTitle": "Ingen tilgang ennå",
-    "noAccessBody": "Kontoen din tilhører {workshop}, men den har ikke fått en rolle ennå, så det er ingenting her du kan åpne. Be en eier eller administrator på verkstedet om å gi deg en."
+    "noAccessBody": "Kontoen din tilhører {workshop}, men den har ikke fått en rolle ennå, så det er ingenting her du kan åpne. Be en eier eller administrator på verkstedet om å gi deg en.",
+    "setupPageInstall": "Last ned fra Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Faktura og tilbud",
     "title": "Faktura og tilbud",
@@ -569,6 +570,7 @@
       "customer": "Kunde",
       "customer": "Kunde",
       "vehicle": "Kjøretøy",
       "vehicle": "Kjøretøy",
       "service": "Tjeneste",
       "service": "Tjeneste",
+      "items_table": "Posttabell",
       "parts_table": "Deler",
       "parts_table": "Deler",
       "labor_table": "Arbeid",
       "labor_table": "Arbeid",
       "totals": "Totaler",
       "totals": "Totaler",

+ 3 - 0
messages/nl/pdf.json

@@ -19,6 +19,9 @@
     "description": "Omschrijving",
     "description": "Omschrijving",
     "qty": "Aantal",
     "qty": "Aantal",
     "unitPrice": "Stukprijs",
     "unitPrice": "Stukprijs",
+    "items": "Artikelen",
+    "pos": "Nr.",
+    "unitOfMeasure": "Eenheid",
     "total": "Totaal",
     "total": "Totaal",
     "labor": "Arbeid",
     "labor": "Arbeid",
     "hours": "Uren",
     "hours": "Uren",

+ 3 - 1
messages/nl/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Hun rol kan niet aan klussen werken. Geef ze eerst Monteur, of een rol met dezelfde rechten.",
     "cannotUseApp": "Hun rol kan niet aan klussen werken. Geef ze eerst Monteur, of een rol met dezelfde rechten.",
     "revokeTechnicianDone": "{name} is uitgelogd uit de app",
     "revokeTechnicianDone": "{name} is uitgelogd uit de app",
     "noAccessTitle": "Nog geen toegang",
     "noAccessTitle": "Nog geen toegang",
-    "noAccessBody": "Uw account hoort bij {workshop}, maar heeft nog geen rol, dus er is hier niets te openen. Vraag een eigenaar of beheerder van uw werkplaats om een rol."
+    "noAccessBody": "Uw account hoort bij {workshop}, maar heeft nog geen rol, dus er is hier niets te openen. Vraag een eigenaar of beheerder van uw werkplaats om een rol.",
+    "setupPageInstall": "Downloaden via Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Factuurindeling",
     "title": "Factuurindeling",
@@ -569,6 +570,7 @@
       "customer": "Klant",
       "customer": "Klant",
       "vehicle": "Voertuig",
       "vehicle": "Voertuig",
       "service": "Service",
       "service": "Service",
+      "items_table": "Artikeltabel",
       "parts_table": "Onderdelen",
       "parts_table": "Onderdelen",
       "labor_table": "Arbeid",
       "labor_table": "Arbeid",
       "totals": "Totalen",
       "totals": "Totalen",

+ 3 - 0
messages/pl/pdf.json

@@ -19,6 +19,9 @@
     "description": "Opis",
     "description": "Opis",
     "qty": "Ilość",
     "qty": "Ilość",
     "unitPrice": "Cena jednostkowa",
     "unitPrice": "Cena jednostkowa",
+    "items": "Pozycje",
+    "pos": "Lp.",
+    "unitOfMeasure": "Jedn.",
     "total": "Suma",
     "total": "Suma",
     "labor": "Robocizna",
     "labor": "Robocizna",
     "hours": "Godziny",
     "hours": "Godziny",

+ 3 - 1
messages/pl/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Ta rola nie może pracować przy zleceniach. Nadaj najpierw rolę Technik lub inną z tymi samymi uprawnieniami.",
     "cannotUseApp": "Ta rola nie może pracować przy zleceniach. Nadaj najpierw rolę Technik lub inną z tymi samymi uprawnieniami.",
     "revokeTechnicianDone": "{name} został wylogowany z aplikacji",
     "revokeTechnicianDone": "{name} został wylogowany z aplikacji",
     "noAccessTitle": "Brak dostępu",
     "noAccessTitle": "Brak dostępu",
-    "noAccessBody": "Twoje konto należy do {workshop}, ale nie ma jeszcze przypisanej roli, więc nie ma tu nic, co możesz otworzyć. Poproś właściciela lub administratora warsztatu o rolę."
+    "noAccessBody": "Twoje konto należy do {workshop}, ale nie ma jeszcze przypisanej roli, więc nie ma tu nic, co możesz otworzyć. Poproś właściciela lub administratora warsztatu o rolę.",
+    "setupPageInstall": "Pobierz z Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Układ faktury",
     "title": "Układ faktury",
@@ -569,6 +570,7 @@
       "customer": "Klient",
       "customer": "Klient",
       "vehicle": "Pojazd",
       "vehicle": "Pojazd",
       "service": "Usługa",
       "service": "Usługa",
+      "items_table": "Tabela pozycji",
       "parts_table": "Części",
       "parts_table": "Części",
       "labor_table": "Robocizna",
       "labor_table": "Robocizna",
       "totals": "Podsumowanie",
       "totals": "Podsumowanie",

+ 3 - 0
messages/pt-BR/pdf.json

@@ -19,6 +19,9 @@
     "description": "Descrição",
     "description": "Descrição",
     "qty": "Qtd",
     "qty": "Qtd",
     "unitPrice": "Preço Unitário",
     "unitPrice": "Preço Unitário",
+    "items": "Itens",
+    "pos": "Nº",
+    "unitOfMeasure": "Unidade",
     "total": "Total",
     "total": "Total",
     "labor": "Mão de Obra",
     "labor": "Mão de Obra",
     "hours": "Horas",
     "hours": "Horas",

+ 3 - 1
messages/pt-BR/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "A função dela não permite trabalhar em serviços. Dê primeiro Técnico, ou uma função com as mesmas permissões.",
     "cannotUseApp": "A função dela não permite trabalhar em serviços. Dê primeiro Técnico, ou uma função com as mesmas permissões.",
     "revokeTechnicianDone": "{name} foi desconectado do aplicativo",
     "revokeTechnicianDone": "{name} foi desconectado do aplicativo",
     "noAccessTitle": "Ainda sem acesso",
     "noAccessTitle": "Ainda sem acesso",
-    "noAccessBody": "Sua conta faz parte da {workshop}, mas ainda não recebeu uma função, então não há nada aqui que você possa abrir. Peça uma função ao proprietário ou a um administrador da sua oficina."
+    "noAccessBody": "Sua conta faz parte da {workshop}, mas ainda não recebeu uma função, então não há nada aqui que você possa abrir. Peça uma função ao proprietário ou a um administrador da sua oficina.",
+    "setupPageInstall": "Baixar no Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Layout da fatura",
     "title": "Layout da fatura",
@@ -569,6 +570,7 @@
       "customer": "Cliente",
       "customer": "Cliente",
       "vehicle": "Veículo",
       "vehicle": "Veículo",
       "service": "Serviço",
       "service": "Serviço",
+      "items_table": "Tabela de itens",
       "parts_table": "Peças",
       "parts_table": "Peças",
       "labor_table": "Mão de obra",
       "labor_table": "Mão de obra",
       "totals": "Totais",
       "totals": "Totais",

+ 3 - 0
messages/ru/pdf.json

@@ -19,6 +19,9 @@
     "description": "Описание",
     "description": "Описание",
     "qty": "Кол-во",
     "qty": "Кол-во",
     "unitPrice": "Цена за ед.",
     "unitPrice": "Цена за ед.",
+    "items": "Позиции",
+    "pos": "№",
+    "unitOfMeasure": "Ед.",
     "total": "Итого",
     "total": "Итого",
     "labor": "Работа",
     "labor": "Работа",
     "hours": "Часы",
     "hours": "Часы",

+ 3 - 1
messages/ru/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Его роль не позволяет работать с заказ-нарядами. Сначала назначьте роль механика или роль с теми же правами.",
     "cannotUseApp": "Его роль не позволяет работать с заказ-нарядами. Сначала назначьте роль механика или роль с теми же правами.",
     "revokeTechnicianDone": "{name} вышел из приложения",
     "revokeTechnicianDone": "{name} вышел из приложения",
     "noAccessTitle": "Доступа пока нет",
     "noAccessTitle": "Доступа пока нет",
-    "noAccessBody": "Ваша учётная запись относится к {workshop}, но ей ещё не назначена роль, поэтому открыть здесь нечего. Попросите владельца или администратора мастерской назначить вам роль."
+    "noAccessBody": "Ваша учётная запись относится к {workshop}, но ей ещё не назначена роль, поэтому открыть здесь нечего. Попросите владельца или администратора мастерской назначить вам роль.",
+    "setupPageInstall": "Загрузить в Google Play"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Счета и предложения",
     "title": "Счета и предложения",
@@ -569,6 +570,7 @@
       "customer": "Клиент",
       "customer": "Клиент",
       "vehicle": "Транспортное средство",
       "vehicle": "Транспортное средство",
       "service": "Обслуживание",
       "service": "Обслуживание",
+      "items_table": "Таблица позиций",
       "parts_table": "Таблица запчастей",
       "parts_table": "Таблица запчастей",
       "labor_table": "Таблица работ",
       "labor_table": "Таблица работ",
       "totals": "Итоги",
       "totals": "Итоги",

+ 3 - 0
messages/tr/pdf.json

@@ -19,6 +19,9 @@
     "description": "Açıklama",
     "description": "Açıklama",
     "qty": "Adet",
     "qty": "Adet",
     "unitPrice": "Birim Fiyat",
     "unitPrice": "Birim Fiyat",
+    "items": "Kalemler",
+    "pos": "No",
+    "unitOfMeasure": "Birim",
     "total": "Toplam",
     "total": "Toplam",
     "labor": "İşçilik",
     "labor": "İşçilik",
     "hours": "Saat",
     "hours": "Saat",

+ 3 - 1
messages/tr/settings.json

@@ -495,7 +495,8 @@
     "cannotUseApp": "Rolü işler üzerinde çalışmaya izin vermiyor. Önce Teknisyen ya da aynı izinlere sahip bir rol verin.",
     "cannotUseApp": "Rolü işler üzerinde çalışmaya izin vermiyor. Önce Teknisyen ya da aynı izinlere sahip bir rol verin.",
     "revokeTechnicianDone": "{name} uygulamadan çıkarıldı",
     "revokeTechnicianDone": "{name} uygulamadan çıkarıldı",
     "noAccessTitle": "Henüz erişim yok",
     "noAccessTitle": "Henüz erişim yok",
-    "noAccessBody": "Hesabınız {workshop} atölyesine ait, ancak henüz bir rol verilmemiş; bu yüzden burada açabileceğiniz bir şey yok. Atölyenizdeki bir sahip ya da yöneticiden rol isteyin."
+    "noAccessBody": "Hesabınız {workshop} atölyesine ait, ancak henüz bir rol verilmemiş; bu yüzden burada açabileceğiniz bir şey yok. Atölyenizdeki bir sahip ya da yöneticiden rol isteyin.",
+    "setupPageInstall": "Google Play’den indirin"
   },
   },
   "invoice": {
   "invoice": {
     "title": "Fatura Düzeni",
     "title": "Fatura Düzeni",
@@ -569,6 +570,7 @@
       "customer": "Müşteri",
       "customer": "Müşteri",
       "vehicle": "Araç",
       "vehicle": "Araç",
       "service": "Servis",
       "service": "Servis",
+      "items_table": "Kalem tablosu",
       "parts_table": "Parçalar",
       "parts_table": "Parçalar",
       "labor_table": "İşçilik",
       "labor_table": "İşçilik",
       "totals": "Toplamlar",
       "totals": "Toplamlar",

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

@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment node
+ *
+ * The Itemized preset is the first one that rearranges sections rather than
+ * only recoloring them, so two things have to keep holding: a workshop that
+ * never picks it sees the parts and labor tables it has always seen, and a
+ * workshop that does pick it sees every line once, 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 { templatePresets } from '@/features/settings/Schema/templatePresets'
+import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+
+const itemized = templatePresets.find((p) => p.id === 'itemized')
+
+function visibility(config: { sections: { id: string; visible: boolean }[] }) {
+  return Object.fromEntries(config.sections.map((s) => [s.id, s.visible]))
+}
+
+const INVOICE_DATA = {
+  id: 'svc-itemized',
+  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,
+  },
+  vehicle: {
+    make: 'BMW',
+    model: 'M340d',
+    year: 2021,
+    vin: 'WBA51DZ050FL79472',
+    licensePlate: 'WST-X340',
+    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('itemized template preset', () => {
+  it('is not what a workshop gets by default', () => {
+    const seen = visibility(getDefaultInvoiceLayout())
+    expect(seen.items_table).toBe(false)
+    expect(seen.parts_table).toBe(true)
+    expect(seen.labor_table).toBe(true)
+  })
+
+  it('swaps the two tables for the combined one', () => {
+    expect(itemized?.layoutConfig).toBeDefined()
+    const seen = visibility(itemized!.layoutConfig!)
+    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
+    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,
+    }
+
+    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} />
+    )
+    expect(quote.byteLength).toBeGreaterThan(1000)
+  })
+})

+ 63 - 54
src/app/(authenticated)/settings/templates/page.tsx

@@ -1,20 +1,23 @@
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { TemplateSettings } from "./template-settings";
-import { getLayoutData } from "@/lib/get-layout-data";
-import { getFeatures, isCloudMode } from "@/lib/features";
-import { FeatureLockedMessage } from "../feature-locked-message";
-import { redirect } from "next/navigation";
-import { getTemplates } from "@/features/inspections/Actions/templateActions";
-import { getTranslations } from "next-intl/server";
-import { getInvoiceLayoutConfig } from "@/features/settings/Actions/invoiceLayoutActions";
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { TemplateSettings } from './template-settings'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getFeatures, isCloudMode } from '@/lib/features'
+import { FeatureLockedMessage } from '../feature-locked-message'
+import { redirect } from 'next/navigation'
+import { getTemplates } from '@/features/inspections/Actions/templateActions'
+import { getTranslations } from 'next-intl/server'
+import {
+  getInvoiceLayoutConfig,
+  getQuoteLayoutConfig,
+} from '@/features/settings/Actions/invoiceLayoutActions'
 export default async function TemplatePage() {
 export default async function TemplatePage() {
-  const data = await getLayoutData();
+  const data = await getLayoutData()
 
 
-  if (data.status === "unauthenticated") redirect("/auth/sign-in");
-  if (data.status === "no-organization") redirect("/onboarding");
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
 
 
-  const features = await getFeatures(data.organizationId);
+  const features = await getFeatures(data.organizationId)
 
 
   if (!features.customTemplates) {
   if (!features.customTemplates) {
     return (
     return (
@@ -23,68 +26,73 @@ export default async function TemplatePage() {
         description="Choose from pre-built templates and customize colors, fonts, and header layouts for your PDF invoices and quotes."
         description="Choose from pre-built templates and customize colors, fonts, and header layouts for your PDF invoices and quotes."
         isCloud={isCloudMode()}
         isCloud={isCloudMode()}
       />
       />
-    );
+    )
   }
   }
 
 
-  const [result, inspectionTemplatesResult, invoiceLayoutResult] = await Promise.all([
-    getSettings([
-      SETTING_KEYS.INVOICE_PRIMARY_COLOR,
-      SETTING_KEYS.INVOICE_FONT_FAMILY,
-      SETTING_KEYS.INVOICE_HEADER_STYLE,
-      SETTING_KEYS.INVOICE_LOGO_SIZE,
-      SETTING_KEYS.QUOTE_PRIMARY_COLOR,
-      SETTING_KEYS.QUOTE_FONT_FAMILY,
-      SETTING_KEYS.QUOTE_HEADER_STYLE,
-      SETTING_KEYS.QUOTE_LOGO_SIZE,
-      SETTING_KEYS.COMPANY_LOGO,
-      SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
-      SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
-      SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
-      SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
-      SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
-      SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
-      SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
-      SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
-    ]),
-    getTemplates(),
-    getInvoiceLayoutConfig(),
-  ]);
+  const [result, inspectionTemplatesResult, invoiceLayoutResult, quoteLayoutResult] =
+    await Promise.all([
+      getSettings([
+        SETTING_KEYS.INVOICE_PRIMARY_COLOR,
+        SETTING_KEYS.INVOICE_FONT_FAMILY,
+        SETTING_KEYS.INVOICE_HEADER_STYLE,
+        SETTING_KEYS.INVOICE_LOGO_SIZE,
+        SETTING_KEYS.QUOTE_PRIMARY_COLOR,
+        SETTING_KEYS.QUOTE_FONT_FAMILY,
+        SETTING_KEYS.QUOTE_HEADER_STYLE,
+        SETTING_KEYS.QUOTE_LOGO_SIZE,
+        SETTING_KEYS.COMPANY_LOGO,
+        SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+        SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
+        SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+        SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
+        SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED,
+      ]),
+      getTemplates(),
+      getInvoiceLayoutConfig(),
+      getQuoteLayoutConfig(),
+    ])
 
 
-  const settings = result.success && result.data ? result.data : {};
-  const inspectionTemplates = inspectionTemplatesResult.success && inspectionTemplatesResult.data
-    ? inspectionTemplatesResult.data
-    : [];
+  const settings = result.success && result.data ? result.data : {}
+  const inspectionTemplates =
+    inspectionTemplatesResult.success && inspectionTemplatesResult.data
+      ? inspectionTemplatesResult.data
+      : []
 
 
-  const t = await getTranslations('settings');
+  const t = await getTranslations('settings')
 
 
   const smsDefaultMap: Record<string, string> = {
   const smsDefaultMap: Record<string, string> = {
     [SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]: t.raw('templates.smsDefaults.invoiceReady'),
     [SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]: t.raw('templates.smsDefaults.invoiceReady'),
     [SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY]: t.raw('templates.smsDefaults.quoteReady'),
     [SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY]: t.raw('templates.smsDefaults.quoteReady'),
     [SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]: t.raw('templates.smsDefaults.inspectionReady'),
     [SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]: t.raw('templates.smsDefaults.inspectionReady'),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS]: t.raw('templates.smsDefaults.statusInProgress'),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS]: t.raw('templates.smsDefaults.statusInProgress'),
-    [SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS]: t.raw('templates.smsDefaults.statusWaitingParts'),
+    [SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS]: t.raw(
+      'templates.smsDefaults.statusWaitingParts'
+    ),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_READY]: t.raw('templates.smsDefaults.statusReady'),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_READY]: t.raw('templates.smsDefaults.statusReady'),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED]: t.raw('templates.smsDefaults.statusCompleted'),
     [SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED]: t.raw('templates.smsDefaults.statusCompleted'),
     [SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED]: t.raw('templates.smsDefaults.paymentReceived'),
     [SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED]: t.raw('templates.smsDefaults.paymentReceived'),
-  };
+  }
 
 
-  const smsTemplates: Record<string, string> = {};
+  const smsTemplates: Record<string, string> = {}
   for (const key of Object.keys(smsDefaultMap)) {
   for (const key of Object.keys(smsDefaultMap)) {
-    smsTemplates[key] = settings[key] || smsDefaultMap[key] || "";
+    smsTemplates[key] = settings[key] || smsDefaultMap[key] || ''
   }
   }
 
 
   return (
   return (
     <TemplateSettings
     <TemplateSettings
       initialInvoiceValues={{
       initialInvoiceValues={{
-        primaryColor: settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || "#d97706",
-        fontFamily: settings[SETTING_KEYS.INVOICE_FONT_FAMILY] || "Helvetica",
-        headerStyle: settings[SETTING_KEYS.INVOICE_HEADER_STYLE] || "standard",
+        primaryColor: settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || '#d97706',
+        fontFamily: settings[SETTING_KEYS.INVOICE_FONT_FAMILY] || 'Helvetica',
+        headerStyle: settings[SETTING_KEYS.INVOICE_HEADER_STYLE] || 'standard',
         logoSize: Number(settings[SETTING_KEYS.INVOICE_LOGO_SIZE]) || 100,
         logoSize: Number(settings[SETTING_KEYS.INVOICE_LOGO_SIZE]) || 100,
       }}
       }}
       initialQuoteValues={{
       initialQuoteValues={{
-        primaryColor: settings[SETTING_KEYS.QUOTE_PRIMARY_COLOR] || "#d97706",
-        fontFamily: settings[SETTING_KEYS.QUOTE_FONT_FAMILY] || "Helvetica",
-        headerStyle: settings[SETTING_KEYS.QUOTE_HEADER_STYLE] || "standard",
+        primaryColor: settings[SETTING_KEYS.QUOTE_PRIMARY_COLOR] || '#d97706',
+        fontFamily: settings[SETTING_KEYS.QUOTE_FONT_FAMILY] || 'Helvetica',
+        headerStyle: settings[SETTING_KEYS.QUOTE_HEADER_STYLE] || 'standard',
         logoSize: Number(settings[SETTING_KEYS.QUOTE_LOGO_SIZE]) || 100,
         logoSize: Number(settings[SETTING_KEYS.QUOTE_LOGO_SIZE]) || 100,
       }}
       }}
       inspectionTemplates={inspectionTemplates}
       inspectionTemplates={inspectionTemplates}
@@ -92,6 +100,7 @@ export default async function TemplatePage() {
       initialSmsTemplates={smsTemplates}
       initialSmsTemplates={smsTemplates}
       logoUrl={settings[SETTING_KEYS.COMPANY_LOGO] || undefined}
       logoUrl={settings[SETTING_KEYS.COMPANY_LOGO] || undefined}
       invoiceLayoutConfig={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
       invoiceLayoutConfig={invoiceLayoutResult.success ? invoiceLayoutResult.data : undefined}
+      quoteLayoutConfig={quoteLayoutResult.success ? quoteLayoutResult.data : undefined}
     />
     />
-  );
+  )
 }
 }

Різницю між файлами не показано, бо вона завелика
+ 432 - 396
src/app/(authenticated)/settings/templates/template-settings.tsx


+ 15 - 1
src/app/(public)/app-setup/app-setup-landing.tsx

@@ -3,8 +3,9 @@
 import { useEffect, useState } from 'react'
 import { useEffect, useState } from 'react'
 import Image from 'next/image'
 import Image from 'next/image'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
-import { Smartphone } from 'lucide-react'
+import { Download, Smartphone } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
+import { PLAY_STORE_URL } from '@/lib/app-links'
 
 
 /**
 /**
  * Reads the code out of the fragment and tells the technician what to do.
  * Reads the code out of the fragment and tells the technician what to do.
@@ -66,6 +67,19 @@ export function AppSetupLanding() {
           </Button>
           </Button>
         )}
         )}
 
 
+        {/* For a phone that does not have it yet. The button above opens the
+            app and does nothing visible when it is missing, which is exactly
+            the moment somebody needs this. */}
+        <a
+          href={PLAY_STORE_URL}
+          target="_blank"
+          rel="noreferrer"
+          className="mt-3 inline-flex items-center gap-2 text-muted-foreground text-sm underline underline-offset-4 hover:text-foreground"
+        >
+          <Download className="h-4 w-4" />
+          {t('team.setupPageInstall')}
+        </a>
+
         <p className="pt-6 text-muted-foreground text-xs">{t('team.setupPageExpiry')}</p>
         <p className="pt-6 text-muted-foreground text-xs">{t('team.setupPageExpiry')}</p>
       </div>
       </div>
     </div>
     </div>

+ 48 - 4
src/features/quotes/Components/QuotePDF.tsx

@@ -5,6 +5,10 @@ import { formatQuantity } from '@/lib/format-quantity'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
 import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
 import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
 import { CustomFields } from '@/features/vehicles/Components/invoice-pdf/CustomFields'
 import { CustomFields } from '@/features/vehicles/Components/invoice-pdf/CustomFields'
+import {
+  ItemsTable,
+  type CombinedItem,
+} from '@/features/vehicles/Components/invoice-pdf/ItemsTable'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
 import type {
 import type {
   InvoiceLayoutConfig,
   InvoiceLayoutConfig,
@@ -151,6 +155,7 @@ export function QuotePDF({
   // Layout config helpers
   // Layout config helpers
   const sections = layoutConfig?.sections ?? []
   const sections = layoutConfig?.sections ?? []
   const sectionMap = new Map<string, InvoiceSection>(sections.map((s) => [s.id, s]))
   const sectionMap = new Map<string, InvoiceSection>(sections.map((s) => [s.id, s]))
+  const effectiveSections = layoutConfig?.sections ?? getDefaultInvoiceLayout().sections
 
 
   const getVisibleFieldsForSection = (sectionId: string): Set<string> | null => {
   const getVisibleFieldsForSection = (sectionId: string): Set<string> | null => {
     const section = sectionMap.get(sectionId)
     const section = sectionMap.get(sectionId)
@@ -671,6 +676,45 @@ export function QuotePDF({
   const taxRate = data.taxRate
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false
   const taxInclusive = data.taxInclusive ?? false
 
 
+  // Splitting the sum back into parts and labor would be a breakdown of a list
+  // the reader never saw split, so the combined table suppresses it.
+  const itemsTableVisible = effectiveSections.some((s) => s.id === 'items_table' && s.visible)
+
+  const renderItemsTable = () => {
+    // Labor first, then parts: a job reads as the work that was done followed
+    // by what it took, and that is the order the numbered positions run in.
+    const items: CombinedItem[] = [
+      ...data.laborItems.map((l) => ({
+        quantity: l.hours,
+        unit: l.pricingType === 'service' ? labels.unit || 'unit' : labels.hrs || 'hrs',
+        description: l.description,
+        unitPrice: l.rate,
+        total: l.total,
+        excluded: l.excluded,
+      })),
+      ...data.partItems.map((p) => ({
+        quantity: p.quantity,
+        unit: p.unit,
+        description: p.name,
+        reference: p.partNumber,
+        unitPrice: p.unitPrice,
+        total: p.total,
+        excluded: p.excluded,
+      })),
+    ]
+    return (
+      <ItemsTable
+        items={items}
+        currencyCode={currencyCode}
+        currencyFormat={currencyFormat}
+        taxRate={taxRate}
+        taxInclusive={taxInclusive}
+        styles={styles}
+        labels={labels}
+      />
+    )
+  }
+
   const renderPartsTable = () => {
   const renderPartsTable = () => {
     if (data.partItems.length === 0) return null
     if (data.partItems.length === 0) return null
     return (
     return (
@@ -871,7 +915,7 @@ export function QuotePDF({
 
 
     return (
     return (
       <View style={styles.totalsBox}>
       <View style={styles.totalsBox}>
-        {data.laborItems.length > 0 && (
+        {!itemsTableVisible && data.laborItems.length > 0 && (
           <View style={styles.totalRow}>
           <View style={styles.totalRow}>
             <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
             <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
             <Text style={styles.totalValue}>
             <Text style={styles.totalValue}>
@@ -879,7 +923,7 @@ export function QuotePDF({
             </Text>
             </Text>
           </View>
           </View>
         )}
         )}
-        {data.partItems.length > 0 && (
+        {!itemsTableVisible && data.partItems.length > 0 && (
           <View style={styles.totalRow}>
           <View style={styles.totalRow}>
             <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
             <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
             <Text style={styles.totalValue}>
             <Text style={styles.totalValue}>
@@ -1033,6 +1077,8 @@ export function QuotePDF({
         return renderVehicleSection()
         return renderVehicleSection()
       case 'service':
       case 'service':
         return renderServiceSection()
         return renderServiceSection()
+      case 'items_table':
+        return renderItemsTable()
       case 'parts_table':
       case 'parts_table':
         return renderPartsTable()
         return renderPartsTable()
       case 'labor_table':
       case 'labor_table':
@@ -1069,8 +1115,6 @@ export function QuotePDF({
     }
     }
   }
   }
 
 
-  // Use column-based grouping from layout config
-  const effectiveSections = layoutConfig?.sections ?? getDefaultInvoiceLayout().sections
   const renderGroups = groupSectionsForRendering(effectiveSections)
   const renderGroups = groupSectionsForRendering(effectiveSections)
   const renderedSections: React.ReactNode[] = []
   const renderedSections: React.ReactNode[] = []
 
 

+ 230 - 216
src/features/settings/Schema/invoiceLayoutSchema.ts

@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Zod schemas
 // Zod schemas
@@ -7,46 +7,46 @@ import { z } from "zod";
 export const invoiceFieldConfigSchema = z.object({
 export const invoiceFieldConfigSchema = z.object({
   id: z.string(),
   id: z.string(),
   visible: z.boolean(),
   visible: z.boolean(),
-});
+})
 
 
 export const invoiceSectionSchema = z.object({
 export const invoiceSectionSchema = z.object({
   id: z.string(),
   id: z.string(),
   visible: z.boolean(),
   visible: z.boolean(),
   order: z.number().int(),
   order: z.number().int(),
   /** When set, the section renders in a 2-column row alongside other column sections. */
   /** When set, the section renders in a 2-column row alongside other column sections. */
-  column: z.enum(["left", "right"]).optional(),
+  column: z.enum(['left', 'right']).optional(),
   /** Controls which fields are shown within this section. */
   /** Controls which fields are shown within this section. */
   fields: z.array(invoiceFieldConfigSchema).optional(),
   fields: z.array(invoiceFieldConfigSchema).optional(),
-});
+})
 
 
 export const invoiceLayoutConfigSchema = z.object({
 export const invoiceLayoutConfigSchema = z.object({
   sections: z.array(invoiceSectionSchema),
   sections: z.array(invoiceSectionSchema),
-});
+})
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // TypeScript types (derived from Zod)
 // TypeScript types (derived from Zod)
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-export type InvoiceFieldConfig = z.infer<typeof invoiceFieldConfigSchema>;
-export type InvoiceSection = z.infer<typeof invoiceSectionSchema>;
-export type InvoiceLayoutConfig = z.infer<typeof invoiceLayoutConfigSchema>;
+export type InvoiceFieldConfig = z.infer<typeof invoiceFieldConfigSchema>
+export type InvoiceSection = z.infer<typeof invoiceSectionSchema>
+export type InvoiceLayoutConfig = z.infer<typeof invoiceLayoutConfigSchema>
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Custom field ID helpers
 // Custom field ID helpers
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-export const CUSTOM_FIELD_PREFIX = "cf_";
+export const CUSTOM_FIELD_PREFIX = 'cf_'
 
 
 export function isCustomFieldId(id: string): boolean {
 export function isCustomFieldId(id: string): boolean {
-  return id.startsWith(CUSTOM_FIELD_PREFIX);
+  return id.startsWith(CUSTOM_FIELD_PREFIX)
 }
 }
 
 
 export function toCustomFieldId(definitionId: string): string {
 export function toCustomFieldId(definitionId: string): string {
-  return `${CUSTOM_FIELD_PREFIX}${definitionId}`;
+  return `${CUSTOM_FIELD_PREFIX}${definitionId}`
 }
 }
 
 
 export function fromCustomFieldId(cfId: string): string {
 export function fromCustomFieldId(cfId: string): string {
-  return cfId.slice(CUSTOM_FIELD_PREFIX.length);
+  return cfId.slice(CUSTOM_FIELD_PREFIX.length)
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -54,109 +54,111 @@ export function fromCustomFieldId(cfId: string): string {
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 export const BUILTIN_SECTIONS = [
 export const BUILTIN_SECTIONS = [
-  { id: "header", name: "Header" },
-  { id: "customer", name: "Customer" },
-  { id: "vehicle", name: "Vehicle" },
-  { id: "service", name: "Service" },
-  { id: "parts_table", name: "Parts Table" },
-  { id: "labor_table", name: "Labor Table" },
-  { id: "findings", name: "Findings" },
-  { id: "totals", name: "Totals" },
-  { id: "notes", name: "Notes" },
-  { id: "warranty", name: "Warranty" },
-  { id: "bank_account", name: "Bank Account" },
-  { id: "footer", name: "Footer" },
-  { id: "telegram_qr", name: "Telegram QR" },
-  { id: "general", name: "General" },
-] as const;
+  { id: 'header', name: 'Header' },
+  { id: 'customer', name: 'Customer' },
+  { id: 'vehicle', name: 'Vehicle' },
+  { id: 'service', name: 'Service' },
+  { id: 'items_table', name: 'Items Table' },
+  { id: 'parts_table', name: 'Parts Table' },
+  { id: 'labor_table', name: 'Labor Table' },
+  { id: 'findings', name: 'Findings' },
+  { id: 'totals', name: 'Totals' },
+  { id: 'notes', name: 'Notes' },
+  { id: 'warranty', name: 'Warranty' },
+  { id: 'bank_account', name: 'Bank Account' },
+  { id: 'footer', name: 'Footer' },
+  { id: 'telegram_qr', name: 'Telegram QR' },
+  { id: 'general', name: 'General' },
+] as const
 
 
 export const BUILTIN_CUSTOMER_FIELDS = [
 export const BUILTIN_CUSTOMER_FIELDS = [
-  { id: "customer_name", name: "Customer Name" },
-  { id: "customer_company", name: "Customer Company" },
-  { id: "customer_address", name: "Customer Address" },
-  { id: "customer_email", name: "Customer Email" },
-  { id: "customer_phone", name: "Customer Phone" },
-  { id: "customer_tax_id", name: "Customer Tax ID" },
-] as const;
+  { id: 'customer_name', name: 'Customer Name' },
+  { id: 'customer_company', name: 'Customer Company' },
+  { id: 'customer_address', name: 'Customer Address' },
+  { id: 'customer_email', name: 'Customer Email' },
+  { id: 'customer_phone', name: 'Customer Phone' },
+  { id: 'customer_tax_id', name: 'Customer Tax ID' },
+] as const
 
 
 export const BUILTIN_VEHICLE_FIELDS = [
 export const BUILTIN_VEHICLE_FIELDS = [
-  { id: "vehicle_name", name: "Vehicle" },
-  { id: "vin", name: "VIN" },
-  { id: "license_plate", name: "License Plate" },
-  { id: "mileage", name: "Mileage" },
-] as const;
+  { id: 'vehicle_name', name: 'Vehicle' },
+  { id: 'vin', name: 'VIN' },
+  { id: 'license_plate', name: 'License Plate' },
+  { id: 'mileage', name: 'Mileage' },
+] as const
 
 
 export const BUILTIN_SERVICE_FIELDS = [
 export const BUILTIN_SERVICE_FIELDS = [
-  { id: "service_title", name: "Service Title" },
-  { id: "service_type", name: "Service Type" },
-  { id: "tech_name", name: "Technician" },
-] as const;
+  { id: 'service_title', name: 'Service Title' },
+  { id: 'service_type', name: 'Service Type' },
+  { id: 'tech_name', name: 'Technician' },
+] as const
 
 
 /** @deprecated Use BUILTIN_CUSTOMER_FIELDS, BUILTIN_VEHICLE_FIELDS, BUILTIN_SERVICE_FIELDS */
 /** @deprecated Use BUILTIN_CUSTOMER_FIELDS, BUILTIN_VEHICLE_FIELDS, BUILTIN_SERVICE_FIELDS */
 export const BUILTIN_INFO_FIELDS = [
 export const BUILTIN_INFO_FIELDS = [
   ...BUILTIN_CUSTOMER_FIELDS,
   ...BUILTIN_CUSTOMER_FIELDS,
   ...BUILTIN_VEHICLE_FIELDS,
   ...BUILTIN_VEHICLE_FIELDS,
   ...BUILTIN_SERVICE_FIELDS,
   ...BUILTIN_SERVICE_FIELDS,
-] as const;
+] as const
 
 
 export const BUILTIN_HEADER_FIELDS = [
 export const BUILTIN_HEADER_FIELDS = [
-  { id: "logo", name: "Logo" },
-  { id: "company_name", name: "Company Name" },
-  { id: "company_address", name: "Address" },
-  { id: "company_phone", name: "Phone" },
-  { id: "company_email", name: "Email" },
-  { id: "company_org_number", name: "Organization Number" },
-] as const;
+  { id: 'logo', name: 'Logo' },
+  { id: 'company_name', name: 'Company Name' },
+  { id: 'company_address', name: 'Address' },
+  { id: 'company_phone', name: 'Phone' },
+  { id: 'company_email', name: 'Email' },
+  { id: 'company_org_number', name: 'Organization Number' },
+] as const
 
 
 export const BUILTIN_BANK_ACCOUNT_FIELDS = [
 export const BUILTIN_BANK_ACCOUNT_FIELDS = [
-  { id: "bank_account", name: "Bank Account" },
-  { id: "org_number", name: "Organization Number" },
-] as const;
-
-export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]["id"];
-export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]["id"];
-export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]["id"];
-export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]["id"];
-export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]["id"];
-export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]["id"];
-export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]["id"];
+  { id: 'bank_account', name: 'Bank Account' },
+  { id: 'org_number', name: 'Organization Number' },
+] as const
+
+export type BuiltinSectionId = (typeof BUILTIN_SECTIONS)[number]['id']
+export type BuiltinInfoFieldId = (typeof BUILTIN_INFO_FIELDS)[number]['id']
+export type BuiltinCustomerFieldId = (typeof BUILTIN_CUSTOMER_FIELDS)[number]['id']
+export type BuiltinVehicleFieldId = (typeof BUILTIN_VEHICLE_FIELDS)[number]['id']
+export type BuiltinServiceFieldId = (typeof BUILTIN_SERVICE_FIELDS)[number]['id']
+export type BuiltinHeaderFieldId = (typeof BUILTIN_HEADER_FIELDS)[number]['id']
+export type BuiltinBankAccountFieldId = (typeof BUILTIN_BANK_ACCOUNT_FIELDS)[number]['id']
 
 
 /** Sections that have configurable fields */
 /** Sections that have configurable fields */
 export const SECTIONS_WITH_FIELDS = new Set<string>([
 export const SECTIONS_WITH_FIELDS = new Set<string>([
-  "header",
-  "customer",
-  "vehicle",
-  "service",
-  "bank_account",
-  "general",
-]);
+  'header',
+  'customer',
+  'vehicle',
+  'service',
+  'bank_account',
+  'general',
+])
 
 
 /** Sections that can be placed in left/right columns */
 /** Sections that can be placed in left/right columns */
 export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
 export const COLUMN_ELIGIBLE_SECTIONS = new Set<string>([
-  "customer",
-  "vehicle",
-  "service",
-  "general",
-  "notes",
-  "bank_account",
-]);
+  'customer',
+  'vehicle',
+  'service',
+  'general',
+  'notes',
+  'bank_account',
+])
 
 
 /** Sections that MUST be full-width (cannot be in columns) */
 /** Sections that MUST be full-width (cannot be in columns) */
 export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
 export const FULL_WIDTH_ONLY_SECTIONS = new Set<string>([
-  "header",
-  "parts_table",
-  "labor_table",
-  "totals",
-  "footer",
-  "telegram_qr",
-]);
+  'header',
+  'items_table',
+  'parts_table',
+  'labor_table',
+  'totals',
+  'footer',
+  'telegram_qr',
+])
 
 
 /** Default column assignment for column-eligible sections */
 /** Default column assignment for column-eligible sections */
-const DEFAULT_COLUMN: Record<string, "left" | "right"> = {
-  customer: "left",
-  vehicle: "left",
-  service: "right",
-};
+const DEFAULT_COLUMN: Record<string, 'left' | 'right'> = {
+  customer: 'left',
+  vehicle: 'left',
+  service: 'right',
+}
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Defaults
 // Defaults
@@ -164,37 +166,44 @@ const DEFAULT_COLUMN: Record<string, "left" | "right"> = {
 
 
 function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | undefined {
 function getDefaultFieldsForSection(sectionId: string): InvoiceFieldConfig[] | undefined {
   switch (sectionId) {
   switch (sectionId) {
-    case "customer":
-      return BUILTIN_CUSTOMER_FIELDS.map((f) => ({ id: f.id, visible: true }));
-    case "vehicle":
-      return BUILTIN_VEHICLE_FIELDS.map((f) => ({ id: f.id, visible: true }));
-    case "service":
-      return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }));
-    case "header":
-      return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }));
-    case "bank_account":
-      return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }));
-    case "general":
-      return []; // no built-in fields, only custom fields
+    case 'customer':
+      return BUILTIN_CUSTOMER_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'vehicle':
+      return BUILTIN_VEHICLE_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'service':
+      return BUILTIN_SERVICE_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'header':
+      return BUILTIN_HEADER_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'bank_account':
+      return BUILTIN_BANK_ACCOUNT_FIELDS.map((f) => ({ id: f.id, visible: true }))
+    case 'general':
+      return [] // no built-in fields, only custom fields
     default:
     default:
-      return undefined;
+      return undefined
   }
   }
 }
 }
 
 
+/**
+ * Sections a workshop has to switch on before they appear. `items_table` is one
+ * of them because it is an alternative to the separate parts and labor tables,
+ * not an addition to them: showing it by default would print every line twice.
+ */
+const HIDDEN_BY_DEFAULT_SECTIONS = new Set<string>(['general', 'telegram_qr', 'items_table'])
+
 export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
 export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
   return {
   return {
     sections: BUILTIN_SECTIONS.map((s, index) => {
     sections: BUILTIN_SECTIONS.map((s, index) => {
-      const fields = getDefaultFieldsForSection(s.id);
-      const column = DEFAULT_COLUMN[s.id];
+      const fields = getDefaultFieldsForSection(s.id)
+      const column = DEFAULT_COLUMN[s.id]
       return {
       return {
         id: s.id,
         id: s.id,
-        visible: s.id !== "general" && s.id !== "telegram_qr", // general and telegram_qr hidden by default
+        visible: !HIDDEN_BY_DEFAULT_SECTIONS.has(s.id),
         order: index,
         order: index,
         ...(column ? { column } : {}),
         ...(column ? { column } : {}),
         ...(fields ? { fields } : {}),
         ...(fields ? { fields } : {}),
-      };
+      }
     }),
     }),
-  };
+  }
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -203,15 +212,21 @@ export function getDefaultInvoiceLayout(): InvoiceLayoutConfig {
 
 
 /** Get all built-in field definitions for a section */
 /** Get all built-in field definitions for a section */
 export function getBuiltinFieldsForSection(
 export function getBuiltinFieldsForSection(
-  sectionId: string,
+  sectionId: string
 ): ReadonlyArray<{ id: string; name: string }> {
 ): ReadonlyArray<{ id: string; name: string }> {
   switch (sectionId) {
   switch (sectionId) {
-    case "customer": return BUILTIN_CUSTOMER_FIELDS;
-    case "vehicle": return BUILTIN_VEHICLE_FIELDS;
-    case "service": return BUILTIN_SERVICE_FIELDS;
-    case "header": return BUILTIN_HEADER_FIELDS;
-    case "bank_account": return BUILTIN_BANK_ACCOUNT_FIELDS;
-    default: return [];
+    case 'customer':
+      return BUILTIN_CUSTOMER_FIELDS
+    case 'vehicle':
+      return BUILTIN_VEHICLE_FIELDS
+    case 'service':
+      return BUILTIN_SERVICE_FIELDS
+    case 'header':
+      return BUILTIN_HEADER_FIELDS
+    case 'bank_account':
+      return BUILTIN_BANK_ACCOUNT_FIELDS
+    default:
+      return []
   }
   }
 }
 }
 
 
@@ -223,83 +238,84 @@ export function getBuiltinFieldName(fieldId: string): string | undefined {
     ...BUILTIN_SERVICE_FIELDS,
     ...BUILTIN_SERVICE_FIELDS,
     ...BUILTIN_HEADER_FIELDS,
     ...BUILTIN_HEADER_FIELDS,
     ...BUILTIN_BANK_ACCOUNT_FIELDS,
     ...BUILTIN_BANK_ACCOUNT_FIELDS,
-  ];
-  return allFields.find((f) => f.id === fieldId)?.name;
+  ]
+  return allFields.find((f) => f.id === fieldId)?.name
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Merge helper – fills in missing sections/fields with defaults
 // Merge helper – fills in missing sections/fields with defaults
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-export function mergeWithDefaults(
-  saved: Partial<InvoiceLayoutConfig>,
-): InvoiceLayoutConfig {
-  const defaults = getDefaultInvoiceLayout();
+export function mergeWithDefaults(saved: Partial<InvoiceLayoutConfig>): InvoiceLayoutConfig {
+  const defaults = getDefaultInvoiceLayout()
 
 
   if (!saved.sections || saved.sections.length === 0) {
   if (!saved.sections || saved.sections.length === 0) {
-    return defaults;
+    return defaults
   }
   }
 
 
   // Migrate old format: split "info" into customer/vehicle/service
   // Migrate old format: split "info" into customer/vehicle/service
-  const migrated = migrateFromLegacy(saved.sections);
+  const migrated = migrateFromLegacy(saved.sections)
 
 
-  const merged: InvoiceSection[] = [];
-  const seen = new Set<string>();
+  const merged: InvoiceSection[] = []
+  const seen = new Set<string>()
 
 
   for (const section of migrated) {
   for (const section of migrated) {
     // Skip duplicate section IDs (keep first occurrence)
     // Skip duplicate section IDs (keep first occurrence)
-    if (seen.has(section.id)) continue;
-    seen.add(section.id);
+    if (seen.has(section.id)) continue
+    seen.add(section.id)
 
 
-    const defaultFields = getDefaultFieldsForSection(section.id);
+    const defaultFields = getDefaultFieldsForSection(section.id)
     if (defaultFields) {
     if (defaultFields) {
       merged.push({
       merged.push({
         ...section,
         ...section,
         fields: mergeSectionFields(section.fields, defaultFields),
         fields: mergeSectionFields(section.fields, defaultFields),
-      });
+      })
     } else {
     } else {
-      merged.push(section);
+      merged.push(section)
     }
     }
   }
   }
 
 
   // Append any new built-in sections that are missing from saved.
   // Append any new built-in sections that are missing from saved.
   // Insert each after its natural predecessor from the default order,
   // Insert each after its natural predecessor from the default order,
   // so e.g. "findings" lands after "labor_table" instead of at the end.
   // so e.g. "findings" lands after "labor_table" instead of at the end.
-  const defaultOrder = defaults.sections.map((s) => s.id);
-  const toInsert: { section: InvoiceSection; afterIdx: number }[] = [];
+  const defaultOrder = defaults.sections.map((s) => s.id)
+  const toInsert: { section: InvoiceSection; afterIdx: number }[] = []
   for (const def of defaults.sections) {
   for (const def of defaults.sections) {
-    if (seen.has(def.id)) continue;
-    const defaultIdx = defaultOrder.indexOf(def.id);
-    let insertAfterIdx = -1;
+    if (seen.has(def.id)) continue
+    const defaultIdx = defaultOrder.indexOf(def.id)
+    let insertAfterIdx = -1
     for (let i = defaultIdx - 1; i >= 0; i--) {
     for (let i = defaultIdx - 1; i >= 0; i--) {
-      const idx = merged.findIndex((s) => s.id === defaultOrder[i]);
-      if (idx !== -1) { insertAfterIdx = idx; break; }
+      const idx = merged.findIndex((s) => s.id === defaultOrder[i])
+      if (idx !== -1) {
+        insertAfterIdx = idx
+        break
+      }
     }
     }
-    toInsert.push({ section: def, afterIdx: insertAfterIdx });
+    toInsert.push({ section: def, afterIdx: insertAfterIdx })
   }
   }
   if (toInsert.length > 0) {
   if (toInsert.length > 0) {
     // Insert in reverse so indices stay stable
     // Insert in reverse so indices stay stable
-    toInsert.sort((a, b) => b.afterIdx - a.afterIdx);
+    toInsert.sort((a, b) => b.afterIdx - a.afterIdx)
     for (const { section, afterIdx } of toInsert) {
     for (const { section, afterIdx } of toInsert) {
-      merged.splice(afterIdx + 1, 0, { ...section, order: 0 });
+      merged.splice(afterIdx + 1, 0, { ...section, order: 0 })
     }
     }
     // Renumber all orders as clean integers
     // Renumber all orders as clean integers
     for (let i = 0; i < merged.length; i++) {
     for (let i = 0; i < merged.length; i++) {
-      merged[i] = { ...merged[i], order: i };
+      merged[i] = { ...merged[i], order: i }
     }
     }
   }
   }
 
 
   // Auto-assign column values to column-eligible sections if none have columns
   // Auto-assign column values to column-eligible sections if none have columns
-  const hasAnyColumn = merged.some((s) => s.column);
+  const hasAnyColumn = merged.some((s) => s.column)
   if (!hasAnyColumn) {
   if (!hasAnyColumn) {
     for (const section of merged) {
     for (const section of merged) {
       if (DEFAULT_COLUMN[section.id]) {
       if (DEFAULT_COLUMN[section.id]) {
-        section.column = DEFAULT_COLUMN[section.id];
+        section.column = DEFAULT_COLUMN[section.id]
       }
       }
     }
     }
   }
   }
 
 
-  return { sections: merged };
+  return { sections: merged }
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -307,91 +323,91 @@ export function mergeWithDefaults(
 //                    "custom_fields" → "general"
 //                    "custom_fields" → "general"
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-const CUSTOMER_FIELD_IDS: Set<string> = new Set(BUILTIN_CUSTOMER_FIELDS.map((f) => f.id));
-const VEHICLE_FIELD_IDS: Set<string> = new Set(BUILTIN_VEHICLE_FIELDS.map((f) => f.id));
-const SERVICE_FIELD_IDS: Set<string> = new Set(BUILTIN_SERVICE_FIELDS.map((f) => f.id));
+const CUSTOMER_FIELD_IDS: Set<string> = new Set(BUILTIN_CUSTOMER_FIELDS.map((f) => f.id))
+const VEHICLE_FIELD_IDS: Set<string> = new Set(BUILTIN_VEHICLE_FIELDS.map((f) => f.id))
+const SERVICE_FIELD_IDS: Set<string> = new Set(BUILTIN_SERVICE_FIELDS.map((f) => f.id))
 
 
 function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
 function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
-  const hasInfo = sections.some((s) => s.id === "info");
-  const hasCustomFields = sections.some((s) => s.id === "custom_fields");
+  const hasInfo = sections.some((s) => s.id === 'info')
+  const hasCustomFields = sections.some((s) => s.id === 'custom_fields')
 
 
   // Already in new format
   // Already in new format
   if (!hasInfo && !hasCustomFields) {
   if (!hasInfo && !hasCustomFields) {
-    return sections;
+    return sections
   }
   }
 
 
-  const result: InvoiceSection[] = [];
+  const result: InvoiceSection[] = []
 
 
   for (const section of sections) {
   for (const section of sections) {
-    if (section.id === "info") {
+    if (section.id === 'info') {
       // Split into customer, vehicle, service
       // Split into customer, vehicle, service
-      const customerFields: InvoiceFieldConfig[] = [];
-      const vehicleFields: InvoiceFieldConfig[] = [];
-      const serviceFields: InvoiceFieldConfig[] = [];
-      const customFieldRefs: InvoiceFieldConfig[] = [];
+      const customerFields: InvoiceFieldConfig[] = []
+      const vehicleFields: InvoiceFieldConfig[] = []
+      const serviceFields: InvoiceFieldConfig[] = []
+      const customFieldRefs: InvoiceFieldConfig[] = []
 
 
       if (section.fields) {
       if (section.fields) {
         for (const field of section.fields) {
         for (const field of section.fields) {
           if (CUSTOMER_FIELD_IDS.has(field.id)) {
           if (CUSTOMER_FIELD_IDS.has(field.id)) {
-            customerFields.push(field);
+            customerFields.push(field)
           } else if (VEHICLE_FIELD_IDS.has(field.id)) {
           } else if (VEHICLE_FIELD_IDS.has(field.id)) {
-            vehicleFields.push(field);
+            vehicleFields.push(field)
           } else if (SERVICE_FIELD_IDS.has(field.id)) {
           } else if (SERVICE_FIELD_IDS.has(field.id)) {
-            serviceFields.push(field);
+            serviceFields.push(field)
           } else if (isCustomFieldId(field.id)) {
           } else if (isCustomFieldId(field.id)) {
-            customFieldRefs.push(field);
+            customFieldRefs.push(field)
           }
           }
         }
         }
       }
       }
 
 
       // Use the info section's order as base, insert three sections
       // Use the info section's order as base, insert three sections
-      const baseOrder = section.order;
+      const baseOrder = section.order
       result.push({
       result.push({
-        id: "customer",
+        id: 'customer',
         visible: section.visible,
         visible: section.visible,
         order: baseOrder,
         order: baseOrder,
         fields: customerFields.length > 0 ? customerFields : undefined,
         fields: customerFields.length > 0 ? customerFields : undefined,
-      });
+      })
       result.push({
       result.push({
-        id: "vehicle",
+        id: 'vehicle',
         visible: section.visible,
         visible: section.visible,
         order: baseOrder + 0.1,
         order: baseOrder + 0.1,
         fields: vehicleFields.length > 0 ? vehicleFields : undefined,
         fields: vehicleFields.length > 0 ? vehicleFields : undefined,
-      });
+      })
       result.push({
       result.push({
-        id: "service",
+        id: 'service',
         visible: section.visible,
         visible: section.visible,
         order: baseOrder + 0.2,
         order: baseOrder + 0.2,
         fields: serviceFields.length > 0 ? serviceFields : undefined,
         fields: serviceFields.length > 0 ? serviceFields : undefined,
-      });
+      })
 
 
       // If the old info section had custom fields, add them to general
       // If the old info section had custom fields, add them to general
       if (customFieldRefs.length > 0) {
       if (customFieldRefs.length > 0) {
         result.push({
         result.push({
-          id: "general",
+          id: 'general',
           visible: section.visible,
           visible: section.visible,
           order: baseOrder + 0.3,
           order: baseOrder + 0.3,
           fields: customFieldRefs,
           fields: customFieldRefs,
-        });
+        })
       }
       }
-    } else if (section.id === "custom_fields") {
+    } else if (section.id === 'custom_fields') {
       // Rename to general, keep any cf_ field references
       // Rename to general, keep any cf_ field references
       result.push({
       result.push({
         ...section,
         ...section,
-        id: "general",
-      });
+        id: 'general',
+      })
     } else {
     } else {
-      result.push(section);
+      result.push(section)
     }
     }
   }
   }
 
 
   // Normalize order values to integers
   // Normalize order values to integers
-  result.sort((a, b) => a.order - b.order);
+  result.sort((a, b) => a.order - b.order)
   result.forEach((s, i) => {
   result.forEach((s, i) => {
-    s.order = i;
-  });
+    s.order = i
+  })
 
 
-  return result;
+  return result
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -403,52 +419,50 @@ function migrateFromLegacy(sections: InvoiceSection[]): InvoiceSection[] {
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 export type RenderGroup =
 export type RenderGroup =
-  | { type: "full-width"; sectionId: string }
-  | { type: "columns"; left: string[]; right: string[] };
+  | { type: 'full-width'; sectionId: string }
+  | { type: 'columns'; left: string[]; right: string[] }
 
 
 /**
 /**
  * Groups sorted visible sections for rendering.
  * Groups sorted visible sections for rendering.
  * Consecutive column-assigned sections are grouped into a single 2-column row.
  * Consecutive column-assigned sections are grouped into a single 2-column row.
  * Sections without a column render as full-width.
  * Sections without a column render as full-width.
  */
  */
-export function groupSectionsForRendering(
-  sections: InvoiceSection[],
-): RenderGroup[] {
+export function groupSectionsForRendering(sections: InvoiceSection[]): RenderGroup[] {
   // Deduplicate by section ID (keep first occurrence by order)
   // Deduplicate by section ID (keep first occurrence by order)
-  const seen = new Set<string>();
+  const seen = new Set<string>()
   const sorted = [...sections]
   const sorted = [...sections]
     .filter((s) => s.visible)
     .filter((s) => s.visible)
     .sort((a, b) => a.order - b.order)
     .sort((a, b) => a.order - b.order)
     .filter((s) => {
     .filter((s) => {
-      if (seen.has(s.id)) return false;
-      seen.add(s.id);
-      return true;
-    });
+      if (seen.has(s.id)) return false
+      seen.add(s.id)
+      return true
+    })
 
 
-  const groups: RenderGroup[] = [];
-  let pendingLeft: string[] = [];
-  let pendingRight: string[] = [];
+  const groups: RenderGroup[] = []
+  let pendingLeft: string[] = []
+  let pendingRight: string[] = []
 
 
   const flushColumns = () => {
   const flushColumns = () => {
     if (pendingLeft.length > 0 || pendingRight.length > 0) {
     if (pendingLeft.length > 0 || pendingRight.length > 0) {
-      groups.push({ type: "columns", left: [...pendingLeft], right: [...pendingRight] });
-      pendingLeft = [];
-      pendingRight = [];
+      groups.push({ type: 'columns', left: [...pendingLeft], right: [...pendingRight] })
+      pendingLeft = []
+      pendingRight = []
     }
     }
-  };
+  }
 
 
   for (const section of sorted) {
   for (const section of sorted) {
-    if (section.column === "left" || section.column === "right") {
-      if (section.column === "left") pendingLeft.push(section.id);
-      else pendingRight.push(section.id);
+    if (section.column === 'left' || section.column === 'right') {
+      if (section.column === 'left') pendingLeft.push(section.id)
+      else pendingRight.push(section.id)
     } else {
     } else {
-      flushColumns();
-      groups.push({ type: "full-width", sectionId: section.id });
+      flushColumns()
+      groups.push({ type: 'full-width', sectionId: section.id })
     }
     }
   }
   }
-  flushColumns();
+  flushColumns()
 
 
-  return groups;
+  return groups
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -462,12 +476,12 @@ export function groupSectionsForRendering(
  */
  */
 export function getOrderedFieldIds(
 export function getOrderedFieldIds(
   visibleFields: Set<string> | null | undefined,
   visibleFields: Set<string> | null | undefined,
-  defaults: string[],
+  defaults: string[]
 ): string[] {
 ): string[] {
-  if (!visibleFields) return defaults;
+  if (!visibleFields) return defaults
   // Set iteration order = insertion order = layout config order
   // Set iteration order = insertion order = layout config order
-  const ordered = [...visibleFields].filter((id) => !isCustomFieldId(id));
-  return ordered.length > 0 ? ordered : defaults;
+  const ordered = [...visibleFields].filter((id) => !isCustomFieldId(id))
+  return ordered.length > 0 ? ordered : defaults
 }
 }
 
 
 /**
 /**
@@ -476,12 +490,12 @@ export function getOrderedFieldIds(
  */
  */
 export function getVisibleFieldsForSection(
 export function getVisibleFieldsForSection(
   layoutConfig: InvoiceLayoutConfig | undefined | null,
   layoutConfig: InvoiceLayoutConfig | undefined | null,
-  sectionId: string,
+  sectionId: string
 ): Set<string> | null {
 ): Set<string> | null {
-  if (!layoutConfig) return null;
-  const section = layoutConfig.sections.find((s) => s.id === sectionId);
-  if (!section?.fields) return null;
-  return new Set(section.fields.filter((f) => f.visible).map((f) => f.id));
+  if (!layoutConfig) return null
+  const section = layoutConfig.sections.find((s) => s.id === sectionId)
+  if (!section?.fields) return null
+  return new Set(section.fields.filter((f) => f.visible).map((f) => f.id))
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -490,26 +504,26 @@ export function getVisibleFieldsForSection(
 
 
 function mergeSectionFields(
 function mergeSectionFields(
   savedFields: InvoiceFieldConfig[] | undefined,
   savedFields: InvoiceFieldConfig[] | undefined,
-  defaults: InvoiceFieldConfig[],
+  defaults: InvoiceFieldConfig[]
 ): InvoiceFieldConfig[] {
 ): InvoiceFieldConfig[] {
   if (!savedFields || savedFields.length === 0) {
   if (!savedFields || savedFields.length === 0) {
-    return defaults;
+    return defaults
   }
   }
 
 
-  const seen = new Set<string>();
-  const merged: InvoiceFieldConfig[] = [];
+  const seen = new Set<string>()
+  const merged: InvoiceFieldConfig[] = []
 
 
   for (const field of savedFields) {
   for (const field of savedFields) {
-    seen.add(field.id);
-    merged.push(field);
+    seen.add(field.id)
+    merged.push(field)
   }
   }
 
 
   // Append any missing default fields
   // Append any missing default fields
   for (const def of defaults) {
   for (const def of defaults) {
     if (!seen.has(def.id)) {
     if (!seen.has(def.id)) {
-      merged.push(def);
+      merged.push(def)
     }
     }
   }
   }
 
 
-  return merged;
+  return merged
 }
 }

+ 42 - 0
src/features/settings/Schema/templatePresets.ts

@@ -1,3 +1,8 @@
+import {
+  type InvoiceLayoutConfig,
+  getDefaultInvoiceLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
 export interface TemplatePreset {
 export interface TemplatePreset {
   id: string
   id: string
   name: string
   name: string
@@ -5,6 +10,34 @@ export interface TemplatePreset {
   primaryColor: string
   primaryColor: string
   fontFamily: string
   fontFamily: string
   headerStyle: string
   headerStyle: string
+  /**
+   * Sections the preset arranges for you. Presets without one only set colors
+   * and fonts, and leave whatever layout the workshop has already built alone.
+   */
+  layoutConfig?: InvoiceLayoutConfig
+}
+
+/**
+ * One numbered list instead of a parts table above a labor table, with the
+ * vehicle sitting opposite the customer rather than under it. Common wherever a
+ * job is quoted and billed as a sequence of positions.
+ */
+function itemizedLayout(): InvoiceLayoutConfig {
+  return {
+    sections: getDefaultInvoiceLayout().sections.map((section) => {
+      switch (section.id) {
+        case 'items_table':
+          return { ...section, visible: true }
+        case 'parts_table':
+        case 'labor_table':
+          return { ...section, visible: false }
+        case 'vehicle':
+          return { ...section, column: 'right' as const }
+        default:
+          return section
+      }
+    }),
+  }
 }
 }
 
 
 export const templatePresets: TemplatePreset[] = [
 export const templatePresets: TemplatePreset[] = [
@@ -48,6 +81,15 @@ export const templatePresets: TemplatePreset[] = [
     fontFamily: 'Helvetica',
     fontFamily: 'Helvetica',
     headerStyle: 'modern',
     headerStyle: 'modern',
   },
   },
+  {
+    id: 'itemized',
+    name: 'Itemized',
+    description: 'One numbered item list with a unit column',
+    primaryColor: '#ea580c',
+    fontFamily: 'Helvetica',
+    headerStyle: 'modern',
+    layoutConfig: itemizedLayout(),
+  },
   {
   {
     id: 'bold',
     id: 'bold',
     name: 'Bold',
     name: 'Bold',

+ 20 - 1
src/features/team/Components/SetupCodeHandoff.tsx

@@ -3,6 +3,7 @@
 import { useEffect, useState } from 'react'
 import { useEffect, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { QRCodeSVG } from 'qrcode.react'
 import { QRCodeSVG } from 'qrcode.react'
+import { PLAY_STORE_URL } from '@/lib/app-links'
 import { Check, Copy, Loader2 } from 'lucide-react'
 import { Check, Copy, Loader2 } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 
 
@@ -80,7 +81,25 @@ export function SetupCodeHandoff({
               <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-xs">
               <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-xs">
                 {i + 1}
                 {i + 1}
               </span>
               </span>
-              <span>{step}</span>
+              <span>
+                {step}
+                {/* The desk is often the one who finds the app for them, so
+                    the first step carries the link rather than leaving
+                    somebody to search the store on a stranger's phone. */}
+                {i === 0 && (
+                  <>
+                    {' '}
+                    <a
+                      href={PLAY_STORE_URL}
+                      target="_blank"
+                      rel="noreferrer"
+                      className="underline underline-offset-4"
+                    >
+                      {t('team.setupPageInstall')}
+                    </a>
+                  </>
+                )}
+              </span>
             </li>
             </li>
           )
           )
         )}
         )}

+ 37 - 1
src/features/vehicles/Components/invoice-pdf/InvoicePDF.tsx

@@ -6,6 +6,7 @@ import { createStyles, gray, getFontBold } from './styles'
 import { Header } from './Header'
 import { Header } from './Header'
 import { CustomerSection, VehicleSection, ServiceSection } from './InfoSection'
 import { CustomerSection, VehicleSection, ServiceSection } from './InfoSection'
 import { PartsTable, LaborTable, FindingsPdfSection } from './Tables'
 import { PartsTable, LaborTable, FindingsPdfSection } from './Tables'
+import { ItemsTable, type CombinedItem } from './ItemsTable'
 import { Totals } from './Totals'
 import { Totals } from './Totals'
 import { NotesOnly, BankAccountSection } from './Notes'
 import { NotesOnly, BankAccountSection } from './Notes'
 import { WarrantySection } from './Warranty'
 import { WarrantySection } from './Warranty'
@@ -132,6 +133,26 @@ export function InvoicePDF({
   const isPaidInFull = paymentSummary ? paymentSummary.totalPaid >= displayTotal : false
   const isPaidInFull = paymentSummary ? paymentSummary.totalPaid >= displayTotal : false
 
 
   const shopDisplayName = workshop?.name || data.shopName || 'Torqvoice'
   const shopDisplayName = workshop?.name || data.shopName || 'Torqvoice'
+
+  // Labor first, then parts: a job reads as the work that was done followed by
+  // what it took, and that is the order the numbered positions run in.
+  const combinedItems: CombinedItem[] = [
+    ...data.laborItems.map((l) => ({
+      quantity: l.hours,
+      unit: l.pricingType === 'service' ? labels.unit || 'unit' : labels.hrs || 'hrs',
+      description: l.description,
+      unitPrice: l.rate,
+      total: l.total,
+    })),
+    ...data.partItems.map((p) => ({
+      quantity: p.quantity,
+      unit: p.unit,
+      description: p.name,
+      reference: p.partNumber,
+      unitPrice: p.unitPrice,
+      total: p.total,
+    })),
+  ]
   const hasAttachments = imageAttachments.length > 0 || otherAttachments.length > 0
   const hasAttachments = imageAttachments.length > 0 || otherAttachments.length > 0
 
 
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
@@ -144,6 +165,9 @@ export function InvoicePDF({
   const visibleHeaderFields = getVisibleFieldsForSection(layoutConfig, 'header')
   const visibleHeaderFields = getVisibleFieldsForSection(layoutConfig, 'header')
   const visibleBankAccountFields = getVisibleFieldsForSection(layoutConfig, 'bank_account')
   const visibleBankAccountFields = getVisibleFieldsForSection(layoutConfig, 'bank_account')
 
 
+  const effectiveSections = layoutConfig?.sections ?? getDefaultInvoiceLayout().sections
+  const itemsTableVisible = effectiveSections.some((s) => s.id === 'items_table' && s.visible)
+
   const allCf = data.customFields || []
   const allCf = data.customFields || []
   const customerCf = getCustomFieldsForSection(layoutConfig, 'customer', allCf)
   const customerCf = getCustomFieldsForSection(layoutConfig, 'customer', allCf)
   const vehicleCf = getCustomFieldsForSection(layoutConfig, 'vehicle', allCf)
   const vehicleCf = getCustomFieldsForSection(layoutConfig, 'vehicle', allCf)
@@ -209,6 +233,18 @@ export function InvoicePDF({
       />
       />
     ),
     ),
 
 
+    items_table: (
+      <ItemsTable
+        items={combinedItems}
+        currencyCode={cc}
+        currencyFormat={cf}
+        taxRate={data.taxRate}
+        taxInclusive={data.taxInclusive ?? false}
+        styles={styles}
+        labels={labels}
+      />
+    ),
+
     parts_table: (
     parts_table: (
       <PartsTable
       <PartsTable
         data={data}
         data={data}
@@ -243,6 +279,7 @@ export function InvoicePDF({
           balanceDue={balanceDue}
           balanceDue={balanceDue}
           isPaidInFull={isPaidInFull}
           isPaidInFull={isPaidInFull}
           paymentSummary={paymentSummary}
           paymentSummary={paymentSummary}
+          showCategorySubtotals={!itemsTableVisible}
           styles={styles}
           styles={styles}
           labels={labels}
           labels={labels}
         />
         />
@@ -352,7 +389,6 @@ export function InvoicePDF({
   }
   }
 
 
   // Use column-based grouping from layout config.
   // Use column-based grouping from layout config.
-  const effectiveSections = layoutConfig?.sections ?? getDefaultInvoiceLayout().sections
   const renderGroups = groupSectionsForRendering(effectiveSections)
   const renderGroups = groupSectionsForRendering(effectiveSections)
   const renderedSections: React.ReactNode[] = []
   const renderedSections: React.ReactNode[] = []
 
 

+ 143 - 0
src/features/vehicles/Components/invoice-pdf/ItemsTable.tsx

@@ -0,0 +1,143 @@
+import { Text, View } from '@react-pdf/renderer'
+import { formatCurrency } from '@/lib/format'
+import { netLineTotal } from '@/lib/tax'
+import { gray, grayLight } from './styles'
+import type { Style } from '@react-pdf/types'
+
+/**
+ * One row of the combined table, flattened from whatever the document calls its
+ * parts and labor lines. Both documents map their own rows into this shape, so
+ * the table itself never has to know which is which.
+ */
+export interface CombinedItem {
+  quantity: number
+  /** Unit of measure. Labor lines pass their own ("hrs", "unit"). */
+  unit?: string | null
+  description: string
+  /** Part number, printed under the description when there is one. */
+  reference?: string | null
+  unitPrice: number
+  total: number
+  /** Quote lines the customer has opted out of. */
+  excluded?: boolean
+}
+
+const COLUMNS = {
+  pos: '6%',
+  qty: '10%',
+  unit: '11%',
+  description: '43%',
+  unitPrice: '15%',
+  total: '15%',
+} as const
+
+/**
+ * Every line on one numbered list, the way a workshop that quotes a job as a
+ * single sequence of positions expects to see it, rather than split across a
+ * parts table and a labor table.
+ */
+export function ItemsTable({
+  items,
+  currencyCode,
+  currencyFormat = 'symbol',
+  taxRate,
+  taxInclusive = false,
+  styles,
+  labels,
+}: {
+  items: CombinedItem[]
+  currencyCode: string
+  currencyFormat?: 'symbol' | 'code'
+  taxRate: number
+  taxInclusive?: boolean
+  styles: Record<string, Style>
+  labels: Record<string, string>
+}) {
+  if (items.length === 0) return null
+
+  return (
+    <View>
+      <Text style={styles.sectionTitle}>{labels.items || 'Items'}</Text>
+      <View style={styles.table}>
+        <View style={styles.tableHeader}>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.pos }}>{labels.pos || '#'}</Text>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.qty, textAlign: 'right' }}>
+            {labels.qty || 'Qty'}
+          </Text>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.unit, paddingLeft: 6 }}>
+            {labels.unitOfMeasure || 'Unit'}
+          </Text>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.description }}>
+            {labels.description || 'Description'}
+          </Text>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.unitPrice, textAlign: 'right' }}>
+            {labels.unitPrice || 'Unit Price'}
+          </Text>
+          <Text style={{ ...styles.tableHeaderCell, width: COLUMNS.total, textAlign: 'right' }}>
+            {labels.total || 'Total'}
+          </Text>
+        </View>
+
+        {items.map((item, i) => {
+          // Inclusive records are back-calculated to net so the line prices on
+          // the page still add up to the net subtotal below them, exactly as
+          // the separate parts and labor tables already do.
+          const netUnitPrice = netLineTotal(item.unitPrice, taxRate, taxInclusive)
+          const netTotal = netLineTotal(item.total, taxRate, taxInclusive)
+          const struck = item.excluded ? { textDecoration: 'line-through' as const } : {}
+
+          return (
+            <View
+              key={i}
+              style={{
+                ...styles.tableRow,
+                ...(i % 2 === 1 ? { backgroundColor: grayLight } : {}),
+                ...(item.excluded ? { opacity: 0.5 } : {}),
+              }}
+            >
+              <Text style={{ ...styles.tableCell, width: COLUMNS.pos, color: gray }}>{i + 1}</Text>
+              <Text
+                style={{ ...styles.tableCell, width: COLUMNS.qty, textAlign: 'right', ...struck }}
+              >
+                {item.quantity}
+              </Text>
+              <Text
+                style={{ ...styles.tableCell, width: COLUMNS.unit, paddingLeft: 6, color: gray }}
+              >
+                {item.unit || ''}
+              </Text>
+              <View style={{ width: COLUMNS.description }}>
+                <Text style={{ ...styles.tableCell, ...struck }}>{item.description}</Text>
+                {item.reference ? (
+                  <Text style={{ ...styles.tableCell, fontSize: 7.5, color: gray }}>
+                    {item.reference}
+                  </Text>
+                ) : null}
+              </View>
+              <Text
+                style={{
+                  ...styles.tableCell,
+                  width: COLUMNS.unitPrice,
+                  textAlign: 'right',
+                  ...struck,
+                }}
+              >
+                {formatCurrency(netUnitPrice, currencyCode, currencyFormat)}
+              </Text>
+              <Text
+                style={{
+                  ...styles.tableCellBold,
+                  width: COLUMNS.total,
+                  textAlign: 'right',
+                  ...struck,
+                }}
+              >
+                {formatCurrency(netTotal, currencyCode, currencyFormat)}
+              </Text>
+            </View>
+          )
+        })}
+      </View>
+    </View>
+  )
+}

+ 9 - 2
src/features/vehicles/Components/invoice-pdf/Totals.tsx

@@ -21,6 +21,12 @@ interface TotalsProps {
   balanceDue: number
   balanceDue: number
   isPaidInFull: boolean
   isPaidInFull: boolean
   paymentSummary?: PaymentSummary
   paymentSummary?: PaymentSummary
+  /**
+   * Off when every line is already on one combined table: splitting the sum
+   * back into parts and labor would be a breakdown of a list the reader never
+   * saw split.
+   */
+  showCategorySubtotals?: boolean
   styles: Record<string, Style>
   styles: Record<string, Style>
   labels: Record<string, string>
   labels: Record<string, string>
 }
 }
@@ -37,6 +43,7 @@ export function Totals({
   balanceDue,
   balanceDue,
   isPaidInFull,
   isPaidInFull,
   paymentSummary,
   paymentSummary,
+  showCategorySubtotals = true,
   styles,
   styles,
   labels,
   labels,
 }: TotalsProps) {
 }: TotalsProps) {
@@ -55,7 +62,7 @@ export function Totals({
 
 
   return (
   return (
     <View style={styles.totalsBox}>
     <View style={styles.totalsBox}>
-      {data.partItems.length > 0 && (
+      {showCategorySubtotals && data.partItems.length > 0 && (
         <View style={styles.totalRow}>
         <View style={styles.totalRow}>
           <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
           <Text style={styles.totalLabel}>{labels.parts || 'Parts'}</Text>
           <Text style={styles.totalValue}>
           <Text style={styles.totalValue}>
@@ -63,7 +70,7 @@ export function Totals({
           </Text>
           </Text>
         </View>
         </View>
       )}
       )}
-      {data.laborItems.length > 0 && (
+      {showCategorySubtotals && data.laborItems.length > 0 && (
         <View style={styles.totalRow}>
         <View style={styles.totalRow}>
           <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
           <Text style={styles.totalLabel}>{labels.labor || 'Labor'}</Text>
           <Text style={styles.totalValue}>
           <Text style={styles.totalValue}>

+ 13 - 0
src/lib/app-links.ts

@@ -0,0 +1,13 @@
+/**
+ * Where the technician app lives.
+ *
+ * One constant rather than a URL typed into each screen that offers it: the
+ * desk's handoff dialog, the page a scanned QR lands on, and the docs all
+ * point at the same listing, and a store link that has drifted in one of them
+ * is a technician who cannot install the app.
+ */
+export const PLAY_STORE_URL =
+  'https://play.google.com/store/apps/details?id=com.torqvoice.technician'
+
+/** The Android package, which is also what a deep link resolves against. */
+export const ANDROID_PACKAGE = 'com.torqvoice.technician'

Деякі файли не було показано, через те що забагато файлів було змінено