Explorar el Código

Work order and tire check-in fixes (#414)

* do not lock design

* tire hotel linking

* layout
Bernt Christian Egeland hace 1 semana
padre
commit
8871a36cea
Se han modificado 69 ficheros con 1462 adiciones y 534 borrados
  1. 6 2
      e2e/specs/work-orders/modern-layout.spec.ts
  2. 2 2
      messages/de/quotes.json
  3. 2 4
      messages/de/service.json
  4. 6 0
      messages/de/tireHotel.json
  5. 2 2
      messages/en/quotes.json
  6. 2 4
      messages/en/service.json
  7. 6 0
      messages/en/tireHotel.json
  8. 2 2
      messages/es/quotes.json
  9. 2 4
      messages/es/service.json
  10. 6 0
      messages/es/tireHotel.json
  11. 2 2
      messages/fr/quotes.json
  12. 2 4
      messages/fr/service.json
  13. 6 0
      messages/fr/tireHotel.json
  14. 2 2
      messages/it/quotes.json
  15. 2 4
      messages/it/service.json
  16. 6 0
      messages/it/tireHotel.json
  17. 2 2
      messages/lt/quotes.json
  18. 2 4
      messages/lt/service.json
  19. 6 0
      messages/lt/tireHotel.json
  20. 2 2
      messages/nb/quotes.json
  21. 2 4
      messages/nb/service.json
  22. 6 0
      messages/nb/tireHotel.json
  23. 2 2
      messages/nl/quotes.json
  24. 2 4
      messages/nl/service.json
  25. 6 0
      messages/nl/tireHotel.json
  26. 2 2
      messages/pl/quotes.json
  27. 2 4
      messages/pl/service.json
  28. 6 0
      messages/pl/tireHotel.json
  29. 2 2
      messages/pt-BR/quotes.json
  30. 2 4
      messages/pt-BR/service.json
  31. 6 0
      messages/pt-BR/tireHotel.json
  32. 2 2
      messages/pt-PT/quotes.json
  33. 2 4
      messages/pt-PT/service.json
  34. 6 0
      messages/pt-PT/tireHotel.json
  35. 2 2
      messages/ru/quotes.json
  36. 2 4
      messages/ru/service.json
  37. 6 0
      messages/ru/tireHotel.json
  38. 2 2
      messages/tr/quotes.json
  39. 2 4
      messages/tr/service.json
  40. 6 0
      messages/tr/tireHotel.json
  41. 81 0
      src/__tests__/features/invoices/set-invoice-design.test.ts
  42. 106 0
      src/__tests__/features/tire-hotel/check-in-job-lines.test.ts
  43. 1 1
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  44. 1 1
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  45. 2 2
      src/components/app-sidebar.tsx
  46. 1 1
      src/features/inspections/Components/NewInspectionDialog.tsx
  47. 1 1
      src/features/inventory/Components/InventoryPartForm.tsx
  48. 1 1
      src/features/inventory/Components/UnitCombobox.tsx
  49. 9 0
      src/features/invoices/Actions/invoiceDesignActions.ts
  50. 8 7
      src/features/quotes/Components/QuoteLaborEditor.tsx
  51. 1 1
      src/features/team/Components/CountryPicker.tsx
  52. 3 85
      src/features/tire-hotel/Actions/tireJobActions.ts
  53. 85 0
      src/features/tire-hotel/Actions/tireSetActions.ts
  54. 652 276
      src/features/tire-hotel/Components/CheckInDialog.tsx
  55. 1 1
      src/features/tire-hotel/Components/LocationPicker.tsx
  56. 44 9
      src/features/tire-hotel/Components/StoreTiresButton.tsx
  57. 24 23
      src/features/tire-hotel/Components/TreadEntry.tsx
  58. 149 0
      src/features/tire-hotel/Lib/jobLines.ts
  59. 15 0
      src/features/tire-hotel/Schema/tireHotelSchema.ts
  60. 1 1
      src/features/vehicles/Components/VehicleForm.tsx
  61. 1 12
      src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx
  62. 3 1
      src/features/vehicles/Components/service-edit/LaborEditor.tsx
  63. 1 1
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx
  64. 8 5
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  65. 0 3
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  66. 0 1
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  67. 25 14
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx
  68. 50 0
      src/features/vehicles/Components/service-page/modern/MoneyBar.tsx
  69. 52 7
      src/features/vehicles/Components/service-page/modern/StatusStepper.tsx

+ 6 - 2
e2e/specs/work-orders/modern-layout.spec.ts

@@ -170,12 +170,16 @@ test.describe('the overhauled work order page', () => {
     // The money bar is a desktop thing: the page already carries its totals,
     // The money bar is a desktop thing: the page already carries its totals,
     // payment form, preview and send, and a phone has no height to spare.
     // payment form, preview and send, and a phone has no height to spare.
     await expect(page.getByTestId('money-bar')).toBeHidden()
     await expect(page.getByTestId('money-bar')).toBeHidden()
+    // What it leaves behind is the running total, in the flow under the parts,
+    // so adding a line on a small screen still shows what it did to the bill.
+    await expect(page.getByTestId('money-line')).toBeVisible()
     // The header's second line is which job this is; when it was opened is
     // The header's second line is which job this is; when it was opened is
     // left for a wider screen.
     // left for a wider screen.
     await expect(page.getByTestId('service-opened')).toBeHidden()
     await expect(page.getByTestId('service-opened')).toBeHidden()
 
 
-    // Each stage of the status stepper stays in its own space: a label is cut
-    // short rather than running into the next stage.
+    // Each control of the status stepper stays in its own space: a label is
+    // cut short rather than running into the next. Three stages and the
+    // waiting-for-parts hold beside the second, four buttons in all.
     const stages = await page
     const stages = await page
       .getByTestId('status-stepper')
       .getByTestId('status-stepper')
       .getByRole('button')
       .getByRole('button')

+ 2 - 2
messages/de/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Einheit / Stunden",
     "qtyOrHours": "Einheit / Stunden",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
-    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
+    "hourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "serviceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
     "deleteRow": "Zeile löschen",
     "deleteRow": "Zeile löschen",
     "excludeFromTotal": "Von der Summe ausnehmen"
     "excludeFromTotal": "Von der Summe ausnehmen"
   },
   },

+ 2 - 4
messages/de/service.json

@@ -103,9 +103,7 @@
     "design": "Rechnungsdesign",
     "design": "Rechnungsdesign",
     "designDefault": "Werkstatt-Standard",
     "designDefault": "Werkstatt-Standard",
     "designFollowing": "Standard ({name})",
     "designFollowing": "Standard ({name})",
-    "designPinned": "Diese Rechnung erreichte den Kunden am {date} und behält daher das Design, mit dem sie hinausging. Eine Auswahl hier greift erst, nachdem sie entsperrt und erneut versendet wurde. Ein Inhaber kann das Design jetzt über Rechnungsdesign im ⋮-Menü oben ändern.",
     "designSaved": "Rechnungsdesign aktualisiert",
     "designSaved": "Rechnungsdesign aktualisiert",
-    "designSavedPinned": "Gespeichert. Diese Rechnung behält das Design, mit dem sie versendet wurde, bis sie entsperrt und erneut versendet wird.",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Reiner Teileverkauf"
       "parts_sale": "Reiner Teileverkauf"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Einheit / Stunden",
     "qtyOrHours": "Einheit / Stunden",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
-    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
+    "hourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "serviceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
     "deleteRow": "Zeile löschen",
     "deleteRow": "Zeile löschen",
     "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
     "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
   },
   },

+ 6 - 0
messages/de/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Reifen einlagern",
     "title": "Reifen einlagern",
+    "sectionOwner": "Kunde und Fahrzeug",
+    "back": "Zurück",
+    "next": "Weiter",
+    "stepStorage": "Regal und Vorbereitung",
+    "stepReview": "Überprüfen",
     "description": "Erfassen Sie, was angekommen ist, und wählen Sie den Lagerplatz.",
     "description": "Erfassen Sie, was angekommen ist, und wählen Sie den Lagerplatz.",
     "customer": "Kunde",
     "customer": "Kunde",
     "selectCustomer": "Kunde auswählen",
     "selectCustomer": "Kunde auswählen",
@@ -358,6 +363,7 @@
     "quantity": "Anz.",
     "quantity": "Anz.",
     "noStockItem": "Kein Lagerartikel, Preis von Hand",
     "noStockItem": "Kein Lagerartikel, Preis von Hand",
     "storeTires": "Reifen einlagern",
     "storeTires": "Reifen einlagern",
+    "noneStored": "Keine Reifen aus diesem Auftrag eingelagert",
     "storage": "Lagerung",
     "storage": "Lagerung",
     "storageFee": "Gebühr",
     "storageFee": "Gebühr",
     "storagePeriod": "Zeitraum",
     "storagePeriod": "Zeitraum",

+ 2 - 2
messages/en/quotes.json

@@ -96,8 +96,8 @@
     "subtotal": "Labor Subtotal: {amount}",
     "subtotal": "Labor Subtotal: {amount}",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "hourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
+    "serviceHint": "Service: priced as a flat fee per unit. Click to switch.",
     "deleteRow": "Delete row",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
     "excludeFromTotal": "Exclude from total"
   },
   },

+ 2 - 4
messages/en/service.json

@@ -103,9 +103,7 @@
     "design": "Invoice design",
     "design": "Invoice design",
     "designDefault": "Workshop default",
     "designDefault": "Workshop default",
     "designFollowing": "Default ({name})",
     "designFollowing": "Default ({name})",
-    "designPinned": "This invoice reached the customer on {date}, so it keeps the design it went out with. A choice here applies only after it is unlocked and sent again. An owner can change the design now from Invoice design in the ⋮ menu at the top.",
     "designSaved": "Invoice design updated",
     "designSaved": "Invoice design updated",
-    "designSavedPinned": "Saved. This invoice keeps the design it was sent with until it is unlocked and sent again.",
     "designFollowingRule": "Default ({name}, {rule})",
     "designFollowingRule": "Default ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Parts-Only Sale"
       "parts_sale": "Parts-Only Sale"
@@ -160,8 +158,8 @@
     "subtotal": "Labor Subtotal: {amount}",
     "subtotal": "Labor Subtotal: {amount}",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "hourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
+    "serviceHint": "Service: priced as a flat fee per unit. Click to switch.",
     "deleteRow": "Delete row",
     "deleteRow": "Delete row",
     "descriptionMissingHint": "Rows without a description are not saved."
     "descriptionMissingHint": "Rows without a description are not saved."
   },
   },

+ 6 - 0
messages/en/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Check in tires",
     "title": "Check in tires",
+    "sectionOwner": "Customer and vehicle",
+    "back": "Back",
+    "next": "Next",
+    "stepStorage": "Shelf and prep",
+    "stepReview": "Review",
     "description": "Record what arrived, then choose where it goes.",
     "description": "Record what arrived, then choose where it goes.",
     "customer": "Customer",
     "customer": "Customer",
     "selectCustomer": "Select customer",
     "selectCustomer": "Select customer",
@@ -358,6 +363,7 @@
     "quantity": "Qty",
     "quantity": "Qty",
     "noStockItem": "No stock item, priced by hand",
     "noStockItem": "No stock item, priced by hand",
     "storeTires": "Store tires",
     "storeTires": "Store tires",
+    "noneStored": "No tires stored from this job",
     "storage": "Storage",
     "storage": "Storage",
     "storageFee": "Fee",
     "storageFee": "Fee",
     "storagePeriod": "Period",
     "storagePeriod": "Period",

+ 2 - 2
messages/es/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Unidad / Horas",
     "qtyOrHours": "Unidad / Horas",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
-    "switchToServiceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
+    "hourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
+    "serviceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
     "deleteRow": "Eliminar fila",
     "deleteRow": "Eliminar fila",
     "excludeFromTotal": "Excluir del total"
     "excludeFromTotal": "Excluir del total"
   },
   },

+ 2 - 4
messages/es/service.json

@@ -103,9 +103,7 @@
     "design": "Diseño de factura",
     "design": "Diseño de factura",
     "designDefault": "Predeterminado del taller",
     "designDefault": "Predeterminado del taller",
     "designFollowing": "Predeterminado ({name})",
     "designFollowing": "Predeterminado ({name})",
-    "designPinned": "Esta factura llegó al cliente el {date}, así que mantiene el diseño con el que salió. Una elección aquí se aplica solo después de desbloquearla y volver a enviarla. Un propietario puede cambiar el diseño ahora desde Diseño de la factura en el menú ⋮ de arriba.",
     "designSaved": "Diseño de factura actualizado",
     "designSaved": "Diseño de factura actualizado",
-    "designSavedPinned": "Guardado. Esta factura mantiene el diseño con el que se envió hasta que se desbloquee y se vuelva a enviar.",
     "designFollowingRule": "Predeterminado ({name}, {rule})",
     "designFollowingRule": "Predeterminado ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Venta solo de piezas"
       "parts_sale": "Venta solo de piezas"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Unidad / Horas",
     "qtyOrHours": "Unidad / Horas",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
-    "switchToServiceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
+    "hourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
+    "serviceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
     "deleteRow": "Eliminar fila",
     "deleteRow": "Eliminar fila",
     "descriptionMissingHint": "Las filas sin descripción no se guardan."
     "descriptionMissingHint": "Las filas sin descripción no se guardan."
   },
   },

+ 6 - 0
messages/es/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Recibir neumáticos",
     "title": "Recibir neumáticos",
+    "sectionOwner": "Cliente y vehículo",
+    "back": "Volver",
+    "next": "Siguiente",
+    "stepStorage": "Estante y preparación",
+    "stepReview": "Revisar",
     "description": "Registra lo que llega y elige dónde se guarda.",
     "description": "Registra lo que llega y elige dónde se guarda.",
     "customer": "Cliente",
     "customer": "Cliente",
     "selectCustomer": "Seleccionar cliente",
     "selectCustomer": "Seleccionar cliente",
@@ -358,6 +363,7 @@
     "quantity": "Cant.",
     "quantity": "Cant.",
     "noStockItem": "Sin artículo de inventario, precio manual",
     "noStockItem": "Sin artículo de inventario, precio manual",
     "storeTires": "Guardar neumáticos",
     "storeTires": "Guardar neumáticos",
+    "noneStored": "No hay neumáticos almacenados de este trabajo",
     "storage": "Almacenaje",
     "storage": "Almacenaje",
     "storageFee": "Importe",
     "storageFee": "Importe",
     "storagePeriod": "Periodo",
     "storagePeriod": "Periodo",

+ 2 - 2
messages/fr/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Unité / Heures",
     "qtyOrHours": "Unité / Heures",
     "hourlyTag": "H",
     "hourlyTag": "H",
     "serviceTag": "SERV",
     "serviceTag": "SERV",
-    "switchToHourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
-    "switchToServiceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
+    "hourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
+    "serviceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
     "deleteRow": "Supprimer la ligne",
     "deleteRow": "Supprimer la ligne",
     "excludeFromTotal": "Exclure du total"
     "excludeFromTotal": "Exclure du total"
   },
   },

+ 2 - 4
messages/fr/service.json

@@ -103,9 +103,7 @@
     "design": "Design de facture",
     "design": "Design de facture",
     "designDefault": "Défaut de l'atelier",
     "designDefault": "Défaut de l'atelier",
     "designFollowing": "Défaut ({name})",
     "designFollowing": "Défaut ({name})",
-    "designPinned": "Cette facture est parvenue au client le {date}, elle conserve donc le design avec lequel elle est partie. Un choix fait ici ne s'applique qu'après déverrouillage et nouvel envoi. Un propriétaire peut changer le design maintenant via Design de la facture dans le menu ⋮ en haut.",
     "designSaved": "Design de facture mis à jour",
     "designSaved": "Design de facture mis à jour",
-    "designSavedPinned": "Enregistré. Cette facture conserve le design avec lequel elle a été envoyée jusqu'à ce qu'elle soit déverrouillée et renvoyée.",
     "designFollowingRule": "Par défaut ({name}, {rule})",
     "designFollowingRule": "Par défaut ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Vente de pièces seule"
       "parts_sale": "Vente de pièces seule"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Unité / Heures",
     "qtyOrHours": "Unité / Heures",
     "hourlyTag": "H",
     "hourlyTag": "H",
     "serviceTag": "SERV",
     "serviceTag": "SERV",
-    "switchToHourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
-    "switchToServiceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
+    "hourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
+    "serviceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
     "deleteRow": "Supprimer la ligne",
     "deleteRow": "Supprimer la ligne",
     "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées."
     "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées."
   },
   },

+ 6 - 0
messages/fr/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Réceptionner des pneus",
     "title": "Réceptionner des pneus",
+    "sectionOwner": "Client et véhicule",
+    "back": "Retour",
+    "next": "Suivant",
+    "stepStorage": "Étagère et préparation",
+    "stepReview": "Vérifier",
     "description": "Enregistrez ce qui arrive, puis choisissez où le ranger.",
     "description": "Enregistrez ce qui arrive, puis choisissez où le ranger.",
     "customer": "Client",
     "customer": "Client",
     "selectCustomer": "Choisir un client",
     "selectCustomer": "Choisir un client",
@@ -358,6 +363,7 @@
     "quantity": "Qté",
     "quantity": "Qté",
     "noStockItem": "Pas d’article en stock, prix saisi à la main",
     "noStockItem": "Pas d’article en stock, prix saisi à la main",
     "storeTires": "Stocker les pneus",
     "storeTires": "Stocker les pneus",
+    "noneStored": "Aucun pneu stocké pour cette intervention",
     "storage": "Stockage",
     "storage": "Stockage",
     "storageFee": "Frais",
     "storageFee": "Frais",
     "storagePeriod": "Période",
     "storagePeriod": "Période",

+ 2 - 2
messages/it/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Unità / Ore",
     "qtyOrHours": "Unità / Ore",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
-    "switchToServiceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
+    "hourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
+    "serviceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
     "deleteRow": "Elimina riga",
     "deleteRow": "Elimina riga",
     "excludeFromTotal": "Escludi dal totale"
     "excludeFromTotal": "Escludi dal totale"
   },
   },

+ 2 - 4
messages/it/service.json

@@ -103,9 +103,7 @@
     "design": "Design della fattura",
     "design": "Design della fattura",
     "designDefault": "Predefinito dell'officina",
     "designDefault": "Predefinito dell'officina",
     "designFollowing": "Predefinito ({name})",
     "designFollowing": "Predefinito ({name})",
-    "designPinned": "Questa fattura è arrivata al cliente il {date}, quindi mantiene il design con cui è uscita. Una scelta fatta qui vale solo dopo che è stata sbloccata e inviata di nuovo. Un titolare può cambiare il design ora da Design della fattura nel menu ⋮ in alto.",
     "designSaved": "Design della fattura aggiornato",
     "designSaved": "Design della fattura aggiornato",
-    "designSavedPinned": "Salvato. Questa fattura mantiene il design con cui è stata inviata finché non viene sbloccata e inviata di nuovo.",
     "designFollowingRule": "Predefinito ({name}, {rule})",
     "designFollowingRule": "Predefinito ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Vendita solo ricambi"
       "parts_sale": "Vendita solo ricambi"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Unità / Ore",
     "qtyOrHours": "Unità / Ore",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
-    "switchToServiceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
+    "hourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
+    "serviceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
     "deleteRow": "Elimina riga",
     "deleteRow": "Elimina riga",
     "descriptionMissingHint": "Le righe senza descrizione non vengono salvate."
     "descriptionMissingHint": "Le righe senza descrizione non vengono salvate."
   },
   },

+ 6 - 0
messages/it/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Accettare pneumatici",
     "title": "Accettare pneumatici",
+    "sectionOwner": "Cliente e veicolo",
+    "back": "Indietro",
+    "next": "Avanti",
+    "stepStorage": "Scaffale e preparazione",
+    "stepReview": "Riepilogo",
     "description": "Registra ciò che è arrivato, poi scegli dove riporlo.",
     "description": "Registra ciò che è arrivato, poi scegli dove riporlo.",
     "customer": "Cliente",
     "customer": "Cliente",
     "selectCustomer": "Seleziona cliente",
     "selectCustomer": "Seleziona cliente",
@@ -358,6 +363,7 @@
     "quantity": "Qtà",
     "quantity": "Qtà",
     "noStockItem": "Nessun articolo a magazzino, prezzo manuale",
     "noStockItem": "Nessun articolo a magazzino, prezzo manuale",
     "storeTires": "Deposita gli pneumatici",
     "storeTires": "Deposita gli pneumatici",
+    "noneStored": "Nessun pneumatico depositato da questo lavoro",
     "storage": "Deposito",
     "storage": "Deposito",
     "storageFee": "Importo",
     "storageFee": "Importo",
     "storagePeriod": "Periodo",
     "storagePeriod": "Periodo",

+ 2 - 2
messages/lt/quotes.json

@@ -96,8 +96,8 @@
     "subtotal": "Darbų tarpinė suma: {amount}",
     "subtotal": "Darbų tarpinė suma: {amount}",
     "hourlyTag": "VAL",
     "hourlyTag": "VAL",
     "serviceTag": "PSL",
     "serviceTag": "PSL",
-    "switchToHourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
-    "switchToServiceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
+    "hourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
+    "serviceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
     "deleteRow": "Ištrinti eilutę",
     "deleteRow": "Ištrinti eilutę",
     "excludeFromTotal": "Neįskaičiuoti į sumą"
     "excludeFromTotal": "Neįskaičiuoti į sumą"
   },
   },

+ 2 - 4
messages/lt/service.json

@@ -103,9 +103,7 @@
     "design": "Sąskaitos dizainas",
     "design": "Sąskaitos dizainas",
     "designDefault": "Dirbtuvės numatytasis",
     "designDefault": "Dirbtuvės numatytasis",
     "designFollowing": "Numatytasis ({name})",
     "designFollowing": "Numatytasis ({name})",
-    "designPinned": "Ši sąskaita klientą pasiekė {date}, todėl išlaiko dizainą, su kuriuo iškeliavo. Čia padarytas pasirinkimas suveiks tik atrakinus ir išsiuntus iš naujo. Savininkas gali pakeisti dizainą dabar per Sąskaitos dizainas ⋮ meniu viršuje.",
     "designSaved": "Sąskaitos dizainas atnaujintas",
     "designSaved": "Sąskaitos dizainas atnaujintas",
-    "designSavedPinned": "Išsaugota. Ši sąskaita išlaiko dizainą, su kuriuo buvo išsiųsta, kol nebus atrakinta ir išsiųsta iš naujo.",
     "designFollowingRule": "Numatytasis ({name}, {rule})",
     "designFollowingRule": "Numatytasis ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Tik dalių pardavimas"
       "parts_sale": "Tik dalių pardavimas"
@@ -160,8 +158,8 @@
     "subtotal": "Darbų tarpinė suma: {amount}",
     "subtotal": "Darbų tarpinė suma: {amount}",
     "hourlyTag": "VAL",
     "hourlyTag": "VAL",
     "serviceTag": "PSL",
     "serviceTag": "PSL",
-    "switchToHourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
-    "switchToServiceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
+    "hourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
+    "serviceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
     "deleteRow": "Ištrinti eilutę",
     "deleteRow": "Ištrinti eilutę",
     "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos."
     "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos."
   },
   },

+ 6 - 0
messages/lt/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Priimti padangas",
     "title": "Priimti padangas",
+    "sectionOwner": "Klientas ir transporto priemonė",
+    "back": "Atgal",
+    "next": "Toliau",
+    "stepStorage": "Lentyna ir paruošimas",
+    "stepReview": "Peržiūra",
     "description": "Užfiksuokite, kas atvyko, ir pasirinkite, kur padėti.",
     "description": "Užfiksuokite, kas atvyko, ir pasirinkite, kur padėti.",
     "customer": "Klientas",
     "customer": "Klientas",
     "selectCustomer": "Pasirinkti klientą",
     "selectCustomer": "Pasirinkti klientą",
@@ -358,6 +363,7 @@
     "quantity": "Kiekis",
     "quantity": "Kiekis",
     "noStockItem": "Nėra sandėlio prekės, kaina įvedama ranka",
     "noStockItem": "Nėra sandėlio prekės, kaina įvedama ranka",
     "storeTires": "Padėti padangas saugoti",
     "storeTires": "Padėti padangas saugoti",
+    "noneStored": "Iš šio darbo padangų nesaugoma",
     "storage": "Saugojimas",
     "storage": "Saugojimas",
     "storageFee": "Mokestis",
     "storageFee": "Mokestis",
     "storagePeriod": "Laikotarpis",
     "storagePeriod": "Laikotarpis",

+ 2 - 2
messages/nb/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Enhet / Timer",
     "qtyOrHours": "Enhet / Timer",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
-    "switchToServiceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
+    "hourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
+    "serviceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
     "deleteRow": "Slett rad",
     "deleteRow": "Slett rad",
     "excludeFromTotal": "Hold utenfor summen"
     "excludeFromTotal": "Hold utenfor summen"
   },
   },

+ 2 - 4
messages/nb/service.json

@@ -103,9 +103,7 @@
     "design": "Fakturadesign",
     "design": "Fakturadesign",
     "designDefault": "Verkstedets standard",
     "designDefault": "Verkstedets standard",
     "designFollowing": "Standard ({name})",
     "designFollowing": "Standard ({name})",
-    "designPinned": "Denne fakturaen nådde kunden {date}, så den beholder designet den gikk ut med. Et valg her gjelder først etter at den låses opp og sendes på nytt. En eier kan endre designet nå fra Fakturadesign i ⋮-menyen øverst.",
     "designSaved": "Fakturadesign oppdatert",
     "designSaved": "Fakturadesign oppdatert",
-    "designSavedPinned": "Lagret. Denne fakturaen beholder designet den ble sendt med til den låses opp og sendes på nytt.",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Kun delesalg"
       "parts_sale": "Kun delesalg"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Enhet / Timer",
     "qtyOrHours": "Enhet / Timer",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
-    "switchToServiceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
+    "hourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
+    "serviceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
     "deleteRow": "Slett rad",
     "deleteRow": "Slett rad",
     "descriptionMissingHint": "Rader uten beskrivelse lagres ikke."
     "descriptionMissingHint": "Rader uten beskrivelse lagres ikke."
   },
   },

+ 6 - 0
messages/nb/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Ta inn dekk",
     "title": "Ta inn dekk",
+    "sectionOwner": "Kunde og kjøretøy",
+    "back": "Tilbake",
+    "next": "Neste",
+    "stepStorage": "Hylle og klargjøring",
+    "stepReview": "Se over",
     "description": "Registrer hva som kom inn, og velg hvor det skal stå.",
     "description": "Registrer hva som kom inn, og velg hvor det skal stå.",
     "customer": "Kunde",
     "customer": "Kunde",
     "selectCustomer": "Velg kunde",
     "selectCustomer": "Velg kunde",
@@ -358,6 +363,7 @@
     "quantity": "Ant.",
     "quantity": "Ant.",
     "noStockItem": "Ingen lagervare, pris settes manuelt",
     "noStockItem": "Ingen lagervare, pris settes manuelt",
     "storeTires": "Legg dekk på hotell",
     "storeTires": "Legg dekk på hotell",
+    "noneStored": "Ingen dekk lagret fra denne jobben",
     "storage": "Lagring",
     "storage": "Lagring",
     "storageFee": "Gebyr",
     "storageFee": "Gebyr",
     "storagePeriod": "Periode",
     "storagePeriod": "Periode",

+ 2 - 2
messages/nl/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Eenheid / Uren",
     "qtyOrHours": "Eenheid / Uren",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
-    "switchToServiceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
+    "hourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
+    "serviceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
     "deleteRow": "Rij verwijderen",
     "deleteRow": "Rij verwijderen",
     "excludeFromTotal": "Buiten het totaal houden"
     "excludeFromTotal": "Buiten het totaal houden"
   },
   },

+ 2 - 4
messages/nl/service.json

@@ -103,9 +103,7 @@
     "design": "Factuurontwerp",
     "design": "Factuurontwerp",
     "designDefault": "Standaard van de werkplaats",
     "designDefault": "Standaard van de werkplaats",
     "designFollowing": "Standaard ({name})",
     "designFollowing": "Standaard ({name})",
-    "designPinned": "Deze factuur bereikte de klant op {date} en houdt dus het ontwerp waarmee ze is uitgegaan. Een keuze hier geldt pas nadat ze is ontgrendeld en opnieuw verstuurd. Een eigenaar kan het ontwerp nu wijzigen via Factuurontwerp in het ⋮-menu bovenaan.",
     "designSaved": "Factuurontwerp bijgewerkt",
     "designSaved": "Factuurontwerp bijgewerkt",
-    "designSavedPinned": "Opgeslagen. Deze factuur houdt het ontwerp waarmee ze is verstuurd totdat ze wordt ontgrendeld en opnieuw verstuurd.",
     "designFollowingRule": "Standaard ({name}, {rule})",
     "designFollowingRule": "Standaard ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Alleen onderdelenverkoop"
       "parts_sale": "Alleen onderdelenverkoop"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Eenheid / Uren",
     "qtyOrHours": "Eenheid / Uren",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
-    "switchToServiceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
+    "hourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
+    "serviceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
     "deleteRow": "Rij verwijderen",
     "deleteRow": "Rij verwijderen",
     "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen."
     "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen."
   },
   },

+ 6 - 0
messages/nl/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Banden innemen",
     "title": "Banden innemen",
+    "sectionOwner": "Klant en voertuig",
+    "back": "Terug",
+    "next": "Volgende",
+    "stepStorage": "Stelling en voorbereiding",
+    "stepReview": "Controleren",
     "description": "Leg vast wat er binnenkwam en kies waar het komt te staan.",
     "description": "Leg vast wat er binnenkwam en kies waar het komt te staan.",
     "customer": "Klant",
     "customer": "Klant",
     "selectCustomer": "Klant kiezen",
     "selectCustomer": "Klant kiezen",
@@ -358,6 +363,7 @@
     "quantity": "Aantal",
     "quantity": "Aantal",
     "noStockItem": "Geen voorraadartikel, prijs handmatig",
     "noStockItem": "Geen voorraadartikel, prijs handmatig",
     "storeTires": "Banden opslaan",
     "storeTires": "Banden opslaan",
+    "noneStored": "Geen banden opgeslagen vanuit deze opdracht",
     "storage": "Opslag",
     "storage": "Opslag",
     "storageFee": "Kosten",
     "storageFee": "Kosten",
     "storagePeriod": "Periode",
     "storagePeriod": "Periode",

+ 2 - 2
messages/pl/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Jednostka / Godziny",
     "qtyOrHours": "Jednostka / Godziny",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
-    "switchToServiceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
+    "hourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
+    "serviceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
     "deleteRow": "Usuń wiersz",
     "deleteRow": "Usuń wiersz",
     "excludeFromTotal": "Wyłącz z sumy"
     "excludeFromTotal": "Wyłącz z sumy"
   },
   },

+ 2 - 4
messages/pl/service.json

@@ -103,9 +103,7 @@
     "design": "Projekt faktury",
     "design": "Projekt faktury",
     "designDefault": "Domyślny warsztatu",
     "designDefault": "Domyślny warsztatu",
     "designFollowing": "Domyślny ({name})",
     "designFollowing": "Domyślny ({name})",
-    "designPinned": "Ta faktura trafiła do klienta {date}, więc zachowuje projekt, z którym wyszła. Wybór tutaj zadziała dopiero po odblokowaniu i ponownej wysyłce. Właściciel może zmienić projekt teraz przez Projekt faktury w menu ⋮ u góry.",
     "designSaved": "Projekt faktury zaktualizowany",
     "designSaved": "Projekt faktury zaktualizowany",
-    "designSavedPinned": "Zapisano. Ta faktura zachowuje projekt, z którym została wysłana, dopóki nie zostanie odblokowana i wysłana ponownie.",
     "designFollowingRule": "Domyślny ({name}, {rule})",
     "designFollowingRule": "Domyślny ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Sprzedaż samych części"
       "parts_sale": "Sprzedaż samych części"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Jednostka / Godziny",
     "qtyOrHours": "Jednostka / Godziny",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
-    "switchToServiceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
+    "hourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
+    "serviceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
     "deleteRow": "Usuń wiersz",
     "deleteRow": "Usuń wiersz",
     "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane."
     "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane."
   },
   },

+ 6 - 0
messages/pl/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Przyjmij opony",
     "title": "Przyjmij opony",
+    "sectionOwner": "Klient i pojazd",
+    "back": "Wstecz",
+    "next": "Dalej",
+    "stepStorage": "Półka i przygotowanie",
+    "stepReview": "Podsumowanie",
     "description": "Zapisz, co przyjechało, i wybierz, gdzie ma stać.",
     "description": "Zapisz, co przyjechało, i wybierz, gdzie ma stać.",
     "customer": "Klient",
     "customer": "Klient",
     "selectCustomer": "Wybierz klienta",
     "selectCustomer": "Wybierz klienta",
@@ -358,6 +363,7 @@
     "quantity": "Ilość",
     "quantity": "Ilość",
     "noStockItem": "Brak pozycji magazynowej, cena ręczna",
     "noStockItem": "Brak pozycji magazynowej, cena ręczna",
     "storeTires": "Przechowaj opony",
     "storeTires": "Przechowaj opony",
+    "noneStored": "Brak opon przechowywanych z tego zlecenia",
     "storage": "Przechowywanie",
     "storage": "Przechowywanie",
     "storageFee": "Opłata",
     "storageFee": "Opłata",
     "storagePeriod": "Okres",
     "storagePeriod": "Okres",

+ 2 - 2
messages/pt-BR/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Unidade / Horas",
     "qtyOrHours": "Unidade / Horas",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
-    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
+    "hourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
+    "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Excluir linha",
     "deleteRow": "Excluir linha",
     "excludeFromTotal": "Não somar ao total"
     "excludeFromTotal": "Não somar ao total"
   },
   },

+ 2 - 4
messages/pt-BR/service.json

@@ -103,9 +103,7 @@
     "design": "Design da fatura",
     "design": "Design da fatura",
     "designDefault": "Padrão da oficina",
     "designDefault": "Padrão da oficina",
     "designFollowing": "Padrão ({name})",
     "designFollowing": "Padrão ({name})",
-    "designPinned": "Esta fatura chegou ao cliente em {date}, então mantém o design com que saiu. Uma escolha aqui só vale depois de destravar e enviar de novo. Um proprietário pode mudar o design agora em Design da fatura no menu ⋮ no topo.",
     "designSaved": "Design da fatura atualizado",
     "designSaved": "Design da fatura atualizado",
-    "designSavedPinned": "Salvo. Esta fatura mantém o design com que foi enviada até ser destravada e enviada de novo.",
     "designFollowingRule": "Padrão ({name}, {rule})",
     "designFollowingRule": "Padrão ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Venda apenas de peças"
       "parts_sale": "Venda apenas de peças"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Unidade / Horas",
     "qtyOrHours": "Unidade / Horas",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
-    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
+    "hourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
+    "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Excluir linha",
     "deleteRow": "Excluir linha",
     "descriptionMissingHint": "Linhas sem descrição não são salvas."
     "descriptionMissingHint": "Linhas sem descrição não são salvas."
   },
   },

+ 6 - 0
messages/pt-BR/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Receber pneus",
     "title": "Receber pneus",
+    "sectionOwner": "Cliente e veículo",
+    "back": "Voltar",
+    "next": "Próximo",
+    "stepStorage": "Prateleira e preparação",
+    "stepReview": "Revisar",
     "description": "Registre o que chegou e escolha onde guardar.",
     "description": "Registre o que chegou e escolha onde guardar.",
     "customer": "Cliente",
     "customer": "Cliente",
     "selectCustomer": "Selecionar cliente",
     "selectCustomer": "Selecionar cliente",
@@ -358,6 +363,7 @@
     "quantity": "Qtd",
     "quantity": "Qtd",
     "noStockItem": "Sem item de estoque, preço manual",
     "noStockItem": "Sem item de estoque, preço manual",
     "storeTires": "Guardar pneus",
     "storeTires": "Guardar pneus",
+    "noneStored": "Nenhum pneu armazenado deste serviço",
     "storage": "Guarda",
     "storage": "Guarda",
     "storageFee": "Valor",
     "storageFee": "Valor",
     "storagePeriod": "Período",
     "storagePeriod": "Período",

+ 2 - 2
messages/pt-PT/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Unidades / Horas",
     "qtyOrHours": "Unidades / Horas",
     "hourlyTag": "HORA",
     "hourlyTag": "HORA",
     "serviceTag": "SERV",
     "serviceTag": "SERV",
-    "switchToHourlyHint": "À hora: calculado pelas horas trabalhadas (horas × preço/hora). Clique para alterar.",
-    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
+    "hourlyHint": "À hora: calculado pelas horas trabalhadas (horas × preço/hora). Clique para alterar.",
+    "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Eliminar linha",
     "deleteRow": "Eliminar linha",
     "excludeFromTotal": "Não somar ao total"
     "excludeFromTotal": "Não somar ao total"
   },
   },

+ 2 - 4
messages/pt-PT/service.json

@@ -103,9 +103,7 @@
     "design": "Design da fatura",
     "design": "Design da fatura",
     "designDefault": "Predefinido da oficina",
     "designDefault": "Predefinido da oficina",
     "designFollowing": "Predefinido ({name})",
     "designFollowing": "Predefinido ({name})",
-    "designPinned": "Esta fatura chegou ao cliente em {date}, por isso mantém o design com que foi enviada. Uma escolha aqui só se aplica depois de ser desbloqueada e enviada de novo. Um proprietário pode mudar o design agora em Design da fatura, no menu ⋮ no topo.",
     "designSaved": "Design da fatura atualizado",
     "designSaved": "Design da fatura atualizado",
-    "designSavedPinned": "Guardado. Esta fatura mantém o design com que foi enviada até ser desbloqueada e enviada de novo.",
     "designFollowingRule": "Predefinido ({name}, {rule})",
     "designFollowingRule": "Predefinido ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Venda só de peças"
       "parts_sale": "Venda só de peças"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Unidade / Horas",
     "qtyOrHours": "Unidade / Horas",
     "hourlyTag": "H",
     "hourlyTag": "H",
     "serviceTag": "SERV",
     "serviceTag": "SERV",
-    "switchToHourlyHint": "À hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
-    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
+    "hourlyHint": "À hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
+    "serviceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Eliminar linha",
     "deleteRow": "Eliminar linha",
     "descriptionMissingHint": "As linhas sem descrição não são guardadas."
     "descriptionMissingHint": "As linhas sem descrição não são guardadas."
   },
   },

+ 6 - 0
messages/pt-PT/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Receber pneus",
     "title": "Receber pneus",
+    "sectionOwner": "Cliente e viatura",
+    "back": "Voltar",
+    "next": "Seguinte",
+    "stepStorage": "Prateleira e preparação",
+    "stepReview": "Rever",
     "description": "Registe o que chegou e escolha onde guardar.",
     "description": "Registe o que chegou e escolha onde guardar.",
     "customer": "Cliente",
     "customer": "Cliente",
     "selectCustomer": "Selecionar cliente",
     "selectCustomer": "Selecionar cliente",
@@ -358,6 +363,7 @@
     "quantity": "Qtd.",
     "quantity": "Qtd.",
     "noStockItem": "Sem artigo de stock, preço manual",
     "noStockItem": "Sem artigo de stock, preço manual",
     "storeTires": "Guardar pneus",
     "storeTires": "Guardar pneus",
+    "noneStored": "Nenhum pneu armazenado deste serviço",
     "storage": "Armazenamento",
     "storage": "Armazenamento",
     "storageFee": "Valor",
     "storageFee": "Valor",
     "storagePeriod": "Período",
     "storagePeriod": "Período",

+ 2 - 2
messages/ru/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Услуга / Часы",
     "qtyOrHours": "Услуга / Часы",
     "hourlyTag": "н/ч",
     "hourlyTag": "н/ч",
     "serviceTag": "усл.",
     "serviceTag": "усл.",
-    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
-    "switchToServiceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
+    "hourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
+    "serviceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
     "deleteRow": "Удалить строку",
     "deleteRow": "Удалить строку",
     "excludeFromTotal": "Не включать в итог"
     "excludeFromTotal": "Не включать в итог"
   },
   },

+ 2 - 4
messages/ru/service.json

@@ -103,9 +103,7 @@
     "design": "Дизайн счёта",
     "design": "Дизайн счёта",
     "designDefault": "По умолчанию для мастерской",
     "designDefault": "По умолчанию для мастерской",
     "designFollowing": "По умолчанию ({name})",
     "designFollowing": "По умолчанию ({name})",
-    "designPinned": "Этот счёт дошёл до клиента {date}, поэтому сохраняет дизайн, с которым ушёл. Выбор здесь сработает только после разблокировки и повторной отправки. Владелец может изменить дизайн сейчас через Дизайн счёта в меню ⋮ наверху.",
     "designSaved": "Дизайн счёта обновлён",
     "designSaved": "Дизайн счёта обновлён",
-    "designSavedPinned": "Сохранено. Этот счёт сохранит дизайн, с которым был отправлен, пока его не разблокируют и не отправят снова.",
     "designFollowingRule": "По умолчанию ({name}, {rule})",
     "designFollowingRule": "По умолчанию ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Продажа только запчастей"
       "parts_sale": "Продажа только запчастей"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Услуга / Часы",
     "qtyOrHours": "Услуга / Часы",
     "hourlyTag": "н/ч",
     "hourlyTag": "н/ч",
     "serviceTag": "усл.",
     "serviceTag": "усл.",
-    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
-    "switchToServiceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
+    "hourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
+    "serviceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
     "deleteRow": "Удалить строку",
     "deleteRow": "Удалить строку",
     "descriptionMissingHint": "Строки без описания не сохраняются."
     "descriptionMissingHint": "Строки без описания не сохраняются."
   },
   },

+ 6 - 0
messages/ru/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Принять шины",
     "title": "Принять шины",
+    "sectionOwner": "Клиент и автомобиль",
+    "back": "Назад",
+    "next": "Далее",
+    "stepStorage": "Полка и подготовка",
+    "stepReview": "Проверка",
     "description": "Запишите, что поступило, и выберите место хранения.",
     "description": "Запишите, что поступило, и выберите место хранения.",
     "customer": "Клиент",
     "customer": "Клиент",
     "selectCustomer": "Выбрать клиента",
     "selectCustomer": "Выбрать клиента",
@@ -358,6 +363,7 @@
     "quantity": "Кол-во",
     "quantity": "Кол-во",
     "noStockItem": "Нет складской позиции, цена вручную",
     "noStockItem": "Нет складской позиции, цена вручную",
     "storeTires": "Сдать шины на хранение",
     "storeTires": "Сдать шины на хранение",
+    "noneStored": "По этому заказу шины не приняты на хранение",
     "storage": "Хранение",
     "storage": "Хранение",
     "storageFee": "Сумма",
     "storageFee": "Сумма",
     "storagePeriod": "Период",
     "storagePeriod": "Период",

+ 2 - 2
messages/tr/quotes.json

@@ -96,8 +96,8 @@
     "qtyOrHours": "Birim / Saat",
     "qtyOrHours": "Birim / Saat",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
-    "switchToServiceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
+    "hourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
+    "serviceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
     "deleteRow": "Satırı sil",
     "deleteRow": "Satırı sil",
     "excludeFromTotal": "Toplama dahil etme"
     "excludeFromTotal": "Toplama dahil etme"
   },
   },

+ 2 - 4
messages/tr/service.json

@@ -103,9 +103,7 @@
     "design": "Fatura tasarımı",
     "design": "Fatura tasarımı",
     "designDefault": "Atölye varsayılanı",
     "designDefault": "Atölye varsayılanı",
     "designFollowing": "Varsayılan ({name})",
     "designFollowing": "Varsayılan ({name})",
-    "designPinned": "Bu fatura müşteriye {date} tarihinde ulaştı, bu yüzden çıktığı tasarımı korur. Buradaki seçim ancak kilidi açılıp yeniden gönderildikten sonra geçerli olur. Bir sahip tasarımı şimdi üstteki ⋮ menüsündeki Fatura tasarımı ile değiştirebilir.",
     "designSaved": "Fatura tasarımı güncellendi",
     "designSaved": "Fatura tasarımı güncellendi",
-    "designSavedPinned": "Kaydedildi. Bu fatura, kilidi açılıp yeniden gönderilene kadar gönderildiği tasarımı korur.",
     "designFollowingRule": "Varsayılan ({name}, {rule})",
     "designFollowingRule": "Varsayılan ({name}, {rule})",
     "designRule": {
     "designRule": {
       "parts_sale": "Yalnızca parça satışı"
       "parts_sale": "Yalnızca parça satışı"
@@ -160,8 +158,8 @@
     "qtyOrHours": "Birim / Saat",
     "qtyOrHours": "Birim / Saat",
     "hourlyTag": "HR",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "serviceTag": "SVC",
-    "switchToHourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
-    "switchToServiceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
+    "hourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
+    "serviceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
     "deleteRow": "Satırı sil",
     "deleteRow": "Satırı sil",
     "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez."
     "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez."
   },
   },

+ 6 - 0
messages/tr/tireHotel.json

@@ -72,6 +72,11 @@
   },
   },
   "checkIn": {
   "checkIn": {
     "title": "Lastik kabul et",
     "title": "Lastik kabul et",
+    "sectionOwner": "Müşteri ve araç",
+    "back": "Geri",
+    "next": "İleri",
+    "stepStorage": "Raf ve hazırlık",
+    "stepReview": "Gözden geçir",
     "description": "Gelenleri kaydedin, sonra nereye konacağını seçin.",
     "description": "Gelenleri kaydedin, sonra nereye konacağını seçin.",
     "customer": "Müşteri",
     "customer": "Müşteri",
     "selectCustomer": "Müşteri seç",
     "selectCustomer": "Müşteri seç",
@@ -358,6 +363,7 @@
     "quantity": "Adet",
     "quantity": "Adet",
     "noStockItem": "Stok kalemi yok, fiyat elle girilir",
     "noStockItem": "Stok kalemi yok, fiyat elle girilir",
     "storeTires": "Lastikleri depola",
     "storeTires": "Lastikleri depola",
+    "noneStored": "Bu işten depolanan lastik yok",
     "storage": "Depolama",
     "storage": "Depolama",
     "storageFee": "Ücret",
     "storageFee": "Ücret",
     "storagePeriod": "Dönem",
     "storagePeriod": "Dönem",

+ 81 - 0
src/__tests__/features/invoices/set-invoice-design.test.ts

@@ -0,0 +1,81 @@
+/**
+ * Choosing a design on an invoice has to show on the sheet.
+ *
+ * Sharing a link issues the invoice, and an issued invoice prints from the
+ * look it was frozen with. The picker used to save the choice and leave that
+ * frozen look alone, so on an invoice that still took new labor lines a
+ * design pick did nothing anyone could see. The pick now moves the frozen
+ * look too, and a locked invoice refuses it like any other edit.
+ */
+
+import { it, expect, vi, beforeEach } from 'vitest'
+
+const serviceRecord = { findFirst: vi.fn(), update: vi.fn() }
+const documentDesign = { findFirst: vi.fn() }
+vi.mock('@/lib/db', () => ({ db: { serviceRecord, documentDesign } }))
+
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (fn: (ctx: { organizationId: string; isAdmin: boolean }) => Promise<unknown>) => {
+    try {
+      return { success: true, data: await fn({ organizationId: 'org-1', isAdmin: false }) }
+    } catch (error) {
+      return { success: false, error: (error as Error).message }
+    }
+  },
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+
+const assertInvoiceEditable = vi.fn()
+vi.mock('@/lib/document-lock.server', () => ({
+  assertInvoiceEditable: (...args: unknown[]) => assertInvoiceEditable(...args),
+}))
+
+const reapplyDesign = vi.fn()
+vi.mock('@/features/invoices/Lib/reapplyDesign', () => ({
+  ISSUED_WHERE: vi.fn(),
+  issuedDesignState: vi.fn(),
+  reapplyDesign: (...args: unknown[]) => reapplyDesign(...args),
+}))
+
+const { setInvoiceDesign } = await import('@/features/invoices/Actions/invoiceDesignActions')
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  assertInvoiceEditable.mockResolvedValue(undefined)
+  serviceRecord.findFirst.mockResolvedValue({ id: 'rec-1', vehicleId: 'veh-1' })
+  serviceRecord.update.mockResolvedValue({})
+  documentDesign.findFirst.mockResolvedValue({ id: 'design-2' })
+  reapplyDesign.mockResolvedValue(1)
+})
+
+it('moves the frozen look to the chosen design, after saving the choice', async () => {
+  const result = await setInvoiceDesign('rec-1', 'design-2')
+
+  expect(result.success).toBe(true)
+  expect(reapplyDesign).toHaveBeenCalledWith('org-1', ['rec-1'], 'design-2')
+  // Following the default is resolved from the saved choice, so the write
+  // has to land first.
+  expect(serviceRecord.update.mock.invocationCallOrder[0]).toBeLessThan(
+    reapplyDesign.mock.invocationCallOrder[0] as number
+  )
+})
+
+it('goes back to the default look when the choice is cleared', async () => {
+  await setInvoiceDesign('rec-1', null)
+
+  expect(serviceRecord.update).toHaveBeenCalledWith({
+    where: { id: 'rec-1' },
+    data: { designId: null },
+  })
+  expect(reapplyDesign).toHaveBeenCalledWith('org-1', ['rec-1'], null)
+})
+
+it('leaves a locked invoice alone', async () => {
+  assertInvoiceEditable.mockRejectedValue(new Error('Invoice is locked'))
+
+  const result = await setInvoiceDesign('rec-1', 'design-2')
+
+  expect(result.success).toBe(false)
+  expect(serviceRecord.update).not.toHaveBeenCalled()
+  expect(reapplyDesign).not.toHaveBeenCalled()
+})

+ 106 - 0
src/__tests__/features/tire-hotel/check-in-job-lines.test.ts

@@ -0,0 +1,106 @@
+/**
+ * What storing tires from a work order puts on that work order.
+ *
+ * Check-in from a job used to link the set and charge nothing, so the storage
+ * fee only reached a bill if somebody remembered to go to the set's page and
+ * raise it from there. The dialog now offers the charge in the same save.
+ * These pin the rules that make that safe: prep is priced from settings and
+ * never from the form, work nobody asked for cannot be billed, and nothing is
+ * written when there is nothing to charge.
+ */
+
+import { it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: { appSetting: { findUnique: vi.fn() } } }))
+vi.mock('@/features/tire-hotel/Lib/serverMessages', () => ({
+  invoiceLineWords: vi.fn(async () => ({
+    storage: 'Tire storage',
+    pieces: 'pcs',
+    fromDate: 'from {date}',
+  })),
+  treatmentNames: vi.fn(async () => ({ wash_tires: 'Wash tires', balance: 'Balancing' })),
+}))
+vi.mock('@/features/vehicles/Lib/retotalServiceRecord', () => ({
+  retotalServiceRecord: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
+import { addLinesToJob, checkInJobLines } from '@/features/tire-hotel/Lib/jobLines'
+
+const ORG = 'org-1'
+const SET = { size: '225/45R17', quantity: 4 }
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue({
+    value: JSON.stringify({ wash_tires: 150, balance: 400 }),
+  } as never)
+})
+
+it('adds nothing when the desk asked for nothing', async () => {
+  expect(await checkInJobLines(ORG, SET, ['wash_tires'], null)).toEqual([])
+  expect(await checkInJobLines(ORG, SET, ['wash_tires'], {})).toEqual([])
+})
+
+it('prices prep from settings, as flat service lines', async () => {
+  const lines = await checkInJobLines(ORG, SET, ['wash_tires', 'balance'], {
+    treatments: ['wash_tires', 'balance'],
+  })
+
+  expect(lines).toEqual([
+    { description: 'Wash tires', hours: 1, rate: 150, total: 150, pricingType: 'service' },
+    { description: 'Balancing', hours: 1, rate: 400, total: 400, pricingType: 'service' },
+  ])
+})
+
+it('bills only the prep that was ticked', async () => {
+  const lines = await checkInJobLines(ORG, SET, ['wash_tires', 'balance'], {
+    treatments: ['balance'],
+  })
+
+  expect(lines.map((line) => line.description)).toEqual(['Balancing'])
+})
+
+it('will not bill work that was never asked for on the set', async () => {
+  const lines = await checkInJobLines(ORG, SET, ['wash_tires'], { treatments: ['balance'] })
+
+  expect(lines).toEqual([])
+})
+
+it('leaves prep the shop has not priced off the job', async () => {
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: '{}' } as never)
+
+  const lines = await checkInJobLines(ORG, SET, ['wash_tires'], { treatments: ['wash_tires'] })
+
+  expect(lines).toEqual([])
+})
+
+it('charges storage at the figure the desk settled on, open-ended from today', async () => {
+  const [line] = await checkInJobLines(ORG, SET, [], { storageAmount: 1200 })
+  const today = new Date().toISOString().slice(0, 10)
+
+  expect(line).toMatchObject({ rate: 1200, total: 1200, hours: 1, pricingType: 'service' })
+  expect(line?.description).toBe(`Tire storage · 225/45R17 · 4 pcs · from ${today}`)
+})
+
+it('writes the lines and retotals the job in the same transaction', async () => {
+  const tx = { serviceLabor: { createMany: vi.fn() } }
+  const lines = await checkInJobLines(ORG, SET, [], { storageAmount: 1200 })
+
+  await addLinesToJob(tx as never, 'rec-1', lines)
+
+  expect(tx.serviceLabor.createMany).toHaveBeenCalledWith({
+    data: [expect.objectContaining({ serviceRecordId: 'rec-1', total: 1200 })],
+  })
+  expect(retotalServiceRecord).toHaveBeenCalledWith('rec-1', tx)
+})
+
+it('touches nothing on the job when there are no lines', async () => {
+  const tx = { serviceLabor: { createMany: vi.fn() } }
+
+  await addLinesToJob(tx as never, 'rec-1', [])
+
+  expect(tx.serviceLabor.createMany).not.toHaveBeenCalled()
+  expect(retotalServiceRecord).not.toHaveBeenCalled()
+})

+ 1 - 1
src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx

@@ -111,7 +111,7 @@ function RecipientPicker({
             <ChevronsUpDown className="ml-2 h-3.5 w-3.5 opacity-50" />
             <ChevronsUpDown className="ml-2 h-3.5 w-3.5 opacity-50" />
           </Button>
           </Button>
         </PopoverTrigger>
         </PopoverTrigger>
-        <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+        <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
           <Command>
           <Command>
             <CommandInput placeholder={t('searchMembers')} />
             <CommandInput placeholder={t('searchMembers')} />
             <CommandList>
             <CommandList>

+ 1 - 1
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -231,7 +231,7 @@ export function WorkshopSettings({
                     <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                     <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                   </Button>
                   </Button>
                 </PopoverTrigger>
                 </PopoverTrigger>
-                <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
                   <Command shouldFilter={true}>
                   <Command shouldFilter={true}>
                     <CommandInput
                     <CommandInput
                       placeholder={t('workshop.technicianPlaceholder')}
                       placeholder={t('workshop.technicianPlaceholder')}

+ 2 - 2
src/components/app-sidebar.tsx

@@ -490,7 +490,7 @@ export function AppSidebar({
                 </SidebarMenuButton>
                 </SidebarMenuButton>
               </DropdownMenuTrigger>
               </DropdownMenuTrigger>
               <DropdownMenuContent
               <DropdownMenuContent
-                className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
+                className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                 side="bottom"
                 side="bottom"
                 align="start"
                 align="start"
                 sideOffset={4}
                 sideOffset={4}
@@ -647,7 +647,7 @@ export function AppSidebar({
                 </SidebarMenuButton>
                 </SidebarMenuButton>
               </DropdownMenuTrigger>
               </DropdownMenuTrigger>
               <DropdownMenuContent
               <DropdownMenuContent
-                className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
+                className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                 side="top"
                 side="top"
                 align="start"
                 align="start"
                 sideOffset={4}
                 sideOffset={4}

+ 1 - 1
src/features/inspections/Components/NewInspectionDialog.tsx

@@ -152,7 +152,7 @@ export function NewInspectionDialog({
                     <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                     <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                   </Button>
                   </Button>
                 </PopoverTrigger>
                 </PopoverTrigger>
-                <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
                   <Command>
                   <Command>
                     <CommandInput placeholder={t('searchVehicles')} />
                     <CommandInput placeholder={t('searchVehicles')} />
                     <CommandList>
                     <CommandList>

+ 1 - 1
src/features/inventory/Components/InventoryPartForm.tsx

@@ -664,7 +664,7 @@ export function InventoryPartForm({
                         <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
                         <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
                       </Button>
                       </Button>
                     </PopoverTrigger>
                     </PopoverTrigger>
-                    <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                    <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
                       <Command>
                       <Command>
                         <CommandInput
                         <CommandInput
                           placeholder={t('form.categorySearch')}
                           placeholder={t('form.categorySearch')}

+ 1 - 1
src/features/inventory/Components/UnitCombobox.tsx

@@ -90,7 +90,7 @@ export function UnitCombobox({
           <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
           <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
         </Button>
         </Button>
       </PopoverTrigger>
       </PopoverTrigger>
-      <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+      <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
         <Command>
         <Command>
           <CommandInput
           <CommandInput
             placeholder={t('form.unitSearch')}
             placeholder={t('form.unitSearch')}

+ 9 - 0
src/features/invoices/Actions/invoiceDesignActions.ts

@@ -11,6 +11,12 @@ import { ISSUED_WHERE, issuedDesignState, reapplyDesign } from '../Lib/reapplyDe
  * Chooses which design one invoice prints with. Null goes back to following
  * Chooses which design one invoice prints with. Null goes back to following
  * the customer's design and the workshop's default. Refused on a locked
  * the customer's design and the workshop's default. Refused on a locked
  * invoice like any other edit: what a locked invoice prints is settled.
  * invoice like any other edit: what a locked invoice prints is settled.
+ *
+ * An invoice that was shared or sent but is still open to edits takes the
+ * choice at once, the way it takes a new labor line. Its frozen look is moved
+ * to the chosen design; the workshop and customer details it was issued with
+ * stay as they were. Saving the choice and leaving the sheet unchanged made
+ * the picker look broken on every invoice whose link had been shared.
  */
  */
 export async function setInvoiceDesign(recordId: string, designId: string | null) {
 export async function setInvoiceDesign(recordId: string, designId: string | null) {
   return withAuth(
   return withAuth(
@@ -34,6 +40,9 @@ export async function setInvoiceDesign(recordId: string, designId: string | null
         where: { id: recordId },
         where: { id: recordId },
         data: { designId: designId || null },
         data: { designId: designId || null },
       })
       })
+      // After the write: following the default is resolved from the record's
+      // own choice. A draft is skipped, it prints from the live design already.
+      await reapplyDesign(organizationId, [recordId], designId || null)
 
 
       revalidatePath(
       revalidatePath(
         record.vehicleId
         record.vehicleId

+ 8 - 7
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -18,8 +18,8 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
   tDescriptionPlaceholder,
   tDescriptionPlaceholder,
   tHourlyTag,
   tHourlyTag,
   tServiceTag,
   tServiceTag,
-  tSwitchToHourly,
-  tSwitchToService,
+  tHourlyHint,
+  tServiceHint,
   tQty,
   tQty,
   tHours,
   tHours,
   tDeleteRow,
   tDeleteRow,
@@ -33,8 +33,9 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
   tDescriptionPlaceholder: string
   tDescriptionPlaceholder: string
   tHourlyTag: string
   tHourlyTag: string
   tServiceTag: string
   tServiceTag: string
-  tSwitchToHourly: string
-  tSwitchToService: string
+  /** Each describes its own mode and says a click switches; shown on that mode's tag. */
+  tHourlyHint: string
+  tServiceHint: string
   tQty: string
   tQty: string
   tHours: string
   tHours: string
   tDeleteRow: string
   tDeleteRow: string
@@ -62,7 +63,7 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
               : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
               : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
           }`}
           }`}
           onClick={() => onUpdate(index, 'pricingType', isService ? 'hourly' : 'service')}
           onClick={() => onUpdate(index, 'pricingType', isService ? 'hourly' : 'service')}
-          title={isService ? tSwitchToHourly : tSwitchToService}
+          title={isService ? tServiceHint : tHourlyHint}
         >
         >
           {isService ? tServiceTag : tHourlyTag}
           {isService ? tServiceTag : tHourlyTag}
         </button>
         </button>
@@ -185,8 +186,8 @@ export const QuoteLaborEditor = memo(function QuoteLaborEditor({
                 tDescriptionPlaceholder={t('labor.descriptionPlaceholder')}
                 tDescriptionPlaceholder={t('labor.descriptionPlaceholder')}
                 tHourlyTag={t('labor.hourlyTag')}
                 tHourlyTag={t('labor.hourlyTag')}
                 tServiceTag={t('labor.serviceTag')}
                 tServiceTag={t('labor.serviceTag')}
-                tSwitchToHourly={t('labor.switchToHourlyHint')}
-                tSwitchToService={t('labor.switchToServiceHint')}
+                tHourlyHint={t('labor.hourlyHint')}
+                tServiceHint={t('labor.serviceHint')}
                 tQty={t('labor.qty')}
                 tQty={t('labor.qty')}
                 tHours={t('labor.hours')}
                 tHours={t('labor.hours')}
                 tDeleteRow={t('labor.deleteRow')}
                 tDeleteRow={t('labor.deleteRow')}

+ 1 - 1
src/features/team/Components/CountryPicker.tsx

@@ -69,7 +69,7 @@ export function CountryPicker({
       </PopoverTrigger>
       </PopoverTrigger>
 
 
       {/* Fixed height, so the list does not grow and shrink as it scrolls. */}
       {/* Fixed height, so the list does not grow and shrink as it scrolls. */}
-      <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+      <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
         <Command>
         <Command>
           <CommandInput placeholder={t('team.countrySearch')} />
           <CommandInput placeholder={t('team.countrySearch')} />
           <CommandList className="max-h-64">
           <CommandList className="max-h-64">

+ 3 - 85
src/features/tire-hotel/Actions/tireJobActions.ts

@@ -24,9 +24,9 @@ import { addTireLineToRecord } from '../Lib/addTireLine'
 import { copySetFilesToJob } from '../Lib/copySetFilesToJob'
 import { copySetFilesToJob } from '../Lib/copySetFilesToJob'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { matchStock, parseTireSize, formatTireSize, sizesMatch } from '../Lib/tireMatching'
 import { matchStock, parseTireSize, formatTireSize, sizesMatch } from '../Lib/tireMatching'
-import { TREATMENT_TYPES, billableTreatments, parseTreatmentPrices } from '../Lib/treatments'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { invoiceLineWords, jobNoteWords, seasonNames, treatmentNames } from '../Lib/serverMessages'
+import { TREATMENT_TYPES } from '../Lib/treatments'
+import { billablePrep, storageLine, treatmentLines } from '../Lib/jobLines'
+import { jobNoteWords, seasonNames } from '../Lib/serverMessages'
 import { isTireHotelEnabled, requireTireHotel } from '../Lib/tireHotelSettings'
 import { isTireHotelEnabled, requireTireHotel } from '../Lib/tireHotelSettings'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { releaseFiles } from '@/lib/files/manager'
 import { releaseFiles } from '@/lib/files/manager'
@@ -189,88 +189,6 @@ export async function searchTireStock(input: unknown) {
   )
   )
 }
 }
 
 
-/**
- * Prep lines for a job, priced from settings.
- *
- * The work was agreed when the set was checked in, so it should reach the
- * bill without anyone retyping it. Only treatments the shop has put a price
- * against produce a line, which keeps washing off the invoice at shops that
- * fold it into the storage fee.
- */
-/**
- * Treatment names in the reader's language.
- *
- * Loaded here rather than passed in from the browser: this text ends up on an
- * invoice, and invoice wording should not be whatever a client happened to
- * send.
- */
-/**
- * The storage fee as a flat service line, in the workshop's language.
- *
- * Priced per job rather than by the hour, so it prints as one figure with the
- * period beside it. Nothing schedules this: it is charged when the tires are
- * billed, which is the moment somebody is looking at the account anyway.
- */
-async function storageLine(
-  set: { size: string | null; quantity: number },
-  data: { storageAmount?: number; storageFrom?: Date; storageTo?: Date }
-) {
-  const amount = Math.round((data.storageAmount ?? 0) * 100) / 100
-  const words = await invoiceLineWords()
-
-  // An open-ended period is the normal case: the shop knows when the tires
-  // arrived and not when they will be collected, and inventing an end date
-  // would print a promise on the invoice.
-  const from = data.storageFrom?.toISOString().slice(0, 10)
-  const to = data.storageTo?.toISOString().slice(0, 10)
-  const period = from ? (to ? `${from} - ${to}` : words.fromDate.replace('{date}', from)) : null
-
-  return {
-    description: [words.storage, set.size, `${set.quantity} ${words.pieces}`, period]
-      .filter(Boolean)
-      .join(' · '),
-    hours: 1,
-    rate: amount,
-    total: amount,
-    pricingType: 'service' as const,
-  }
-}
-
-async function billablePrep(
-  organizationId: string,
-  treatments: { type: string; status: string }[]
-) {
-  const setting = await db.appSetting.findUnique({
-    where: {
-      organizationId_key: { organizationId, key: SETTING_KEYS.TIRE_HOTEL_TREATMENT_PRICES },
-    },
-    select: { value: true },
-  })
-  return billableTreatments(treatments, parseTreatmentPrices(setting?.value))
-}
-
-async function treatmentLines(
-  organizationId: string,
-  treatments: { type: string; status: string }[],
-  only?: string[]
-) {
-  const names = await treatmentNames()
-  const billable = await billablePrep(organizationId, treatments)
-  const wanted = only ? new Set(only) : null
-
-  return billable
-    .filter((line) => !wanted || wanted.has(line.type))
-    .map((line) => ({
-      description: names[line.type] ?? line.type,
-      // A flat service line, not hours: prep is priced per job, and an hourly
-      // line would invite someone to multiply it by a duration nobody tracked.
-      hours: 1,
-      rate: line.price,
-      total: line.price,
-      pricingType: 'service' as const,
-    }))
-}
-
 /** Human label for the tires, used as the quote line and the job title. */
 /** Human label for the tires, used as the quote line and the job title. */
 /**
 /**
  * What to call a set on a quote, a work order or an invoice line.
  * What to call a set on a quote, a work order or an invoice line.

+ 85 - 0
src/features/tire-hotel/Actions/tireSetActions.ts

@@ -15,6 +15,10 @@ import {
 } from '../Schema/tireHotelSchema'
 } from '../Schema/tireHotelSchema'
 import type { MeasurementInput } from '../Schema/tireHotelSchema'
 import type { MeasurementInput } from '../Schema/tireHotelSchema'
 import { requireTireHotel } from '../Lib/tireHotelSettings'
 import { requireTireHotel } from '../Lib/tireHotelSettings'
+import { addLinesToJob, checkInJobLines } from '../Lib/jobLines'
+import { parseTreatmentPrices } from '../Lib/treatments'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { auditDetails } from '@/lib/audit'
 import { auditDetails } from '@/lib/audit'
 import { releaseFiles } from '@/lib/files/manager'
 import { releaseFiles } from '@/lib/files/manager'
 import { tireSetFileUrls } from '@/lib/files/collect'
 import { tireSetFileUrls } from '@/lib/files/collect'
@@ -45,6 +49,41 @@ async function nextReference(tx: TxClient, organizationId: string): Promise<stri
   return String(next)
   return String(next)
 }
 }
 
 
+/**
+ * The storage fee and prep a check-in asks to have put on its job.
+ *
+ * Refused outright on a locked invoice, before anything is stored: adding
+ * lines is an edit to what the invoice says is owed. The dialog does not
+ * offer billing there, so reaching this with a locked job is a stale page,
+ * and storing the tires while silently dropping the charge would be worse
+ * than asking the desk to look again.
+ */
+async function linesForJob(
+  organizationId: string,
+  data: {
+    serviceRecordId?: string | null
+    treatments?: string[]
+    billing?: { storageAmount?: number; treatments?: string[] } | null
+  },
+  set: { size: string | null; quantity: number }
+) {
+  if (!data.serviceRecordId || !data.billing) return []
+  const lines = await checkInJobLines(organizationId, set, data.treatments ?? [], data.billing)
+  if (lines.length === 0) return lines
+
+  // Storing tires is the tire hotel's permission; charging for it writes to a
+  // work order, which is its own. Asked through withAuth so the rules, and the
+  // audit row for a refusal, are the ones every other edit to a job gets.
+  const mayEditJob = await withAuth(async () => true, {
+    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }],
+  })
+  if (!mayEditJob.success) {
+    throw new Error('You do not have permission to add charges to this work order')
+  }
+  await assertInvoiceEditable(data.serviceRecordId, organizationId)
+  return lines
+}
+
 function measurementRows(measurements: MeasurementInput[] | undefined, userId: string) {
 function measurementRows(measurements: MeasurementInput[] | undefined, userId: string) {
   if (!measurements?.length) return undefined
   if (!measurements?.length) return undefined
   return measurements.map((m) => ({
   return measurements.map((m) => ({
@@ -272,6 +311,38 @@ export async function getTireSet(id: string) {
   )
   )
 }
 }
 
 
+/**
+ * The figures the check-in dialog shows when it is opened from a job: what
+ * storage costs and what each kind of prep costs. Shown so the desk can see
+ * what is about to land on the work order; the prep prices are read again on
+ * save rather than trusted from the form.
+ */
+export async function getCheckInPrices() {
+  return withAuth(
+    async ({ organizationId }) => {
+      await requireTireHotel(organizationId)
+      const rows = await db.appSetting.findMany({
+        where: {
+          organizationId,
+          key: {
+            in: [
+              SETTING_KEYS.TIRE_HOTEL_DEFAULT_SEASONAL_PRICE,
+              SETTING_KEYS.TIRE_HOTEL_TREATMENT_PRICES,
+            ],
+          },
+        },
+        select: { key: true, value: true },
+      })
+      const byKey = new Map(rows.map((row) => [row.key, row.value]))
+      return {
+        storagePrice: Number(byKey.get(SETTING_KEYS.TIRE_HOTEL_DEFAULT_SEASONAL_PRICE)) || 0,
+        treatmentPrices: parseTreatmentPrices(byKey.get(SETTING_KEYS.TIRE_HOTEL_TREATMENT_PRICES)),
+      }
+    },
+    { requiredPermissions: READ }
+  )
+}
+
 /**
 /**
  * Arrival. Creates the set, records the intake measurements, logs the
  * Arrival. Creates the set, records the intake measurements, logs the
  * movement and claims shelf space, all in one transaction, because a set
  * movement and claims shelf space, all in one transaction, because a set
@@ -283,6 +354,10 @@ export async function checkInTireSet(input: unknown) {
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
       await requireTireHotel(organizationId)
       await requireTireHotel(organizationId)
       const data = checkInSchema.parse(input)
       const data = checkInSchema.parse(input)
+      const jobLines = await linesForJob(organizationId, data, {
+        size: data.size || null,
+        quantity: data.quantity,
+      })
 
 
       const created = await db.$transaction(async (tx) => {
       const created = await db.$transaction(async (tx) => {
         const location = await assertRoom(tx, data.locationId, organizationId, data.quantity)
         const location = await assertRoom(tx, data.locationId, organizationId, data.quantity)
@@ -371,6 +446,7 @@ export async function checkInTireSet(input: unknown) {
               data: { tireSetId: set.id },
               data: { tireSetId: set.id },
             })
             })
           }
           }
+          if (record) await addLinesToJob(tx, record.id, jobLines)
         }
         }
 
 
         return { ...set, locationCode: location.code }
         return { ...set, locationCode: location.code }
@@ -530,6 +606,14 @@ export async function returnTireSet(input: unknown) {
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
       await requireTireHotel(organizationId)
       await requireTireHotel(organizationId)
       const data = returnSetSchema.parse(input)
       const data = returnSetSchema.parse(input)
+      const known = await db.tireSet.findFirst({
+        where: { id: data.id, organizationId },
+        select: { size: true, quantity: true },
+      })
+      const jobLines = await linesForJob(organizationId, data, {
+        size: known?.size ?? null,
+        quantity: data.quantity ?? known?.quantity ?? 0,
+      })
 
 
       const returned = await db.$transaction(async (tx) => {
       const returned = await db.$transaction(async (tx) => {
         const set = await tx.tireSet.findFirst({
         const set = await tx.tireSet.findFirst({
@@ -594,6 +678,7 @@ export async function returnTireSet(input: unknown) {
               data: { tireSetId: set.id },
               data: { tireSetId: set.id },
             })
             })
           }
           }
+          if (record) await addLinesToJob(tx, record.id, jobLines)
         }
         }
 
 
         const updated = await tx.tireSet.update({
         const updated = await tx.tireSet.update({

+ 652 - 276
src/features/tire-hotel/Components/CheckInDialog.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { useEffect, useState } from 'react'
+import { type ReactNode, useEffect, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
@@ -17,7 +17,8 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
 import { Textarea } from '@/components/ui/textarea'
 import { Switch } from '@/components/ui/switch'
 import { Switch } from '@/components/ui/switch'
-import { Separator } from '@/components/ui/separator'
+import { Checkbox } from '@/components/ui/checkbox'
+import { useFormatCurrency } from '@/components/currency-settings-context'
 import {
 import {
   Select,
   Select,
   SelectContent,
   SelectContent,
@@ -25,7 +26,22 @@ import {
   SelectTrigger,
   SelectTrigger,
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
-import { Check, Loader2, Warehouse } from 'lucide-react'
+import {
+  Car,
+  Check,
+  ChevronLeft,
+  ChevronRight,
+  CircleDot,
+  ClipboardCheck,
+  History,
+  Loader2,
+  Receipt,
+  Ruler,
+  Sparkles,
+  StickyNote,
+  Warehouse,
+  type LucideIcon,
+} from 'lucide-react'
 import { DocsLink } from '@/components/docs-link'
 import { DocsLink } from '@/components/docs-link'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatDate } from '@/lib/use-format-date'
@@ -33,20 +49,25 @@ import { LocationPicker, type PickerLocation } from './LocationPicker'
 import { OwnerVehicleFields, type VehicleOption } from './OwnerVehicleFields'
 import { OwnerVehicleFields, type VehicleOption } from './OwnerVehicleFields'
 import { TreadEntry, type TreadRow } from './TreadEntry'
 import { TreadEntry, type TreadRow } from './TreadEntry'
 import { TreatmentPicker } from './TreatmentPicker'
 import { TreatmentPicker } from './TreatmentPicker'
-import { defaultTreatments, type TreatmentType } from '../Lib/treatments'
+import { defaultTreatments, type TreatmentPrices, type TreatmentType } from '../Lib/treatments'
 import { checkInTireSet, getReturningSets, returnTireSet } from '../Actions/tireSetActions'
 import { checkInTireSet, getReturningSets, returnTireSet } from '../Actions/tireSetActions'
 import { groupRounds } from '../Lib/wear'
 import { groupRounds } from '../Lib/wear'
 import { TIRE_SEASONS, TIRE_ROAD_POSITIONS, thirtySecondsToMm } from '../Lib/tireConstants'
 import { TIRE_SEASONS, TIRE_ROAD_POSITIONS, thirtySecondsToMm } from '../Lib/tireConstants'
 
 
+/** The three steps, in order. Keys only; the labels are translated where they are drawn. */
+const STEPS = ['tires', 'storage', 'review'] as const
+
 type ReturningSet = NonNullable<Awaited<ReturnType<typeof getReturningSets>>['data']>[number]
 type ReturningSet = NonNullable<Awaited<ReturnType<typeof getReturningSets>>['data']>[number]
 
 
 /**
 /**
  * Arrival, in one pass.
  * Arrival, in one pass.
  *
  *
  * The order follows what actually happens at the counter: whose tires these
  * The order follows what actually happens at the counter: whose tires these
- * are, what they are, how many, then where they go. Capacity is answered
- * live as the quantity changes, so nobody fills in a whole form only to be
- * told at submit that the shelf is full.
+ * are and what they are, then where they go and what gets done to them, then
+ * a read-back with the charges before anything is saved. Three steps rather
+ * than one long form, because a form that scrolls hides the button that
+ * finishes it. Capacity is answered live as the quantity changes, so nobody
+ * gets to the end only to be told the shelf is full.
  */
  */
 export function CheckInDialog({
 export function CheckInDialog({
   open,
   open,
@@ -58,6 +79,7 @@ export function CheckInDialog({
   defaultQuantity = 4,
   defaultQuantity = 4,
   lockedVehicle,
   lockedVehicle,
   serviceRecordId,
   serviceRecordId,
+  billing,
   onCheckedIn,
   onCheckedIn,
 }: {
 }: {
   open: boolean
   open: boolean
@@ -80,6 +102,13 @@ export function CheckInDialog({
   lockedVehicle?: VehicleOption
   lockedVehicle?: VehicleOption
   /** The job these tires came in on. Linked to the new set on save. */
   /** The job these tires came in on. Linked to the new set on save. */
   serviceRecordId?: string
   serviceRecordId?: string
+  /**
+   * What the workshop charges, when the job can take the charge. Given, the
+   * dialog offers to put the storage fee and the priced prep on the job in the
+   * same save: the desk is looking at the bill right now, and a fee that has
+   * to be remembered later is a fee that does not get charged.
+   */
+  billing?: { storagePrice: number; treatmentPrices: TreatmentPrices }
   /**
   /**
    * Called instead of navigating to the new set. Check-in from the tire hotel
    * Called instead of navigating to the new set. Check-in from the tire hotel
    * goes straight to the labels; check-in from a job should not drag the desk
    * goes straight to the labels; check-in from a job should not drag the desk
@@ -90,7 +119,9 @@ export function CheckInDialog({
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('tireHotel')
   const t = useTranslations('tireHotel')
   const { formatDate } = useFormatDate()
   const { formatDate } = useFormatDate()
+  const formatCurrency = useFormatCurrency()
   const [saving, setSaving] = useState(false)
   const [saving, setSaving] = useState(false)
+  const [step, setStep] = useState(0)
 
 
   const [customerId, setCustomerId] = useState('')
   const [customerId, setCustomerId] = useState('')
   const [vehicleId, setVehicleId] = useState('')
   const [vehicleId, setVehicleId] = useState('')
@@ -105,7 +136,9 @@ export function CheckInDialog({
   const [model, setModel] = useState('')
   const [model, setModel] = useState('')
   const [size, setSize] = useState('')
   const [size, setSize] = useState('')
   const [dotCode, setDotCode] = useState('')
   const [dotCode, setDotCode] = useState('')
-  const [withRims, setWithRims] = useState(false)
+  // On by default: a stored set is nearly always a second set of wheels, and
+  // the odd loose set is one switch away. Washing the rims follows it.
+  const [withRims, setWithRims] = useState(true)
   const [hasTpms, setHasTpms] = useState(false)
   const [hasTpms, setHasTpms] = useState(false)
   const [quantity, setQuantity] = useState(String(defaultQuantity))
   const [quantity, setQuantity] = useState(String(defaultQuantity))
   const [locationId, setLocationId] = useState<string | null>(null)
   const [locationId, setLocationId] = useState<string | null>(null)
@@ -114,8 +147,13 @@ export function CheckInDialog({
     TIRE_ROAD_POSITIONS.map((position) => ({ position, tread: '', condition: 'good' }))
     TIRE_ROAD_POSITIONS.map((position) => ({ position, tread: '', condition: 'good' }))
   )
   )
   const [treatments, setTreatments] = useState<TreatmentType[]>(() =>
   const [treatments, setTreatments] = useState<TreatmentType[]>(() =>
-    defaultTreatments({ withRims: false })
+    defaultTreatments({ withRims: true })
   )
   )
+  const [billStorage, setBillStorage] = useState(false)
+  const [storageAmount, setStorageAmount] = useState('')
+  // Kept as the prep left off the bill rather than the prep on it, so work
+  // ticked after the dialog opened is charged without a second click.
+  const [unbilledPrep, setUnbilledPrep] = useState<TreatmentType[]>([])
 
 
   useEffect(() => {
   useEffect(() => {
     if (open) return
     if (open) return
@@ -127,18 +165,24 @@ export function CheckInDialog({
     setModel('')
     setModel('')
     setSize('')
     setSize('')
     setDotCode('')
     setDotCode('')
-    setWithRims(false)
+    setWithRims(true)
     setHasTpms(false)
     setHasTpms(false)
     setQuantity(String(defaultQuantity))
     setQuantity(String(defaultQuantity))
     setLocationId(null)
     setLocationId(null)
     setNotes('')
     setNotes('')
     setTreads(TIRE_ROAD_POSITIONS.map((position) => ({ position, tread: '', condition: 'good' })))
     setTreads(TIRE_ROAD_POSITIONS.map((position) => ({ position, tread: '', condition: 'good' })))
-    setTreatments(defaultTreatments({ withRims: false }))
+    setTreatments(defaultTreatments({ withRims: true }))
     setVehicleId(lockedVehicle?.id ?? '')
     setVehicleId(lockedVehicle?.id ?? '')
     setCustomerId(lockedVehicle?.customerId ?? '')
     setCustomerId(lockedVehicle?.customerId ?? '')
     setPreviousSets([])
     setPreviousSets([])
     setReturning(null)
     setReturning(null)
-  }, [open, defaultQuantity, lockedVehicle])
+    // A shop with a price wants it charged; one without has to opt in, since
+    // a zero line on the job is only something to delete.
+    setBillStorage((billing?.storagePrice ?? 0) > 0)
+    setStorageAmount(billing?.storagePrice ? String(billing.storagePrice) : '')
+    setUnbilledPrep([])
+    setStep(0)
+  }, [open, defaultQuantity, lockedVehicle, billing])
 
 
   // Look for their earlier sets as soon as there is somebody to look under.
   // Look for their earlier sets as soon as there is somebody to look under.
   useEffect(() => {
   useEffect(() => {
@@ -202,6 +246,24 @@ export function CheckInDialog({
 
 
   const qty = Math.max(1, Number(quantity) || 1)
   const qty = Math.max(1, Number(quantity) || 1)
 
 
+  // Only the prep somebody asked for and the shop has priced can be billed.
+  const pricedPrep = billing
+    ? treatments.flatMap((type) => {
+        const price = billing.treatmentPrices[type] ?? 0
+        return price > 0 ? [{ type, price }] : []
+      })
+    : []
+  const billedPrep = pricedPrep.filter((line) => !unbilledPrep.includes(line.type))
+  const storageFee = billStorage ? Math.max(0, Number(storageAmount) || 0) : 0
+  const billTotal = storageFee + billedPrep.reduce((sum, line) => sum + line.price, 0)
+  const jobBilling =
+    billing && serviceRecordId && (billStorage || billedPrep.length > 0)
+      ? {
+          ...(billStorage ? { storageAmount: storageFee } : {}),
+          treatments: billedPrep.map((line) => line.type),
+        }
+      : null
+
   const handleSubmit = async () => {
   const handleSubmit = async () => {
     if (!locationId) {
     if (!locationId) {
       toast.error(t('checkIn.pickLocationFirst'))
       toast.error(t('checkIn.pickLocationFirst'))
@@ -236,9 +298,11 @@ export function CheckInDialog({
           measurements,
           measurements,
           treatments,
           treatments,
           serviceRecordId: serviceRecordId ?? null,
           serviceRecordId: serviceRecordId ?? null,
+          billing: jobBilling,
         })
         })
       : await checkInTireSet({
       : await checkInTireSet({
           serviceRecordId: serviceRecordId ?? null,
           serviceRecordId: serviceRecordId ?? null,
+          billing: jobBilling,
           customerId: customerId || null,
           customerId: customerId || null,
           vehicleId: vehicleId || null,
           vehicleId: vehicleId || null,
           season,
           season,
@@ -284,293 +348,605 @@ export function CheckInDialog({
     router.refresh()
     router.refresh()
   }
   }
 
 
+  const shelf = locations.find((l) => l.id === locationId) ?? null
+  const canBillJob = Boolean(billing && serviceRecordId)
+  const last = step === STEPS.length - 1
+  // The one thing a set cannot be stored without. Asked for on the step that
+  // has the picker, so nobody reaches the end to be sent back.
+  const blocked = step >= 1 && !locationId
+
+  const stepLabels = [t('list.tires'), t('checkIn.stepStorage'), t('checkIn.stepReview')]
+  const measured = treads.filter((row) => row.tread.trim() !== '')
+
   return (
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="sm:max-w-2xl">
-        <DialogHeader>
-          <DialogTitle>{t('checkIn.title')}</DialogTitle>
-          <DialogDescription>{t('checkIn.description')}</DialogDescription>
-          <DocsLink href="/docs/features/tire-hotel" variant="hint" className="self-start" />
-        </DialogHeader>
-
-        <div className="space-y-5">
-          {lockedVehicle ? (
-            // The job already answered this. Shown rather than asked, so the
-            // desk can see the tires are going against the right car.
-            <div className="rounded-lg border bg-muted/40 px-3 py-2">
-              <p className="text-xs text-muted-foreground">{t('checkIn.vehicle')}</p>
-              <p className="truncate text-sm font-medium">
-                {lockedVehicle.licensePlate ? `${lockedVehicle.licensePlate} - ` : ''}
-                {lockedVehicle.year} {lockedVehicle.make} {lockedVehicle.model}
-              </p>
+      {/* Three short steps rather than one long form. Each fits the dialog, so
+          nothing scrolls on an ordinary screen and the button that moves on is
+          always in the same place. The body keeps a floor under its height
+          so the dialog does not jump between steps, but only on a screen tall
+          enough for it: on a short one the floor would push the footer out of
+          the dialog, and scrolling is the lesser evil there. */}
+      <DialogContent className="flex max-h-[92vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-5xl">
+        <DialogHeader className="border-b px-6 py-4 pr-14">
+          <div className="flex items-center gap-3">
+            <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
+              <Warehouse className="size-4.5" />
+            </span>
+            <div className="min-w-0 space-y-1 text-left">
+              <DialogTitle>{t('checkIn.title')}</DialogTitle>
+              <DialogDescription>{t('checkIn.description')}</DialogDescription>
             </div>
             </div>
-          ) : (
-            <OwnerVehicleFields
-              vehicles={vehicles}
-              customerId={customerId}
-              onCustomerChange={setCustomerId}
-              vehicleId={vehicleId}
-              onVehicleChange={setVehicleId}
-              // A set chosen under the old pairing is not this pairing's set.
-              onPairChanged={() => setReturning(null)}
-              idPrefix="checkIn"
+            <DocsLink
+              href="/docs/features/tire-hotel"
+              variant="hint"
+              className="ml-auto hidden shrink-0 sm:inline-flex"
             />
             />
-          )}
+          </div>
+        </DialogHeader>
 
 
-          {previousSets.length > 0 && (
+        {/* Where you are. A finished step can be gone back to; the way
+            forward is the button, which is where the shelf is checked. */}
+        <ol className="flex items-center gap-2 border-b bg-muted/30 px-6 py-2.5">
+          {STEPS.map((key, index) => {
+            const done = index < step
+            const current = index === step
+            return (
+              <li key={key} className="flex min-w-0 items-center gap-2">
+                {index > 0 && <span aria-hidden className="h-px w-6 shrink-0 bg-border sm:w-10" />}
+                <button
+                  type="button"
+                  onClick={() => setStep(index)}
+                  disabled={!done}
+                  aria-current={current ? 'step' : undefined}
+                  className={cn(
+                    'flex min-w-0 items-center gap-2 rounded-md text-sm transition-colors',
+                    'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
+                    current ? 'font-medium text-foreground' : 'text-muted-foreground',
+                    done && 'hover:text-foreground'
+                  )}
+                >
+                  <span
+                    className={cn(
+                      'flex size-6 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums',
+                      current && 'border-primary bg-primary text-primary-foreground',
+                      done && 'border-primary/40 bg-primary/10 text-primary'
+                    )}
+                  >
+                    {done ? <Check className="size-3.5" /> : index + 1}
+                  </span>
+                  <span className={cn('truncate', !current && 'hidden sm:inline')}>
+                    {stepLabels[index]}
+                  </span>
+                </button>
+              </li>
+            )
+          })}
+        </ol>
+
+        <div className="grid min-h-0 flex-1 items-start gap-4 overflow-y-auto bg-muted/30 p-4 sm:p-6 md:grid-cols-2 md:[@media(min-height:760px)]:min-h-[30rem]">
+          {step === 0 && (
             <>
             <>
-              <Separator />
-              <div className="space-y-2">
-                <Label>{t('checkIn.storedBefore')}</Label>
-                <p className="text-xs text-muted-foreground">{t('checkIn.storedBeforeHint')}</p>
-                <div className="divide-y rounded-lg border">
-                  {previousSets.map((set) => {
-                    const isOn = returning?.id === set.id
-                    return (
-                      <button
-                        key={set.id}
-                        type="button"
-                        onClick={() => pickReturning(isOn ? null : set)}
-                        aria-pressed={isOn}
-                        className={cn(
-                          'flex w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left transition-colors',
-                          'focus-visible:ring-ring focus-visible:ring-inset focus-visible:ring-2 focus-visible:outline-none',
-                          isOn ? 'bg-primary/5' : 'hover:bg-muted/60'
-                        )}
-                      >
-                        <span className="min-w-0 flex-1">
-                          <span className="block truncate text-sm">
-                            {[
-                              t(`seasons.${set.season}`),
-                              set.brand,
-                              set.size,
-                              t('checkIn.pieces', { count: set.quantity }),
-                            ]
-                              .filter(Boolean)
-                              .join(' · ')}
-                          </span>
-                          <span className="block truncate text-[11px] text-muted-foreground">
-                            {set.reference ? `${set.reference} · ` : ''}
-                            {/* Which car, since a customer with two of them
-                                has two sets on this list and they are not
-                                interchangeable. */}
-                            {set.vehicle?.licensePlate ? `${set.vehicle.licensePlate} · ` : ''}
-                            {set.checkedOutAt
-                              ? t('checkIn.lastOut', {
-                                  date: formatDate(new Date(set.checkedOutAt)),
-                                })
-                              : t('checkIn.notStored')}
-                          </span>
-                        </span>
-                        <span className="w-3.5 shrink-0">
-                          {isOn && <Check className="size-3.5 text-primary" />}
+              {/* Whose tires, and whether the shop has held them before. */}
+              <div className="space-y-4">
+                <Section
+                  icon={Car}
+                  title={lockedVehicle ? t('checkIn.vehicle') : t('checkIn.sectionOwner')}
+                >
+                  {lockedVehicle ? (
+                    // The job already answered this. Shown rather than asked, so
+                    // the desk can see the tires are going against the right car.
+                    <div className="flex items-center gap-3 rounded-lg border bg-muted/40 px-3 py-2">
+                      {lockedVehicle.licensePlate && (
+                        <span className="shrink-0 rounded border bg-background px-2 py-0.5 font-mono text-sm font-semibold">
+                          {lockedVehicle.licensePlate}
                         </span>
                         </span>
-                      </button>
-                    )
-                  })}
-                </div>
+                      )}
+                      <p className="truncate text-sm font-medium">
+                        {lockedVehicle.year} {lockedVehicle.make} {lockedVehicle.model}
+                      </p>
+                    </div>
+                  ) : (
+                    <OwnerVehicleFields
+                      vehicles={vehicles}
+                      customerId={customerId}
+                      onCustomerChange={setCustomerId}
+                      vehicleId={vehicleId}
+                      onVehicleChange={setVehicleId}
+                      // A set chosen under the old pairing is not this pairing's set.
+                      onPairChanged={() => setReturning(null)}
+                      idPrefix="checkIn"
+                    />
+                  )}
+                </Section>
+
+                {previousSets.length > 0 && (
+                  <Section
+                    icon={History}
+                    title={t('checkIn.storedBefore')}
+                    hint={t('checkIn.storedBeforeHint')}
+                  >
+                    {/* Its own short scroller: a fleet customer can have a
+                        dozen sets, and the step must not grow with them. */}
+                    <div className="max-h-44 divide-y overflow-y-auto rounded-lg border">
+                      {previousSets.map((set) => {
+                        const isOn = returning?.id === set.id
+                        return (
+                          <button
+                            key={set.id}
+                            type="button"
+                            onClick={() => pickReturning(isOn ? null : set)}
+                            aria-pressed={isOn}
+                            className={cn(
+                              'flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors',
+                              'focus-visible:ring-ring focus-visible:ring-inset focus-visible:ring-2 focus-visible:outline-none',
+                              isOn ? 'bg-primary/5' : 'hover:bg-muted/60'
+                            )}
+                          >
+                            <span className="min-w-0 flex-1">
+                              <span className="block truncate text-sm">
+                                {[
+                                  t(`seasons.${set.season}`),
+                                  set.brand,
+                                  set.size,
+                                  t('checkIn.pieces', { count: set.quantity }),
+                                ]
+                                  .filter(Boolean)
+                                  .join(' · ')}
+                              </span>
+                              <span className="block truncate text-[11px] text-muted-foreground">
+                                {set.reference ? `${set.reference} · ` : ''}
+                                {/* Which car, since a customer with two of them
+                                    has two sets on this list and they are not
+                                    interchangeable. */}
+                                {set.vehicle?.licensePlate ? `${set.vehicle.licensePlate} · ` : ''}
+                                {set.checkedOutAt
+                                  ? t('checkIn.lastOut', {
+                                      date: formatDate(new Date(set.checkedOutAt)),
+                                    })
+                                  : t('checkIn.notStored')}
+                              </span>
+                            </span>
+                            <span className="w-3.5 shrink-0">
+                              {isOn && <Check className="size-3.5 text-primary" />}
+                            </span>
+                          </button>
+                        )
+                      })}
+                    </div>
+                  </Section>
+                )}
               </div>
               </div>
+
+              {/* What they are. */}
+              <Section icon={CircleDot} title={t('list.tires')}>
+                {returning ? (
+                  // Settled the first time these tires came in. What changes each
+                  // season is where they go and what they measure, and offering the
+                  // brand for editing here invites a typo that quietly turns one
+                  // set's history into another's.
+                  <div className="rounded-lg border bg-muted/40 px-3 py-2">
+                    <p className="text-xs text-muted-foreground">{t('checkIn.sameSet')}</p>
+                    <p className="truncate text-sm font-medium">
+                      {[
+                        t(`seasons.${returning.season}`),
+                        returning.brand,
+                        returning.model,
+                        returning.size,
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </p>
+                  </div>
+                ) : null}
+
+                <div className={cn('grid gap-4 sm:grid-cols-2', returning && 'sm:grid-cols-1')}>
+                  {!returning && (
+                    <div className="space-y-2">
+                      <Label htmlFor="checkInSeason">{t('checkIn.season')}</Label>
+                      <Select value={season} onValueChange={setSeason}>
+                        <SelectTrigger id="checkInSeason" className="w-full">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent>
+                          {TIRE_SEASONS.map((value) => (
+                            <SelectItem key={value} value={value}>
+                              {t(`seasons.${value}`)}
+                            </SelectItem>
+                          ))}
+                        </SelectContent>
+                      </Select>
+                    </div>
+                  )}
+                  <div className="space-y-2">
+                    <Label htmlFor="checkInQuantity">{t('checkIn.quantity')}</Label>
+                    <Input
+                      id="checkInQuantity"
+                      type="number"
+                      min="1"
+                      max="20"
+                      value={quantity}
+                      onChange={(e) => setQuantity(e.target.value)}
+                    />
+                  </div>
+                  <p className="-mt-2 text-xs text-muted-foreground sm:col-span-full">
+                    {t('checkIn.quantityHint')}
+                  </p>
+                </div>
+
+                {!returning && (
+                  <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="space-y-2">
+                      <Label htmlFor="checkInBrand">{t('checkIn.brand')}</Label>
+                      <Input
+                        id="checkInBrand"
+                        value={brand}
+                        onChange={(e) => setBrand(e.target.value)}
+                        placeholder={t('checkIn.brandPlaceholder')}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="checkInModel">{t('checkIn.model')}</Label>
+                      <Input
+                        id="checkInModel"
+                        value={model}
+                        onChange={(e) => setModel(e.target.value)}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="checkInSize">{t('checkIn.size')}</Label>
+                      <Input
+                        id="checkInSize"
+                        value={size}
+                        onChange={(e) => setSize(e.target.value)}
+                        placeholder={t('checkIn.sizePlaceholder')}
+                        className="font-mono"
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="checkInDot">{t('checkIn.dotCode')}</Label>
+                      <Input
+                        id="checkInDot"
+                        value={dotCode}
+                        onChange={(e) => setDotCode(e.target.value)}
+                        placeholder={t('checkIn.dotPlaceholder')}
+                        className="font-mono"
+                      />
+                      <p className="text-xs text-muted-foreground">{t('checkIn.dotHint')}</p>
+                    </div>
+                  </div>
+                )}
+
+                {!returning && (
+                  <div className="divide-y rounded-lg border">
+                    <SwitchRow
+                      id="checkInRims"
+                      label={t('checkIn.withRims')}
+                      checked={withRims}
+                      onCheckedChange={(on) => {
+                        setWithRims(on)
+                        // Rims arriving almost always means the rims get washed too;
+                        // unticking removes it again rather than leaving a job for
+                        // parts that are not here.
+                        setTreatments((current) =>
+                          on
+                            ? current.includes('wash_rims')
+                              ? current
+                              : [...current, 'wash_rims']
+                            : current.filter((x) => x !== 'wash_rims')
+                        )
+                      }}
+                    />
+                    <SwitchRow
+                      id="checkInTpms"
+                      label={t('checkIn.hasTpms')}
+                      checked={hasTpms}
+                      onCheckedChange={setHasTpms}
+                    />
+                    {season === 'winter' && (
+                      <SwitchRow
+                        id="checkInStudded"
+                        label={t('checkIn.studded')}
+                        checked={studded}
+                        onCheckedChange={setStudded}
+                      />
+                    )}
+                  </div>
+                )}
+              </Section>
             </>
             </>
           )}
           )}
 
 
-          <Separator />
-
-          {returning ? (
-            // Settled the first time these tires came in. What changes each
-            // season is where they go and what they measure, and offering the
-            // brand for editing here invites a typo that quietly turns one
-            // set's history into another's.
-            <div className="rounded-lg border bg-muted/40 px-3 py-2">
-              <p className="text-xs text-muted-foreground">{t('checkIn.sameSet')}</p>
-              <p className="truncate text-sm font-medium">
-                {[
-                  t(`seasons.${returning.season}`),
-                  returning.brand,
-                  returning.model,
-                  returning.size,
-                ]
-                  .filter(Boolean)
-                  .join(' · ')}
-              </p>
-            </div>
-          ) : null}
-
-          <div className={cn('grid gap-4 sm:grid-cols-2', returning && 'sm:grid-cols-1')}>
-            {!returning && (
-              <div className="space-y-2">
-                <Label htmlFor="checkInSeason">{t('checkIn.season')}</Label>
-                <Select value={season} onValueChange={setSeason}>
-                  <SelectTrigger id="checkInSeason">
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {TIRE_SEASONS.map((value) => (
-                      <SelectItem key={value} value={value}>
-                        {t(`seasons.${value}`)}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
-              </div>
-            )}
-            <div className="space-y-2">
-              <Label htmlFor="checkInQuantity">{t('checkIn.quantity')}</Label>
-              <Input
-                id="checkInQuantity"
-                type="number"
-                min="1"
-                max="20"
-                value={quantity}
-                onChange={(e) => setQuantity(e.target.value)}
-              />
-              <p className="text-xs text-muted-foreground">{t('checkIn.quantityHint')}</p>
-            </div>
-          </div>
+          {step === 1 && (
+            <>
+              {/* Where they go and what gets done to them. */}
+              <div className="space-y-4">
+                <Section icon={Warehouse} title={t('checkIn.location')}>
+                  <LocationPicker
+                    locations={locations}
+                    value={locationId}
+                    onChange={setLocationId}
+                    quantity={qty}
+                  />
+                </Section>
 
 
-          {!returning && (
-            <div className="grid gap-4 sm:grid-cols-2">
-              <div className="space-y-2">
-                <Label htmlFor="checkInBrand">{t('checkIn.brand')}</Label>
-                <Input
-                  id="checkInBrand"
-                  value={brand}
-                  onChange={(e) => setBrand(e.target.value)}
-                  placeholder={t('checkIn.brandPlaceholder')}
-                />
-              </div>
-              <div className="space-y-2">
-                <Label htmlFor="checkInModel">{t('checkIn.model')}</Label>
-                <Input id="checkInModel" value={model} onChange={(e) => setModel(e.target.value)} />
+                <Section
+                  icon={Sparkles}
+                  title={t('treatments.title')}
+                  hint={t('checkIn.treatmentsHint')}
+                >
+                  <TreatmentPicker
+                    selected={treatments}
+                    onChange={setTreatments}
+                    withRims={withRims}
+                    hasTpms={hasTpms}
+                    className="sm:grid-cols-2"
+                  />
+                </Section>
               </div>
               </div>
-              <div className="space-y-2">
-                <Label htmlFor="checkInSize">{t('checkIn.size')}</Label>
-                <Input
-                  id="checkInSize"
-                  value={size}
-                  onChange={(e) => setSize(e.target.value)}
-                  placeholder={t('checkIn.sizePlaceholder')}
-                  className="font-mono"
-                />
-              </div>
-              <div className="space-y-2">
-                <Label htmlFor="checkInDot">{t('checkIn.dotCode')}</Label>
-                <Input
-                  id="checkInDot"
-                  value={dotCode}
-                  onChange={(e) => setDotCode(e.target.value)}
-                  placeholder={t('checkIn.dotPlaceholder')}
-                  className="font-mono"
-                />
-                <p className="text-xs text-muted-foreground">{t('checkIn.dotHint')}</p>
-              </div>
-            </div>
-          )}
 
 
-          {!returning && (
-            <div className="flex flex-wrap gap-6">
-              <div className="flex items-center gap-2">
-                <Switch
-                  id="checkInRims"
-                  checked={withRims}
-                  onCheckedChange={(on) => {
-                    setWithRims(on)
-                    // Rims arriving almost always means the rims get washed too;
-                    // unticking removes it again rather than leaving a job for
-                    // parts that are not here.
-                    setTreatments((current) =>
-                      on
-                        ? current.includes('wash_rims')
-                          ? current
-                          : [...current, 'wash_rims']
-                        : current.filter((x) => x !== 'wash_rims')
-                    )
-                  }}
+              {/* What they measured on the way in. */}
+              <Section icon={Ruler} title={t('checkIn.treadTitle')} hint={t('checkIn.treadHint')}>
+                <TreadEntry
+                  rows={treads}
+                  onChange={setTreads}
+                  imperial={imperial}
+                  season={season}
+                  thresholds={thresholds}
+                  previous={lastReadings}
                 />
                 />
-                <Label htmlFor="checkInRims" className="font-normal">
-                  {t('checkIn.withRims')}
-                </Label>
-              </div>
-              <div className="flex items-center gap-2">
-                <Switch id="checkInTpms" checked={hasTpms} onCheckedChange={setHasTpms} />
-                <Label htmlFor="checkInTpms" className="font-normal">
-                  {t('checkIn.hasTpms')}
-                </Label>
-              </div>
-              {season === 'winter' && (
-                <div className="flex items-center gap-2">
-                  <Switch id="checkInStudded" checked={studded} onCheckedChange={setStudded} />
-                  <Label htmlFor="checkInStudded" className="font-normal">
-                    {t('checkIn.studded')}
-                  </Label>
-                </div>
-              )}
-            </div>
+              </Section>
+            </>
           )}
           )}
 
 
-          <Separator />
-
-          <div className="space-y-2">
-            <Label className="flex items-center gap-2">
-              <Warehouse className="h-4 w-4" />
-              {t('checkIn.location')}
-            </Label>
-            <LocationPicker
-              locations={locations}
-              value={locationId}
-              onChange={setLocationId}
-              quantity={qty}
-            />
-          </div>
-
-          <Separator />
-
-          <div className="space-y-2">
-            <Label>{t('treatments.title')}</Label>
-            <p className="text-xs text-muted-foreground">{t('checkIn.treatmentsHint')}</p>
-            <TreatmentPicker
-              selected={treatments}
-              onChange={setTreatments}
-              withRims={withRims}
-              hasTpms={hasTpms}
-            />
-          </div>
+          {step === 2 && (
+            <>
+              {/* Read back before it is saved, with room for what the form
+                  had no field for. */}
+              <div className="space-y-4">
+                <Section icon={ClipboardCheck} title={t('checkIn.stepReview')}>
+                  <dl className="divide-y rounded-lg border text-sm">
+                    {lockedVehicle && (
+                      <SummaryRow label={t('checkIn.vehicle')}>
+                        {[lockedVehicle.licensePlate, lockedVehicle.make, lockedVehicle.model]
+                          .filter(Boolean)
+                          .join(' ')}
+                      </SummaryRow>
+                    )}
+                    <SummaryRow label={t('list.tires')}>
+                      {[
+                        t('list.tireCount', { count: qty }),
+                        t(`seasons.${season}`),
+                        brand,
+                        model,
+                        size,
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </SummaryRow>
+                    <SummaryRow label={t('list.location')}>
+                      {shelf ? (
+                        <>
+                          <span className="font-mono font-medium">{shelf.code}</span>
+                          <span className="ml-2 text-xs text-muted-foreground">
+                            {shelf.warehouseName}
+                          </span>
+                        </>
+                      ) : (
+                        '-'
+                      )}
+                    </SummaryRow>
+                    <SummaryRow label={t('treatments.title')}>
+                      {treatments.length > 0
+                        ? treatments.map((type) => t(`treatments.types.${type}`)).join(', ')
+                        : '-'}
+                    </SummaryRow>
+                    {measured.length > 0 && (
+                      <SummaryRow label={t('checkIn.treadTitle')}>
+                        <span className="tabular-nums">
+                          {measured
+                            .map((row) => `${row.tread} ${imperial ? '/32"' : 'mm'}`)
+                            .join(' · ')}
+                        </span>
+                      </SummaryRow>
+                    )}
+                  </dl>
+                </Section>
 
 
-          <Separator />
-
-          <div className="space-y-2">
-            <Label>{t('checkIn.treadTitle')}</Label>
-            <p className="text-xs text-muted-foreground">{t('checkIn.treadHint')}</p>
-            <TreadEntry
-              rows={treads}
-              onChange={setTreads}
-              imperial={imperial}
-              season={season}
-              thresholds={thresholds}
-              previous={lastReadings}
-            />
-          </div>
+                {canBillJob && (
+                  <Section icon={StickyNote} title={t('checkIn.notes')}>
+                    <Textarea
+                      id="checkInNotes"
+                      aria-label={t('checkIn.notes')}
+                      value={notes}
+                      onChange={(e) => setNotes(e.target.value)}
+                      rows={3}
+                    />
+                  </Section>
+                )}
+              </div>
 
 
-          <div className="space-y-2">
-            <Label htmlFor="checkInNotes">{t('checkIn.notes')}</Label>
-            <Textarea
-              id="checkInNotes"
-              value={notes}
-              onChange={(e) => setNotes(e.target.value)}
-              rows={2}
-            />
-          </div>
+              {canBillJob && billing ? (
+                // What this visit costs, last thing before the button that saves it.
+                <Section icon={Receipt} title={t('job.addToWorkOrder')} hint={t('job.pricesHint')}>
+                  <div className="divide-y rounded-lg border">
+                    <div className="flex min-h-11 items-center gap-2.5 px-3 py-1.5">
+                      <Checkbox
+                        id="checkInBillStorage"
+                        checked={billStorage}
+                        onCheckedChange={(value) => setBillStorage(value === true)}
+                      />
+                      <Label
+                        htmlFor="checkInBillStorage"
+                        className="flex-1 cursor-pointer font-normal"
+                      >
+                        {t('job.storage')}
+                      </Label>
+                      {billStorage && (
+                        <Input
+                          type="number"
+                          min="0"
+                          step="0.01"
+                          inputMode="decimal"
+                          value={storageAmount}
+                          onChange={(e) => setStorageAmount(e.target.value)}
+                          aria-label={t('job.storageFee')}
+                          className="h-8 w-28 text-right text-sm tabular-nums"
+                        />
+                      )}
+                    </div>
+                    {pricedPrep.map((line) => (
+                      <label
+                        key={line.type}
+                        className="flex min-h-11 cursor-pointer items-center gap-2.5 px-3 py-1.5 hover:bg-muted/50"
+                      >
+                        <Checkbox
+                          checked={!unbilledPrep.includes(line.type)}
+                          onCheckedChange={(value) =>
+                            setUnbilledPrep((current) =>
+                              value === true
+                                ? current.filter((x) => x !== line.type)
+                                : [...current, line.type]
+                            )
+                          }
+                        />
+                        <span className="min-w-0 flex-1 truncate text-sm">
+                          {t(`treatments.types.${line.type}`)}
+                        </span>
+                        <span className="shrink-0 text-sm tabular-nums text-muted-foreground">
+                          {formatCurrency(line.price)}
+                        </span>
+                      </label>
+                    ))}
+                    <div className="flex items-center justify-between gap-2 bg-muted/40 px-3 py-2">
+                      <span className="text-sm font-medium">{t('job.total')}</span>
+                      <span className="text-sm font-semibold tabular-nums">
+                        {formatCurrency(billTotal)}
+                      </span>
+                    </div>
+                  </div>
+                </Section>
+              ) : (
+                <Section icon={StickyNote} title={t('checkIn.notes')}>
+                  <Textarea
+                    id="checkInNotes"
+                    aria-label={t('checkIn.notes')}
+                    value={notes}
+                    onChange={(e) => setNotes(e.target.value)}
+                    rows={6}
+                  />
+                </Section>
+              )}
+            </>
+          )}
         </div>
         </div>
 
 
-        <DialogFooter>
-          <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
-            {t('common.cancel')}
-          </Button>
-          <Button onClick={handleSubmit} disabled={saving || !locationId}>
-            {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-            {t(returning ? 'checkIn.submitReturn' : 'checkIn.submit')}
-          </Button>
+        <DialogFooter className="items-center border-t px-6 py-3 sm:justify-between">
+          {/* What is about to be saved, read back in one line, or the one
+              thing still missing. */}
+          {blocked ? (
+            <p className="text-sm text-amber-600">{t('checkIn.pickLocationFirst')}</p>
+          ) : (
+            <p className="flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
+              <span>{t('list.tireCount', { count: qty })}</span>
+              <span aria-hidden>·</span>
+              <span>{t(`seasons.${season}`)}</span>
+              {shelf && (
+                <>
+                  <span aria-hidden>·</span>
+                  <span className="font-mono font-medium text-foreground">{shelf.code}</span>
+                </>
+              )}
+              {jobBilling && (
+                <>
+                  <span aria-hidden>·</span>
+                  <span className="tabular-nums">+ {formatCurrency(billTotal)}</span>
+                </>
+              )}
+            </p>
+          )}
+          <div className="flex flex-col-reverse gap-2 sm:flex-row">
+            {step === 0 ? (
+              <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
+                {t('common.cancel')}
+              </Button>
+            ) : (
+              <Button variant="outline" onClick={() => setStep(step - 1)} disabled={saving}>
+                <ChevronLeft className="mr-1 h-4 w-4" />
+                {t('checkIn.back')}
+              </Button>
+            )}
+            {last ? (
+              <Button onClick={handleSubmit} disabled={saving || !locationId}>
+                {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {t(returning ? 'checkIn.submitReturn' : 'checkIn.submit')}
+              </Button>
+            ) : (
+              <Button onClick={() => setStep(step + 1)} disabled={blocked}>
+                {t('checkIn.next')}
+                <ChevronRight className="ml-1 h-4 w-4" />
+              </Button>
+            )}
+          </div>
         </DialogFooter>
         </DialogFooter>
       </DialogContent>
       </DialogContent>
     </Dialog>
     </Dialog>
   )
   )
 }
 }
+
+/** One line of the read-back: what it is on the left, what was entered on the right. */
+function SummaryRow({ label, children }: { label: string; children: ReactNode }) {
+  return (
+    <div className="flex items-baseline gap-3 px-3 py-2">
+      <dt className="w-28 shrink-0 text-xs text-muted-foreground">{label}</dt>
+      <dd className="min-w-0 flex-1 break-words">{children}</dd>
+    </div>
+  )
+}
+
+/** One titled panel of the form. */
+function Section({
+  icon: Icon,
+  title,
+  hint,
+  children,
+}: {
+  icon: LucideIcon
+  title: string
+  hint?: string
+  children: ReactNode
+}) {
+  return (
+    <section className="space-y-4 rounded-xl border bg-card p-4 shadow-xs">
+      <div className="space-y-1">
+        <h3 className="flex items-center gap-2 text-sm font-semibold">
+          <Icon className="size-4 text-muted-foreground" />
+          {title}
+        </h3>
+        {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
+      </div>
+      {children}
+    </section>
+  )
+}
+
+/** A yes/no fact about the set: the whole row is the label, the switch sits right. */
+function SwitchRow({
+  id,
+  label,
+  checked,
+  onCheckedChange,
+}: {
+  id: string
+  label: string
+  checked: boolean
+  onCheckedChange: (on: boolean) => void
+}) {
+  return (
+    <div className="flex items-center justify-between gap-3 px-3 py-2.5">
+      <Label htmlFor={id} className="flex-1 cursor-pointer font-normal">
+        {label}
+      </Label>
+      <Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
+    </div>
+  )
+}

+ 1 - 1
src/features/tire-hotel/Components/LocationPicker.tsx

@@ -95,7 +95,7 @@ export function LocationPicker({
             <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
             <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
           </Button>
           </Button>
         </PopoverTrigger>
         </PopoverTrigger>
-        <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+        <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
           <Command>
           <Command>
             <CommandInput placeholder={t('checkIn.searchLocations')} />
             <CommandInput placeholder={t('checkIn.searchLocations')} />
             <CommandList>
             <CommandList>

+ 44 - 9
src/features/tire-hotel/Components/StoreTiresButton.tsx

@@ -8,8 +8,14 @@ import { Loader2, Warehouse } from 'lucide-react'
 import { CheckInDialog } from './CheckInDialog'
 import { CheckInDialog } from './CheckInDialog'
 import { PrintLabelsDialog } from './PrintLabelsDialog'
 import { PrintLabelsDialog } from './PrintLabelsDialog'
 import { getLocationOptions } from '../Actions/storageActions'
 import { getLocationOptions } from '../Actions/storageActions'
+import { getCheckInPrices } from '../Actions/tireSetActions'
+import type { TreatmentPrices } from '../Lib/treatments'
 import type { PickerLocation } from './LocationPicker'
 import type { PickerLocation } from './LocationPicker'
 
 
+// One object for every render: the dialog resets its form when this prop
+// changes, and a fresh literal each time would reset it on every keystroke.
+const NO_PRICES = { storagePrice: 0, treatmentPrices: {} }
+
 type Vehicle = {
 type Vehicle = {
   id: string
   id: string
   make: string
   make: string
@@ -34,11 +40,25 @@ type Vehicle = {
 export function StoreTiresButton({
 export function StoreTiresButton({
   serviceRecordId,
   serviceRecordId,
   vehicle,
   vehicle,
+  hasSet,
+  canBill,
   imperial,
   imperial,
   thresholds,
   thresholds,
 }: {
 }: {
   serviceRecordId: string
   serviceRecordId: string
   vehicle: Vehicle
   vehicle: Vehicle
+  /**
+   * The job already has its set, so there is nothing left to offer. The
+   * component stays mounted all the same: the label dialog opens at the very
+   * moment the job gains its set, and a parent that dropped this component
+   * then took the dialog down with it a second after it appeared.
+   */
+  hasSet: boolean
+  /**
+   * Whether the job can still take lines. A locked invoice cannot, so the
+   * dialog stores the tires without offering to charge for it there.
+   */
+  canBill: boolean
   imperial: boolean
   imperial: boolean
   /** The workshop's tread limits, passed straight to the check-in form. */
   /** The workshop's tread limits, passed straight to the check-in form. */
   thresholds?: { summerReplace: number; winterReplace: number; warnMargin: number }
   thresholds?: { summerReplace: number; winterReplace: number; warnMargin: number }
@@ -46,6 +66,10 @@ export function StoreTiresButton({
   const t = useTranslations('tireHotel')
   const t = useTranslations('tireHotel')
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [locations, setLocations] = useState<PickerLocation[] | null>(null)
   const [locations, setLocations] = useState<PickerLocation[] | null>(null)
+  const [prices, setPrices] = useState<{
+    storagePrice: number
+    treatmentPrices: TreatmentPrices
+  } | null>(null)
   const [checkingIn, setCheckingIn] = useState(false)
   const [checkingIn, setCheckingIn] = useState(false)
   const [printing, setPrinting] = useState<{ id: string; quantity: number } | null>(null)
   const [printing, setPrinting] = useState<{ id: string; quantity: number } | null>(null)
 
 
@@ -55,8 +79,11 @@ export function StoreTiresButton({
       return
       return
     }
     }
     setLoading(true)
     setLoading(true)
-    const result = await getLocationOptions()
+    const [result, priced] = await Promise.all([getLocationOptions(), getCheckInPrices()])
     setLoading(false)
     setLoading(false)
+    // Without the prices the panel still works, it just starts empty: the
+    // storage fee can be typed, and unpriced prep is never billed anyway.
+    setPrices(priced.success && priced.data ? priced.data : null)
 
 
     const rows = result.success && result.data ? result.data : []
     const rows = result.success && result.data ? result.data : []
     if (rows.length === 0) {
     if (rows.length === 0) {
@@ -71,14 +98,21 @@ export function StoreTiresButton({
 
 
   return (
   return (
     <>
     <>
-      <Button size="sm" variant="outline" onClick={handleOpen} disabled={loading}>
-        {loading ? (
-          <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
-        ) : (
-          <Warehouse className="mr-1.5 h-3.5 w-3.5" />
-        )}
-        {t('job.storeTires')}
-      </Button>
+      {!hasSet && (
+        // A row of its own rather than a button adrift between two cards: it
+        // says what the state is, and the banner that replaces it once a set
+        // is stored sits in the same place.
+        <div className="flex min-w-0 items-center gap-3 rounded-lg border border-dashed border-card-edge px-4 py-2">
+          <Warehouse className="h-4 w-4 shrink-0 text-muted-foreground" />
+          <span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground">
+            {t('job.noneStored')}
+          </span>
+          <Button size="sm" variant="outline" onClick={handleOpen} disabled={loading}>
+            {loading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
+            {t('job.storeTires')}
+          </Button>
+        </div>
+      )}
 
 
       {locations && (
       {locations && (
         <CheckInDialog
         <CheckInDialog
@@ -88,6 +122,7 @@ export function StoreTiresButton({
           vehicles={[vehicle]}
           vehicles={[vehicle]}
           lockedVehicle={vehicle}
           lockedVehicle={vehicle}
           serviceRecordId={serviceRecordId}
           serviceRecordId={serviceRecordId}
+          billing={canBill ? (prices ?? NO_PRICES) : undefined}
           imperial={imperial}
           imperial={imperial}
           thresholds={thresholds}
           thresholds={thresholds}
           // Straight into the labels, still on the job. The sticker has to go
           // Straight into the labels, still on the job. The sticker has to go

+ 24 - 23
src/features/tire-hotel/Components/TreadEntry.tsx

@@ -21,7 +21,7 @@ export type TreadRow = {
 }
 }
 
 
 /**
 /**
- * Four tread readings in a row, graded as they are typed.
+ * Four tread readings, one per corner of the car, graded as they are typed.
  *
  *
  * The grade is suggested rather than imposed: the number decides the colour,
  * The grade is suggested rather than imposed: the number decides the colour,
  * but a technician who sees sidewall damage on an otherwise deep tire can
  * but a technician who sees sidewall damage on an otherwise deep tire can
@@ -68,7 +68,8 @@ export function TreadEntry({
   }
   }
 
 
   return (
   return (
-    <div className="grid gap-2 sm:grid-cols-2">
+    // Two by two, the way the wheels sit on the car: front pair above rear.
+    <div className="grid grid-cols-2 gap-2">
       {rows.map((row, index) => {
       {rows.map((row, index) => {
         const suggested = gradeFor(row.tread)
         const suggested = gradeFor(row.tread)
         const active = suggested ?? row.condition
         const active = suggested ?? row.condition
@@ -89,28 +90,11 @@ export function TreadEntry({
             : null
             : null
 
 
         return (
         return (
-          <div key={row.position} className="rounded-lg border p-2">
-            <div className="flex items-center gap-2">
-              <span className="w-20 shrink-0 text-xs text-muted-foreground">
+          <div key={row.position} className="space-y-1.5 rounded-lg border p-2.5">
+            <div className="flex items-center justify-between gap-2">
+              <span className="truncate text-xs font-medium text-muted-foreground">
                 {t(`positions.${row.position}`)}
                 {t(`positions.${row.position}`)}
               </span>
               </span>
-              <Input
-                type="number"
-                step="0.1"
-                min="0"
-                inputMode="decimal"
-                value={row.tread}
-                onChange={(e) => {
-                  const grade = gradeFor(e.target.value)
-                  update(index, {
-                    tread: e.target.value,
-                    ...(grade ? { condition: grade } : {}),
-                  })
-                }}
-                placeholder={imperial ? t('tread.placeholder32') : t('tread.placeholderMm')}
-                className="h-8 flex-1 tabular-nums"
-                aria-label={t('tread.inputLabel', { position: t(`positions.${row.position}`) })}
-              />
               <Badge
               <Badge
                 variant="outline"
                 variant="outline"
                 className={cn('shrink-0 text-[10px]', CONDITION_TOKENS[active].badge)}
                 className={cn('shrink-0 text-[10px]', CONDITION_TOKENS[active].badge)}
@@ -118,9 +102,26 @@ export function TreadEntry({
                 {t(`conditions.${active}`)}
                 {t(`conditions.${active}`)}
               </Badge>
               </Badge>
             </div>
             </div>
+            <Input
+              type="number"
+              step="0.1"
+              min="0"
+              inputMode="decimal"
+              value={row.tread}
+              onChange={(e) => {
+                const grade = gradeFor(e.target.value)
+                update(index, {
+                  tread: e.target.value,
+                  ...(grade ? { condition: grade } : {}),
+                })
+              }}
+              placeholder={imperial ? t('tread.placeholder32') : t('tread.placeholderMm')}
+              className="h-8 tabular-nums"
+              aria-label={t('tread.inputLabel', { position: t(`positions.${row.position}`) })}
+            />
 
 
             {typeof before === 'number' && (
             {typeof before === 'number' && (
-              <p className="mt-1 pl-[5.5rem] text-[11px] text-muted-foreground">
+              <p className="text-[11px] text-muted-foreground">
                 {t('tread.lastTime', { value: display(before) })}
                 {t('tread.lastTime', { value: display(before) })}
                 {worn !== null && (
                 {worn !== null && (
                   <span className="ml-1.5 text-amber-600">
                   <span className="ml-1.5 text-amber-600">

+ 149 - 0
src/features/tire-hotel/Lib/jobLines.ts

@@ -0,0 +1,149 @@
+import { db, type TxClient } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
+import { invoiceLineWords, treatmentNames } from './serverMessages'
+import { billableTreatments, parseTreatmentPrices } from './treatments'
+
+/**
+ * The service lines the tire hotel puts on a job: the storage fee and the
+ * prep work. One place builds them, because they reach a job from two
+ * directions, a job raised from a stored set and a set stored from a job, and
+ * the customer should read the same line either way.
+ */
+
+export type TireJobLine = {
+  description: string
+  hours: number
+  rate: number
+  total: number
+  pricingType: 'service'
+}
+
+/**
+ * The storage fee as a flat service line, in the workshop's language.
+ *
+ * Priced per job rather than by the hour, so it prints as one figure with the
+ * period beside it. Nothing schedules this: it is charged when the tires are
+ * billed, which is the moment somebody is looking at the account anyway.
+ */
+export async function storageLine(
+  set: { size: string | null; quantity: number },
+  data: { storageAmount?: number; storageFrom?: Date; storageTo?: Date }
+): Promise<TireJobLine> {
+  const amount = Math.round((data.storageAmount ?? 0) * 100) / 100
+  const words = await invoiceLineWords()
+
+  // An open-ended period is the normal case: the shop knows when the tires
+  // arrived and not when they will be collected, and inventing an end date
+  // would print a promise on the invoice.
+  const from = data.storageFrom?.toISOString().slice(0, 10)
+  const to = data.storageTo?.toISOString().slice(0, 10)
+  const period = from ? (to ? `${from} - ${to}` : words.fromDate.replace('{date}', from)) : null
+
+  return {
+    description: [words.storage, set.size, `${set.quantity} ${words.pieces}`, period]
+      .filter(Boolean)
+      .join(' · '),
+    hours: 1,
+    rate: amount,
+    total: amount,
+    pricingType: 'service' as const,
+  }
+}
+
+export async function billablePrep(
+  organizationId: string,
+  treatments: { type: string; status: string }[]
+) {
+  const setting = await db.appSetting.findUnique({
+    where: {
+      organizationId_key: { organizationId, key: SETTING_KEYS.TIRE_HOTEL_TREATMENT_PRICES },
+    },
+    select: { value: true },
+  })
+  return billableTreatments(treatments, parseTreatmentPrices(setting?.value))
+}
+
+/**
+ * Prep lines for a job, priced from settings.
+ *
+ * The work was agreed when the set was checked in, so it should reach the
+ * bill without anyone retyping it. Only treatments the shop has put a price
+ * against produce a line, which keeps washing off the invoice at shops that
+ * fold it into the storage fee.
+ *
+ * The names are loaded here rather than passed in from the browser: this text
+ * ends up on an invoice, and invoice wording should not be whatever a client
+ * happened to send. The prices are read here for the same reason.
+ */
+export async function treatmentLines(
+  organizationId: string,
+  treatments: { type: string; status: string }[],
+  only?: string[]
+): Promise<TireJobLine[]> {
+  const names = await treatmentNames()
+  const billable = await billablePrep(organizationId, treatments)
+  const wanted = only ? new Set(only) : null
+
+  return billable
+    .filter((line) => !wanted || wanted.has(line.type))
+    .map((line) => ({
+      description: names[line.type] ?? line.type,
+      // A flat service line, not hours: prep is priced per job, and an hourly
+      // line would invite someone to multiply it by a duration nobody tracked.
+      hours: 1,
+      rate: line.price,
+      total: line.price,
+      pricingType: 'service' as const,
+    }))
+}
+
+/**
+ * What a check-in started from a job asks to have put on that job. Absent
+ * means nothing is billed, which is every check-in from the tire hotel itself.
+ */
+export type CheckInBilling = {
+  /** Present when the storage fee is billed; the figure the desk settled on. */
+  storageAmount?: number
+  /** Which of the requested prep to bill. Prices come from settings. */
+  treatments?: string[]
+}
+
+/**
+ * The lines a check-in adds to its job, built before the transaction opens:
+ * they read settings and translations, and neither belongs inside one.
+ */
+export async function checkInJobLines(
+  organizationId: string,
+  set: { size: string | null; quantity: number },
+  requested: string[],
+  billing: CheckInBilling | null | undefined
+): Promise<TireJobLine[]> {
+  if (!billing) return []
+  const lines = billing.treatments?.length
+    ? await treatmentLines(
+        organizationId,
+        requested.map((type) => ({ type, status: 'pending' })),
+        billing.treatments
+      )
+    : []
+  if (billing.storageAmount !== undefined) {
+    lines.push(
+      await storageLine(set, { storageAmount: billing.storageAmount, storageFrom: new Date() })
+    )
+  }
+  return lines
+}
+
+/** Writes those lines and brings the job's totals up to date. In a transaction. */
+export async function addLinesToJob(
+  tx: TxClient,
+  serviceRecordId: string,
+  lines: TireJobLine[]
+): Promise<void> {
+  if (lines.length === 0) return
+  await tx.serviceLabor.createMany({
+    data: lines.map((line) => ({ ...line, serviceRecordId })),
+  })
+  await retotalServiceRecord(serviceRecordId, tx)
+}

+ 15 - 0
src/features/tire-hotel/Schema/tireHotelSchema.ts

@@ -117,6 +117,19 @@ export const updateTireSetSchema = tireSetSchema.partial().extend({
   status: z.enum(TIRE_SET_STATUSES).optional(),
   status: z.enum(TIRE_SET_STATUSES).optional(),
 })
 })
 
 
+/**
+ * What a check-in started from a job puts on that job. Amounts for prep are
+ * never taken from the browser: the client names the work, settings price it.
+ * The storage fee is the one figure the desk may settle on the spot.
+ */
+const checkInBillingSchema = z
+  .object({
+    storageAmount: z.coerce.number().min(0).max(1_000_000).optional(),
+    treatments: z.array(z.enum(TREATMENT_TYPES)).max(TREATMENT_TYPES.length).optional(),
+  })
+  .optional()
+  .nullable()
+
 export const checkInSchema = tireSetSchema.extend({
 export const checkInSchema = tireSetSchema.extend({
   /// Required on check-in: a set that arrives has to land somewhere.
   /// Required on check-in: a set that arrives has to land somewhere.
   locationId: z.string().min(1, 'Choose where the tires go'),
   locationId: z.string().min(1, 'Choose where the tires go'),
@@ -125,6 +138,7 @@ export const checkInSchema = tireSetSchema.extend({
   /// the new set to that work order so the technician sees the shelf without
   /// the new set to that work order so the technician sees the shelf without
   /// anyone going looking for it.
   /// anyone going looking for it.
   serviceRecordId: z.string().min(1).optional().nullable(),
   serviceRecordId: z.string().min(1).optional().nullable(),
+  billing: checkInBillingSchema,
 })
 })
 
 
 /**
 /**
@@ -145,6 +159,7 @@ export const returnSetSchema = z.object({
   measurements: z.array(measurementSchema).max(20).optional(),
   measurements: z.array(measurementSchema).max(20).optional(),
   treatments: z.array(z.enum(TREATMENT_TYPES)).max(TREATMENT_TYPES.length).optional(),
   treatments: z.array(z.enum(TREATMENT_TYPES)).max(TREATMENT_TYPES.length).optional(),
   serviceRecordId: z.string().min(1).optional().nullable(),
   serviceRecordId: z.string().min(1).optional().nullable(),
+  billing: checkInBillingSchema,
 })
 })
 
 
 /** Taking a set out of circulation for good. */
 /** Taking a set out of circulation for good. */

+ 1 - 1
src/features/vehicles/Components/VehicleForm.tsx

@@ -451,7 +451,7 @@ export function VehicleForm({
                       <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                       <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                     </Button>
                     </Button>
                   </PopoverTrigger>
                   </PopoverTrigger>
-                  <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                  <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
                     <Command>
                     <Command>
                       <CommandInput placeholder={t('searchCustomers')} />
                       <CommandInput placeholder={t('searchCustomers')} />
                       <CommandList className="max-h-60 overflow-y-auto">
                       <CommandList className="max-h-60 overflow-y-auto">

+ 1 - 12
src/features/vehicles/Components/service-edit/InvoiceDetailsSection.tsx

@@ -47,8 +47,6 @@ interface InvoiceDetailsSectionProps {
   designId?: string | null
   designId?: string | null
   /** What "default" resolves to for this invoice, when it has a name. */
   /** What "default" resolves to for this invoice, when it has a name. */
   designFollowsName?: string | null
   designFollowsName?: string | null
-  /** When the sheet was frozen, ISO. Set only while it prints from that copy. */
-  designPinnedAt?: string | null
   /** Set when a design rule, not the customer or the default, is what "default" means here. */
   /** Set when a design rule, not the customer or the default, is what "default" means here. */
   designFollowsRule?: DesignAutoRule | null
   designFollowsRule?: DesignAutoRule | null
   /**
   /**
@@ -73,7 +71,6 @@ export function InvoiceDetailsSection({
   designOptions = [],
   designOptions = [],
   designId = null,
   designId = null,
   designFollowsName = null,
   designFollowsName = null,
-  designPinnedAt = null,
   designFollowsRule = null,
   designFollowsRule = null,
   part,
   part,
 }: InvoiceDetailsSectionProps) {
 }: InvoiceDetailsSectionProps) {
@@ -95,10 +92,7 @@ export function InvoiceDetailsSection({
     const result = await setInvoiceDesign(initialData.id, value === FOLLOW_DEFAULT ? null : value)
     const result = await setInvoiceDesign(initialData.id, value === FOLLOW_DEFAULT ? null : value)
     setSavingDesign(false)
     setSavingDesign(false)
     if (result.success) {
     if (result.success) {
-      // An issued invoice prints from the copy it was sent with, so the choice
-      // is saved but not visible yet. Saying "updated" here sends people off
-      // to email a sheet that still carries the old design.
-      toast.success(designPinnedAt ? t('designSavedPinned') : t('designSaved'))
+      toast.success(t('designSaved'))
       router.refresh()
       router.refresh()
     } else {
     } else {
       setDesign(previous)
       setDesign(previous)
@@ -255,11 +249,6 @@ export function InvoiceDetailsSection({
               ))}
               ))}
             </SelectContent>
             </SelectContent>
           </Select>
           </Select>
-          {designPinnedAt && (
-            <p className="text-xs text-muted-foreground">
-              {t('designPinned', { date: new Date(designPinnedAt).toLocaleDateString() })}
-            </p>
-          )}
         </div>
         </div>
       )}
       )}
     </>
     </>

+ 3 - 1
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -134,7 +134,9 @@ function SortableLaborRow({
                 : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
                 : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
             }`}
             }`}
             onClick={() => updateLabor(index, 'pricingType', isService ? 'hourly' : 'service')}
             onClick={() => updateLabor(index, 'pricingType', isService ? 'hourly' : 'service')}
-            title={isService ? t('switchToHourlyHint') : t('switchToServiceHint')}
+            // Says what the line is now, then that a click changes it. It used to
+            // describe the other mode, so hovering SVC read "Hourly".
+            title={isService ? t('serviceHint') : t('hourlyHint')}
           >
           >
             {isService ? t('serviceTag') : t('hourlyTag')}
             {isService ? t('serviceTag') : t('hourlyTag')}
           </button>
           </button>

+ 1 - 1
src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

@@ -415,7 +415,7 @@ export function ScheduleTimesSection({
       : null
       : null
 
 
   const techPickerContent = (
   const techPickerContent = (
-    <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+    <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
       <Command shouldFilter={true}>
       <Command shouldFilter={true}>
         <CommandInput
         <CommandInput
           placeholder={t('searchOrCreate')}
           placeholder={t('searchOrCreate')}

+ 8 - 5
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -103,13 +103,16 @@ export function DetailsLeftColumn({
     />
     />
   )
   )
 
 
-  // No set on this job yet. The tires that came off the car are standing in the
-  // corner while the desk writes it up, so the offer to store them belongs here
-  // rather than three screens away.
+  // The tires that came off the car are standing in the corner while the desk
+  // writes the job up, so the offer to store them belongs here rather than
+  // three screens away. Rendered even once the job has its set, when it shows
+  // no button: the label dialog it opens has to outlive that moment.
   const storeTires =
   const storeTires =
-    !tireSet && tireHotelEnabled && record.vehicle ? (
+    tireHotelEnabled && record.vehicle ? (
       <StoreTiresButton
       <StoreTiresButton
         serviceRecordId={record.id}
         serviceRecordId={record.id}
+        hasSet={!!tireSet}
+        canBill={!locked}
         vehicle={{
         vehicle={{
           id: record.vehicle.id,
           id: record.vehicle.id,
           make: record.vehicle.make,
           make: record.vehicle.make,
@@ -131,7 +134,7 @@ export function DetailsLeftColumn({
           invoice sidebar cares about, so they belong here rather than spanning
           invoice sidebar cares about, so they belong here rather than spanning
           both columns. */}
           both columns. */}
       {concerns}
       {concerns}
-      {storeTires && <div className="flex justify-end">{storeTires}</div>}
+      {storeTires}
 
 
       {tireSet && (
       {tireSet && (
         <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />
         <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />

+ 0 - 3
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -52,7 +52,6 @@ interface DetailsRightColumnProps {
   telegramEnabled?: boolean
   telegramEnabled?: boolean
   designOptions?: DesignOption[]
   designOptions?: DesignOption[]
   designFollowsName?: string | null
   designFollowsName?: string | null
-  designPinnedAt?: string | null
   designFollowsRule?: DesignAutoRule | null
   designFollowsRule?: DesignAutoRule | null
 }
 }
 
 
@@ -77,7 +76,6 @@ export function DetailsRightColumn({
   telegramEnabled = false,
   telegramEnabled = false,
   designOptions = [],
   designOptions = [],
   designFollowsName = null,
   designFollowsName = null,
-  designPinnedAt = null,
   designFollowsRule = null,
   designFollowsRule = null,
 }: DetailsRightColumnProps) {
 }: DetailsRightColumnProps) {
   const router = useRouter()
   const router = useRouter()
@@ -112,7 +110,6 @@ export function DetailsRightColumn({
         designOptions={designOptions}
         designOptions={designOptions}
         designId={record.designId ?? null}
         designId={record.designId ?? null}
         designFollowsName={designFollowsName}
         designFollowsName={designFollowsName}
-        designPinnedAt={designPinnedAt}
         designFollowsRule={designFollowsRule}
         designFollowsRule={designFollowsRule}
       />
       />
       <BasicInfoSection
       <BasicInfoSection

+ 0 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -543,7 +543,6 @@ export function ServicePageClient({
     notificationHistory,
     notificationHistory,
     designOptions,
     designOptions,
     designFollowsName,
     designFollowsName,
-    designPinnedAt,
     designFollowsRule,
     designFollowsRule,
   }
   }
 
 

+ 25 - 14
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -35,7 +35,7 @@ import { WarrantySection } from '../WarrantySection'
 import { ActivityCard } from './ActivityCard'
 import { ActivityCard } from './ActivityCard'
 import { FilesMediaCard } from './FilesMediaCard'
 import { FilesMediaCard } from './FilesMediaCard'
 import { JobFactsCard } from './JobFactsCard'
 import { JobFactsCard } from './JobFactsCard'
-import { MoneyBar } from './MoneyBar'
+import { MoneyBar, MoneyLine } from './MoneyBar'
 import { StatusStepper } from './StatusStepper'
 import { StatusStepper } from './StatusStepper'
 
 
 type LeftProps = ComponentProps<typeof DetailsLeftColumn>
 type LeftProps = ComponentProps<typeof DetailsLeftColumn>
@@ -213,9 +213,11 @@ export function ModernDetails(props: ModernDetailsProps) {
   }, {})
   }, {})
 
 
   const storeTires =
   const storeTires =
-    !tireSet && tireHotelEnabled && record.vehicle ? (
+    tireHotelEnabled && record.vehicle ? (
       <StoreTiresButton
       <StoreTiresButton
         serviceRecordId={record.id}
         serviceRecordId={record.id}
+        hasSet={!!tireSet}
+        canBill={!locked}
         vehicle={{
         vehicle={{
           id: record.vehicle.id,
           id: record.vehicle.id,
           make: record.vehicle.make,
           make: record.vehicle.make,
@@ -308,7 +310,7 @@ export function ModernDetails(props: ModernDetailsProps) {
                     />
                     />
                   )}
                   )}
 
 
-                  {storeTires && <div className="flex justify-end">{storeTires}</div>}
+                  {storeTires}
                   {tireSet && (
                   {tireSet && (
                     <TireSetBanner
                     <TireSetBanner
                       set={tireSet}
                       set={tireSet}
@@ -346,6 +348,14 @@ export function ModernDetails(props: ModernDetailsProps) {
                   />
                   />
                 </Lockable>
                 </Lockable>
 
 
+                {/* Below `lg` only, where there is no money bar. */}
+                <MoneyLine
+                  total={formState.displayTotal}
+                  paid={formState.totalPaid}
+                  balance={formState.balanceDue}
+                  currencyCode={currencyCode}
+                />
+
                 <FilesMediaCard serviceRecordId={record.id} customerId={customer?.id} {...files} />
                 <FilesMediaCard serviceRecordId={record.id} customerId={customer?.id} {...files} />
 
 
                 {/* Outside the lock: the customer notes freeze with the invoice,
                 {/* Outside the lock: the customer notes freeze with the invoice,
@@ -403,16 +413,6 @@ export function ModernDetails(props: ModernDetailsProps) {
                   }
                   }
                 />
                 />
 
 
-                <Lockable locked={locked} label={lockedLabel}>
-                  <WarrantySection
-                    value={formState.warranty}
-                    onChange={formState.dirtySetWarranty}
-                    texts={props.warrantyTexts}
-                    distanceUnit={unitSystem === 'metric' ? 'km' : 'mi'}
-                    serviceDate={formState.initialData.serviceDate}
-                  />
-                </Lockable>
-
                 <AppCard
                 <AppCard
                   icon={Receipt}
                   icon={Receipt}
                   title={t('modern.invoiceTitle')}
                   title={t('modern.invoiceTitle')}
@@ -441,7 +441,6 @@ export function ModernDetails(props: ModernDetailsProps) {
                         designOptions={props.designOptions}
                         designOptions={props.designOptions}
                         designId={record.designId ?? null}
                         designId={record.designId ?? null}
                         designFollowsName={props.designFollowsName}
                         designFollowsName={props.designFollowsName}
-                        designPinnedAt={props.designPinnedAt}
                         designFollowsRule={props.designFollowsRule}
                         designFollowsRule={props.designFollowsRule}
                       />
                       />
                     </div>
                     </div>
@@ -486,6 +485,18 @@ export function ModernDetails(props: ModernDetailsProps) {
                   </div>
                   </div>
                 </AppCard>
                 </AppCard>
 
 
+                {/* Under the invoice, not over it: the warranty is set once, the
+                    invoice card is used on every job. */}
+                <Lockable locked={locked} label={lockedLabel}>
+                  <WarrantySection
+                    value={formState.warranty}
+                    onChange={formState.dirtySetWarranty}
+                    texts={props.warrantyTexts}
+                    distanceUnit={unitSystem === 'metric' ? 'km' : 'mi'}
+                    serviceDate={formState.initialData.serviceDate}
+                  />
+                </Lockable>
+
                 {record.publicToken && (
                 {record.publicToken && (
                   <SharedLinkCard
                   <SharedLinkCard
                     publicToken={record.publicToken}
                     publicToken={record.publicToken}

+ 50 - 0
src/features/vehicles/Components/service-page/modern/MoneyBar.tsx

@@ -104,3 +104,53 @@ export function MoneyBar({
     </div>
     </div>
   )
   )
 }
 }
+
+/**
+ * The same figures in the flow of the page, under the parts, wherever the bar
+ * is not drawn. The bar stays off a phone or tablet for the reason above, but
+ * that left the total in the invoice card at the very end of a single column,
+ * so somebody adding parts on a tablet never saw it move. This costs the
+ * screen nothing while it is scrolled past, and it sits where the lines are
+ * being typed.
+ */
+export function MoneyLine({
+  total,
+  paid,
+  balance,
+  currencyCode,
+}: Pick<MoneyBarProps, 'total' | 'paid' | 'balance' | 'currencyCode'>) {
+  const t = useTranslations('service.modern.moneyBar')
+  const formatCurrency = useFormatCurrency()
+  const settled = total > 0 && balance <= 0
+
+  return (
+    <dl
+      data-testid="money-line"
+      className="flex flex-wrap items-baseline justify-end gap-x-5 gap-y-1 rounded-lg border border-card-edge bg-card px-5 py-3 lg:hidden"
+    >
+      <div className="flex items-baseline gap-1.5">
+        <dt className="text-xs text-muted-foreground">{t('total')}</dt>
+        <dd className="font-mono text-sm tabular-nums">{formatCurrency(total, currencyCode)}</dd>
+      </div>
+      {paid > 0 && (
+        <div className="flex items-baseline gap-1.5">
+          <dt className="text-xs text-muted-foreground">{t('paid')}</dt>
+          <dd className="font-mono text-sm tabular-nums">{formatCurrency(paid, currencyCode)}</dd>
+        </div>
+      )}
+      <div className="flex items-baseline gap-2">
+        <dt className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
+          {settled ? t('settled') : t('balance')}
+        </dt>
+        <dd
+          className={cn(
+            'order-number text-lg font-bold leading-none tabular-nums',
+            settled && 'text-emerald-600 dark:text-emerald-400'
+          )}
+        >
+          {formatCurrency(balance, currencyCode)}
+        </dd>
+      </div>
+    </dl>
+  )
+}

+ 52 - 7
src/features/vehicles/Components/service-page/modern/StatusStepper.tsx

@@ -1,12 +1,18 @@
 'use client'
 'use client'
 
 
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
-import { Check } from 'lucide-react'
+import { Check, PackageSearch } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { statusMessageKeys } from '../../service-detail/types'
 import { statusMessageKeys } from '../../service-detail/types'
 
 
-/** The order a job moves through, the same order the status select lists. */
-const STAGES = ['pending', 'in-progress', 'waiting-parts', 'completed'] as const
+/**
+ * The stages every job passes through. Waiting for parts is not one of them:
+ * it is a hold on a job that is under way, and most jobs never have it. Drawn
+ * as a fourth stage it was ticked as done on every completed job, including
+ * the ones that never waited for anything.
+ */
+const STAGES = ['pending', 'in-progress', 'completed'] as const
+const WAITING = 'waiting-parts'
 
 
 /**
 /**
  * The job's status as a line it travels along: a marker for each stage, joined
  * The job's status as a line it travels along: a marker for each stage, joined
@@ -15,6 +21,11 @@ const STAGES = ['pending', 'in-progress', 'waiting-parts', 'completed'] as const
  * and what is still ahead is an outline. No box around it: it is a property
  * and what is still ahead is an outline. No box around it: it is a property
  * of the page, not a card on it.
  * of the page, not a card on it.
  *
  *
+ * Waiting for parts sits on the line as a hold beside "in progress": a pill
+ * that is off until somebody sets it, amber while the job is held, and gone
+ * back to off when work resumes. It is still one of the four statuses the
+ * record stores; only the drawing changed.
+ *
  * Clicking a stage sets the status exactly as the select on the classic page
  * Clicking a stage sets the status exactly as the select on the classic page
  * does, saved with the rest of the form. It sits inside the form's fieldset,
  * does, saved with the rest of the form. It sits inside the form's fieldset,
  * so a locked invoice disables it with everything else.
  * so a locked invoice disables it with everything else.
@@ -27,7 +38,10 @@ export function StatusStepper({
   onChange: (status: string) => void
   onChange: (status: string) => void
 }) {
 }) {
   const t = useTranslations('service')
   const t = useTranslations('service')
-  const current = STAGES.indexOf(status as (typeof STAGES)[number])
+  const waiting = status === WAITING
+  // A held job is under way, so it stands where "in progress" does.
+  const current = STAGES.indexOf((waiting ? 'in-progress' : status) as (typeof STAGES)[number])
+  const waitingLabel = t(`basicInfo.statusOptions.${statusMessageKeys[WAITING]}`)
 
 
   return (
   return (
     <nav
     <nav
@@ -41,18 +55,21 @@ export function StatusStepper({
         const isCurrent = i === current
         const isCurrent = i === current
         const isDone = i < current
         const isDone = i < current
         const isLast = i === STAGES.length - 1
         const isLast = i === STAGES.length - 1
+        // While the job is held, the hold is the current step and this stage
+        // is where it is held.
+        const isHeld = isCurrent && waiting
         const label = t(`basicInfo.statusOptions.${statusMessageKeys[stage]}`)
         const label = t(`basicInfo.statusOptions.${statusMessageKeys[stage]}`)
         return (
         return (
           <div key={stage} className={cn('flex min-w-0 items-center', !isLast && 'flex-1')}>
           <div key={stage} className={cn('flex min-w-0 items-center', !isLast && 'flex-1')}>
             <button
             <button
               type="button"
               type="button"
-              aria-current={isCurrent ? 'step' : undefined}
+              aria-current={isCurrent && !isHeld ? 'step' : undefined}
               // Named even where only its number shows, for screen readers and
               // Named even where only its number shows, for screen readers and
               // for the tooltip on a stage whose label is cut short.
               // for the tooltip on a stage whose label is cut short.
               aria-label={label}
               aria-label={label}
               title={label}
               title={label}
               onClick={() => {
               onClick={() => {
-                if (!isCurrent) onChange(stage)
+                if (!isCurrent || isHeld) onChange(stage)
               }}
               }}
               // Allowed to shrink, so a long label (or a longer language) is
               // Allowed to shrink, so a long label (or a longer language) is
               // cut with an ellipsis instead of running into the next stage.
               // cut with an ellipsis instead of running into the next stage.
@@ -62,7 +79,10 @@ export function StatusStepper({
                 className={cn(
                 className={cn(
                   'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums transition-colors',
                   'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums transition-colors',
                   isCurrent &&
                   isCurrent &&
+                    !isHeld &&
                     'border-primary bg-primary text-primary-foreground ring-4 ring-primary/15',
                     'border-primary bg-primary text-primary-foreground ring-4 ring-primary/15',
+                  isHeld &&
+                    'border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-400',
                   isDone && 'border-primary/60 bg-primary/15 text-primary',
                   isDone && 'border-primary/60 bg-primary/15 text-primary',
                   !isCurrent &&
                   !isCurrent &&
                     !isDone &&
                     !isDone &&
@@ -76,7 +96,7 @@ export function StatusStepper({
               <span
               <span
                 className={cn(
                 className={cn(
                   'min-w-0 truncate py-px text-[13px] leading-none',
                   'min-w-0 truncate py-px text-[13px] leading-none',
-                  isCurrent
+                  isCurrent && !isHeld
                     ? 'font-semibold text-foreground'
                     ? 'font-semibold text-foreground'
                     : 'hidden text-muted-foreground group-hover/step:text-foreground group-disabled/step:group-hover/step:text-muted-foreground @2xl:inline'
                     : 'hidden text-muted-foreground group-hover/step:text-foreground group-disabled/step:group-hover/step:text-muted-foreground @2xl:inline'
                 )}
                 )}
@@ -84,6 +104,31 @@ export function StatusStepper({
                 {label}
                 {label}
               </span>
               </span>
             </button>
             </button>
+
+            {stage === 'in-progress' && (
+              <button
+                type="button"
+                data-testid="status-waiting"
+                aria-current={waiting ? 'step' : undefined}
+                aria-pressed={waiting}
+                aria-label={waitingLabel}
+                title={waitingLabel}
+                onClick={() => onChange(waiting ? 'in-progress' : WAITING)}
+                className={cn(
+                  'group/hold ml-1 flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2 py-1 text-[12px] leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default',
+                  waiting
+                    ? 'border-amber-500/50 bg-amber-500/15 font-semibold text-amber-700 ring-4 ring-amber-500/10 dark:text-amber-400'
+                    : 'border-dashed border-border text-muted-foreground hover:border-amber-500/50 hover:text-foreground disabled:hover:border-border disabled:hover:text-muted-foreground'
+                )}
+              >
+                <PackageSearch className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+                {/* Named while it is on, and wherever there is room for it. */}
+                <span className={cn('truncate', !waiting && 'hidden @2xl:inline')}>
+                  {waitingLabel}
+                </span>
+              </button>
+            )}
+
             {!isLast && (
             {!isLast && (
               <span
               <span
                 aria-hidden="true"
                 aria-hidden="true"