Ver Fonte

Pay code at the desk, and drop-off photos from the phone (#415)

Bernt Christian Egeland há 1 semana atrás
pai
commit
4ceef676c8
33 ficheiros alterados com 1135 adições e 94 exclusões
  1. 41 4
      messages/de/service.json
  2. 41 4
      messages/en/service.json
  3. 41 4
      messages/es/service.json
  4. 41 4
      messages/fr/service.json
  5. 41 4
      messages/it/service.json
  6. 41 4
      messages/lt/service.json
  7. 41 4
      messages/nb/service.json
  8. 41 4
      messages/nl/service.json
  9. 41 4
      messages/pl/service.json
  10. 41 4
      messages/pt-BR/service.json
  11. 41 4
      messages/pt-PT/service.json
  12. 41 4
      messages/ru/service.json
  13. 41 4
      messages/tr/service.json
  14. 56 1
      src/__tests__/api/photo-handoff.test.ts
  15. 1 0
      src/app/(public)/p/[token]/page.tsx
  16. 90 1
      src/app/(public)/p/[token]/photo-handoff-client.tsx
  17. 24 7
      src/app/api/public/photo-handoff/[token]/route.ts
  18. 27 0
      src/features/payments/Actions/paymentActions.ts
  19. 7 3
      src/features/vehicles/Actions/photoHandoffActions.ts
  20. 12 5
      src/features/vehicles/Components/service-detail/PaymentsSection.tsx
  21. 5 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  22. 217 0
      src/features/vehicles/Components/service-page/PayCodeButton.tsx
  23. 19 8
      src/features/vehicles/Components/service-page/PhotoHandoffButton.tsx
  24. 18 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  25. 13 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  26. 29 3
      src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx
  27. 31 9
      src/features/vehicles/Components/service-page/modern/MediaGrid.tsx
  28. 2 0
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx
  29. 5 0
      src/features/vehicles/Components/service-page/modern/MoneyBar.tsx
  30. 4 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  31. 3 1
      src/features/vehicles/Schema/serviceSchema.ts
  32. 15 0
      src/lib/dropoff-slots.ts
  33. 24 4
      src/lib/photo-handoff.ts

+ 41 - 4
messages/de/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Fotos",
-        "diagnostics": "Diagnose"
+        "diagnostics": "Diagnose",
+        "dropoff": "Annahme"
       },
       "summary": "{count, plural, one {# Datei} other {# Dateien}} · nur markierte werden mit dem Kunden geteilt",
       "customerCanSee": "Für Kunden sichtbar",
@@ -615,13 +616,15 @@
         "image": "Fotos hinzufügen",
         "document": "Dokumente hinzufügen",
         "diagnostic": "Diagnosebericht hinzufügen",
-        "video": "Video hinzufügen"
+        "video": "Video hinzufügen",
+        "dropoff": "Annahmefotos hinzufügen"
       },
       "dropHint": "Dateien hier ablegen oder zum Durchsuchen klicken",
       "added": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
       "failed": "{name} konnte nicht hochgeladen werden",
       "wrongType": "Nicht die richtige Dateiart für diesen Tab: {names}",
-      "limit": "Limit erreicht ({count}/{max})"
+      "limit": "Limit erreicht ({count}/{max})",
+      "dropoffHint": "Das Fahrzeug, wie es ankam, mit der Aufnahmezeit jedes Fotos. Für die Werkstatt, für den Kunden nur sichtbar, wenn Sie es wählen."
     },
     "moneyBar": {
       "total": "Gesamt",
@@ -688,6 +691,40 @@
     "scope": "Der Link fügt nur Fotos und Dokumente zu diesem Auftrag hinzu und funktioniert 30 Minuten lang.",
     "scanHint": "Mit der Handykamera scannen",
     "chooseDocument": "Dokument hochladen",
-    "privacy": "Nur die Werkstatt sieht, was Sie hier hinzufügen."
+    "privacy": "Nur die Werkstatt sieht, was Sie hier hinzufügen.",
+    "dropoff": {
+      "button": "Annahmefotos",
+      "dialogTitle": "Das Fahrzeug bei der Annahme fotografieren",
+      "dialogBody": "Ein Rundgang ums Fahrzeug vor Arbeitsbeginn: vier Seiten, Kilometerstand und vorhandene Schäden. Das ist Ihr Nachweis, falls ein Kratzer später strittig wird.",
+      "step2": "Der Liste auf dem Handy folgen: ein Foto je Seite, der Kilometerstand und vorhandene Schäden.",
+      "scope": "Der Link fügt diesem Arbeitsauftrag nur Annahmefotos hinzu und funktioniert nach 30 Minuten nicht mehr.",
+      "pageTitle": "Das Fahrzeug bei der Annahme",
+      "pageBody": "Die Fotos der Reihe nach aufnehmen. Vorhandene Schäden zusätzlich in Nahaufnahme.",
+      "retake": "Neu aufnehmen",
+      "slots": {
+        "front": "Vorne",
+        "right": "Rechte Seite",
+        "rear": "Hinten",
+        "left": "Linke Seite",
+        "odometer": "Kilometerstand",
+        "other": "Schaden oder Sonstiges"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Zahlcode",
+    "title": "Per Scan bezahlen",
+    "body": "Der Kunde scannt den Code mit der Handykamera, die Rechnung öffnet sich, und er bezahlt dort.",
+    "sharesInvoice": "Mit dem Anzeigen des Codes wird die Rechnung mit dem Kunden geteilt, genau wie beim Senden. Ab dann behält die Rechnung die Angaben, die sie jetzt hat.",
+    "show": "Code anzeigen",
+    "failed": "Der Code konnte nicht erstellt werden. Bitte erneut versuchen.",
+    "toPay": "Zu zahlen",
+    "scanHint": "Mit der Handykamera scannen",
+    "copyLink": "Link kopieren",
+    "linkCopied": "Link kopiert",
+    "waiting": "Warten auf die Zahlung. Das aktualisiert sich von selbst.",
+    "paid": "Zahlung eingegangen",
+    "paidBody": "Die Zahlung steht auf dem Arbeitsauftrag.",
+    "done": "Fertig"
   }
 }

+ 41 - 4
messages/en/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Photos",
-        "diagnostics": "Diagnostics"
+        "diagnostics": "Diagnostics",
+        "dropoff": "Drop-off"
       },
       "summary": "{count, plural, one {# file} other {# files}} · shared with the customer only when marked",
       "customerCanSee": "Customer can see",
@@ -615,13 +616,15 @@
         "image": "Add photos",
         "document": "Add documents",
         "diagnostic": "Add diagnostic report",
-        "video": "Add video"
+        "video": "Add video",
+        "dropoff": "Add drop-off photos"
       },
       "dropHint": "Drop files or click to browse",
       "added": "{count, plural, one {# file added} other {# files added}}",
       "failed": "Could not upload {name}",
       "wrongType": "Not the right kind of file for this tab: {names}",
-      "limit": "Limit reached ({count}/{max})"
+      "limit": "Limit reached ({count}/{max})",
+      "dropoffHint": "The car as it arrived, with the time each photo was taken. Kept for the workshop, not shown to the customer unless you choose to."
     },
     "moneyBar": {
       "total": "Total",
@@ -688,6 +691,40 @@
     "scope": "The link only adds photos and documents to this work order, and stops working after 30 minutes.",
     "scanHint": "Scan with the phone camera",
     "chooseDocument": "Upload a document",
-    "privacy": "Only the workshop can see what you add here."
+    "privacy": "Only the workshop can see what you add here.",
+    "dropoff": {
+      "button": "Drop-off photos",
+      "dialogTitle": "Photograph the car as it arrives",
+      "dialogBody": "A walk round the car before work starts: four sides, the odometer and any damage already there. It is your record if a scratch is ever questioned.",
+      "step2": "Follow the list on the phone: one photo for each side, the odometer, and any damage.",
+      "scope": "The link only adds drop-off photos to this work order, and stops working after 30 minutes.",
+      "pageTitle": "The car as it arrives",
+      "pageBody": "Take each photo in turn. Add a close-up of any damage that is already there.",
+      "retake": "Retake",
+      "slots": {
+        "front": "Front",
+        "right": "Right side",
+        "rear": "Rear",
+        "left": "Left side",
+        "odometer": "Odometer",
+        "other": "Damage or other"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Pay code",
+    "title": "Pay by scanning",
+    "body": "The customer scans this with their phone camera, the invoice opens, and they pay there.",
+    "sharesInvoice": "Showing the code shares the invoice with the customer, the same as sending it. From then on the invoice keeps the details it has now.",
+    "show": "Show the code",
+    "failed": "The code could not be made. Try again.",
+    "toPay": "To pay",
+    "scanHint": "Scan with the phone camera",
+    "copyLink": "Copy link",
+    "linkCopied": "Link copied",
+    "waiting": "Waiting for the payment. This updates on its own.",
+    "paid": "Payment received",
+    "paidBody": "The payment is on the work order.",
+    "done": "Done"
   }
 }

+ 41 - 4
messages/es/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Fotos",
-        "diagnostics": "Diagnóstico"
+        "diagnostics": "Diagnóstico",
+        "dropoff": "Recepción"
       },
       "summary": "{count, plural, one {# archivo} other {# archivos}} · se comparten con el cliente solo si se marcan",
       "customerCanSee": "Visible para el cliente",
@@ -615,13 +616,15 @@
         "image": "Añadir fotos",
         "document": "Añadir documentos",
         "diagnostic": "Añadir informe de diagnóstico",
-        "video": "Añadir vídeo"
+        "video": "Añadir vídeo",
+        "dropoff": "Añadir fotos de recepción"
       },
       "dropHint": "Suelta archivos aquí o haz clic para explorar",
       "added": "{count, plural, one {# archivo añadido} other {# archivos añadidos}}",
       "failed": "No se pudo subir {name}",
       "wrongType": "No es el tipo de archivo adecuado para esta pestaña: {names}",
-      "limit": "Límite alcanzado ({count}/{max})"
+      "limit": "Límite alcanzado ({count}/{max})",
+      "dropoffHint": "El coche tal como llegó, con la hora de cada foto. Se guarda para el taller y no se muestra al cliente salvo que usted lo decida."
     },
     "moneyBar": {
       "total": "Total",
@@ -688,6 +691,40 @@
     "scope": "El enlace solo añade fotos y documentos a esta orden de trabajo y deja de funcionar a los 30 minutos.",
     "scanHint": "Escanea con la cámara del móvil",
     "chooseDocument": "Subir un documento",
-    "privacy": "Solo el taller ve lo que añades aquí."
+    "privacy": "Solo el taller ve lo que añades aquí.",
+    "dropoff": {
+      "button": "Fotos de recepción",
+      "dialogTitle": "Fotografiar el coche a su llegada",
+      "dialogBody": "Una vuelta al coche antes de empezar: los cuatro lados, el cuentakilómetros y los daños que ya tenga. Es su prueba si algún día se discute un arañazo.",
+      "step2": "Siga la lista en el móvil: una foto de cada lado, el cuentakilómetros y los daños.",
+      "scope": "El enlace solo añade fotos de recepción a esta orden de trabajo y deja de funcionar a los 30 minutos.",
+      "pageTitle": "El coche a su llegada",
+      "pageBody": "Haga cada foto por orden. Añada un primer plano de los daños que ya existan.",
+      "retake": "Repetir",
+      "slots": {
+        "front": "Frontal",
+        "right": "Lado derecho",
+        "rear": "Trasera",
+        "left": "Lado izquierdo",
+        "odometer": "Cuentakilómetros",
+        "other": "Daño u otro"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Código de pago",
+    "title": "Pagar escaneando",
+    "body": "El cliente lo escanea con la cámara del móvil, se abre la factura y paga allí.",
+    "sharesInvoice": "Mostrar el código comparte la factura con el cliente, igual que enviarla. Desde ese momento la factura conserva los datos que tiene ahora.",
+    "show": "Mostrar el código",
+    "failed": "No se pudo crear el código. Inténtalo de nuevo.",
+    "toPay": "A pagar",
+    "scanHint": "Escanear con la cámara del móvil",
+    "copyLink": "Copiar enlace",
+    "linkCopied": "Enlace copiado",
+    "waiting": "Esperando el pago. Se actualiza solo.",
+    "paid": "Pago recibido",
+    "paidBody": "El pago ya está en la orden de trabajo.",
+    "done": "Listo"
   }
 }

+ 41 - 4
messages/fr/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Photos",
-        "diagnostics": "Diagnostics"
+        "diagnostics": "Diagnostics",
+        "dropoff": "Réception"
       },
       "summary": "{count, plural, one {# fichier} other {# fichiers}} · montrés au client seulement s'ils sont marqués",
       "customerCanSee": "Visible par le client",
@@ -615,13 +616,15 @@
         "image": "Ajouter des photos",
         "document": "Ajouter des documents",
         "diagnostic": "Ajouter un rapport de diagnostic",
-        "video": "Ajouter une vidéo"
+        "video": "Ajouter une vidéo",
+        "dropoff": "Ajouter des photos de réception"
       },
       "dropHint": "Déposez des fichiers ou cliquez pour parcourir",
       "added": "{count, plural, one {# fichier ajouté} other {# fichiers ajoutés}}",
       "failed": "Impossible d'envoyer {name}",
       "wrongType": "Type de fichier non adapté à cet onglet : {names}",
-      "limit": "Limite atteinte ({count}/{max})"
+      "limit": "Limite atteinte ({count}/{max})",
+      "dropoffHint": "Le véhicule tel qu’il est arrivé, avec l’heure de chaque photo. Conservé pour l’atelier, non montré au client sauf si vous le choisissez."
     },
     "moneyBar": {
       "total": "Total",
@@ -688,6 +691,40 @@
     "scope": "Le lien ajoute uniquement des photos et des documents à cet ordre de travail et cesse de fonctionner après 30 minutes.",
     "scanHint": "Scannez avec l’appareil photo du téléphone",
     "chooseDocument": "Envoyer un document",
-    "privacy": "Seul l’atelier voit ce que vous ajoutez ici."
+    "privacy": "Seul l’atelier voit ce que vous ajoutez ici.",
+    "dropoff": {
+      "button": "Photos de réception",
+      "dialogTitle": "Photographier le véhicule à son arrivée",
+      "dialogBody": "Un tour du véhicule avant de commencer : les quatre côtés, le compteur et les dégâts déjà présents. C’est votre preuve si une rayure est contestée un jour.",
+      "step2": "Suivez la liste sur le téléphone : une photo par côté, le compteur et les dégâts.",
+      "scope": "Le lien n’ajoute que des photos de réception à cet ordre de réparation et cesse de fonctionner après 30 minutes.",
+      "pageTitle": "Le véhicule à son arrivée",
+      "pageBody": "Prenez chaque photo dans l’ordre. Ajoutez un gros plan des dégâts déjà présents.",
+      "retake": "Reprendre",
+      "slots": {
+        "front": "Avant",
+        "right": "Côté droit",
+        "rear": "Arrière",
+        "left": "Côté gauche",
+        "odometer": "Compteur",
+        "other": "Dégât ou autre"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Code de paiement",
+    "title": "Payer en scannant",
+    "body": "Le client le scanne avec l’appareil photo de son téléphone, la facture s’ouvre et il paie sur place.",
+    "sharesInvoice": "Afficher le code partage la facture avec le client, comme un envoi. À partir de là, la facture conserve les informations qu’elle contient maintenant.",
+    "show": "Afficher le code",
+    "failed": "Le code n’a pas pu être créé. Réessayez.",
+    "toPay": "À payer",
+    "scanHint": "Scanner avec l’appareil photo du téléphone",
+    "copyLink": "Copier le lien",
+    "linkCopied": "Lien copié",
+    "waiting": "En attente du paiement. La mise à jour est automatique.",
+    "paid": "Paiement reçu",
+    "paidBody": "Le paiement figure sur l’ordre de réparation.",
+    "done": "Terminé"
   }
 }

+ 41 - 4
messages/it/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Foto",
-        "diagnostics": "Diagnosi"
+        "diagnostics": "Diagnosi",
+        "dropoff": "Accettazione"
       },
       "summary": "{count, plural, one {# file} other {# file}} · condivisi con il cliente solo se contrassegnati",
       "customerCanSee": "Visibile al cliente",
@@ -615,13 +616,15 @@
         "image": "Aggiungi foto",
         "document": "Aggiungi documenti",
         "diagnostic": "Aggiungi rapporto diagnostico",
-        "video": "Aggiungi video"
+        "video": "Aggiungi video",
+        "dropoff": "Aggiungi foto di accettazione"
       },
       "dropHint": "Trascina qui i file o clicca per sfogliare",
       "added": "{count, plural, one {# file aggiunto} other {# file aggiunti}}",
       "failed": "Impossibile caricare {name}",
       "wrongType": "Tipo di file non adatto a questa scheda: {names}",
-      "limit": "Limite raggiunto ({count}/{max})"
+      "limit": "Limite raggiunto ({count}/{max})",
+      "dropoffHint": "L’auto com’era all’arrivo, con l’ora di ogni foto. Resta all’officina e non è mostrata al cliente, salvo tua scelta."
     },
     "moneyBar": {
       "total": "Totale",
@@ -688,6 +691,40 @@
     "scope": "Il link aggiunge solo foto e documenti a questo ordine di lavoro e smette di funzionare dopo 30 minuti.",
     "scanHint": "Inquadra con la fotocamera del telefono",
     "chooseDocument": "Carica un documento",
-    "privacy": "Solo l’officina vede ciò che aggiungi qui."
+    "privacy": "Solo l’officina vede ciò che aggiungi qui.",
+    "dropoff": {
+      "button": "Foto di accettazione",
+      "dialogTitle": "Fotografa l’auto all’arrivo",
+      "dialogBody": "Un giro intorno all’auto prima di iniziare: i quattro lati, il contachilometri e i danni già presenti. È la tua prova se un graffio viene contestato.",
+      "step2": "Segui l’elenco sul telefono: una foto per lato, il contachilometri e gli eventuali danni.",
+      "scope": "Il link aggiunge solo foto di accettazione a questo ordine di lavoro e smette di funzionare dopo 30 minuti.",
+      "pageTitle": "L’auto all’arrivo",
+      "pageBody": "Scatta ogni foto in ordine. Aggiungi un primo piano dei danni già presenti.",
+      "retake": "Rifai",
+      "slots": {
+        "front": "Anteriore",
+        "right": "Lato destro",
+        "rear": "Posteriore",
+        "left": "Lato sinistro",
+        "odometer": "Contachilometri",
+        "other": "Danno o altro"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Codice di pagamento",
+    "title": "Paga con una scansione",
+    "body": "Il cliente lo inquadra con la fotocamera del telefono, si apre la fattura e paga da lì.",
+    "sharesInvoice": "Mostrare il codice condivide la fattura con il cliente, come inviarla. Da quel momento la fattura mantiene i dati che ha ora.",
+    "show": "Mostra il codice",
+    "failed": "Impossibile creare il codice. Riprova.",
+    "toPay": "Da pagare",
+    "scanHint": "Inquadra con la fotocamera del telefono",
+    "copyLink": "Copia link",
+    "linkCopied": "Link copiato",
+    "waiting": "In attesa del pagamento. Si aggiorna da solo.",
+    "paid": "Pagamento ricevuto",
+    "paidBody": "Il pagamento è registrato sull’ordine di lavoro.",
+    "done": "Fatto"
   }
 }

+ 41 - 4
messages/lt/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Nuotraukos",
-        "diagnostics": "Diagnostika"
+        "diagnostics": "Diagnostika",
+        "dropoff": "Priėmimas"
       },
       "summary": "{count, plural, one {# failas} few {# failai} many {# failo} other {# failų}} · klientui rodomi tik pažymėti",
       "customerCanSee": "Klientas mato",
@@ -615,13 +616,15 @@
         "image": "Pridėti nuotraukų",
         "document": "Pridėti dokumentų",
         "diagnostic": "Pridėti diagnostikos ataskaitą",
-        "video": "Pridėti vaizdo įrašą"
+        "video": "Pridėti vaizdo įrašą",
+        "dropoff": "Pridėti priėmimo nuotraukų"
       },
       "dropHint": "Nutempkite failus arba spustelėkite, kad pasirinktumėte",
       "added": "{count, plural, one {Pridėtas # failas} few {Pridėti # failai} many {Pridėta # failo} other {Pridėta # failų}}",
       "failed": "Nepavyko įkelti {name}",
       "wrongType": "Netinkamas failo tipas šiam skirtukui: {names}",
-      "limit": "Pasiekta riba ({count}/{max})"
+      "limit": "Pasiekta riba ({count}/{max})",
+      "dropoffHint": "Automobilis toks, koks atvyko, su kiekvienos nuotraukos laiku. Saugoma dirbtuvėms, klientui nerodoma, nebent pasirinksite."
     },
     "moneyBar": {
       "total": "Iš viso",
@@ -688,6 +691,40 @@
     "scope": "Nuoroda tik prideda nuotraukas ir dokumentus prie šio darbo užsakymo ir nustoja veikti po 30 minučių.",
     "scanHint": "Nuskenuokite telefono kamera",
     "chooseDocument": "Įkelti dokumentą",
-    "privacy": "Tai, ką čia pridėsite, mato tik servisas."
+    "privacy": "Tai, ką čia pridėsite, mato tik servisas.",
+    "dropoff": {
+      "button": "Priėmimo nuotraukos",
+      "dialogTitle": "Nufotografuokite automobilį atvykus",
+      "dialogBody": "Apeikite automobilį prieš pradėdami darbą: keturios pusės, odometras ir jau esami pažeidimai. Tai jūsų įrodymas, jei kada nors kils ginčas dėl įbrėžimo.",
+      "step2": "Sekite sąrašą telefone: po nuotrauką kiekvienai pusei, odometras ir pažeidimai.",
+      "scope": "Nuoroda tik prideda priėmimo nuotraukas prie šio darbo užsakymo ir nustoja veikti po 30 minučių.",
+      "pageTitle": "Automobilis atvykus",
+      "pageBody": "Fotografuokite iš eilės. Pridėkite jau esamų pažeidimų stambų planą.",
+      "retake": "Perfotografuoti",
+      "slots": {
+        "front": "Priekis",
+        "right": "Dešinė pusė",
+        "rear": "Galas",
+        "left": "Kairė pusė",
+        "odometer": "Odometras",
+        "other": "Pažeidimas ar kita"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Mokėjimo kodas",
+    "title": "Mokėti nuskenavus",
+    "body": "Klientas nuskenuoja kodą telefono kamera, atsidaro sąskaita ir jis sumoka ten.",
+    "sharesInvoice": "Parodžius kodą, sąskaita bendrinama su klientu, taip pat kaip ją išsiuntus. Nuo tada sąskaita išlaiko dabartinius duomenis.",
+    "show": "Rodyti kodą",
+    "failed": "Kodo sukurti nepavyko. Bandykite dar kartą.",
+    "toPay": "Mokėti",
+    "scanHint": "Nuskenuokite telefono kamera",
+    "copyLink": "Kopijuoti nuorodą",
+    "linkCopied": "Nuoroda nukopijuota",
+    "waiting": "Laukiama mokėjimo. Atsinaujina savaime.",
+    "paid": "Mokėjimas gautas",
+    "paidBody": "Mokėjimas įrašytas darbo užsakyme.",
+    "done": "Atlikta"
   }
 }

+ 41 - 4
messages/nb/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Bilder",
-        "diagnostics": "Diagnostikk"
+        "diagnostics": "Diagnostikk",
+        "dropoff": "Innlevering"
       },
       "summary": "{count, plural, one {# fil} other {# filer}} · deles med kunden bare når de er merket",
       "customerCanSee": "Kunden kan se",
@@ -615,13 +616,15 @@
         "image": "Legg til bilder",
         "document": "Legg til dokumenter",
         "diagnostic": "Legg til diagnoserapport",
-        "video": "Legg til video"
+        "video": "Legg til video",
+        "dropoff": "Legg til innleveringsbilder"
       },
       "dropHint": "Slipp filer her eller klikk for å velge",
       "added": "{count, plural, one {# fil lagt til} other {# filer lagt til}}",
       "failed": "Kunne ikke laste opp {name}",
       "wrongType": "Feil filtype for denne fanen: {names}",
-      "limit": "Grensen er nådd ({count}/{max})"
+      "limit": "Grensen er nådd ({count}/{max})",
+      "dropoffHint": "Bilen slik den kom inn, med tidspunktet for hvert bilde. Beholdes av verkstedet og vises ikke til kunden med mindre du velger det."
     },
     "moneyBar": {
       "total": "Totalt",
@@ -688,6 +691,40 @@
     "scope": "Lenken kan bare legge til bilder og dokumenter på denne arbeidsordren, og slutter å virke etter 30 minutter.",
     "scanHint": "Skann med telefonkameraet",
     "chooseDocument": "Last opp et dokument",
-    "privacy": "Bare verkstedet ser det du legger til her."
+    "privacy": "Bare verkstedet ser det du legger til her.",
+    "dropoff": {
+      "button": "Bilder ved innlevering",
+      "dialogTitle": "Fotografer bilen når den kommer",
+      "dialogBody": "En runde rundt bilen før arbeidet starter: fire sider, kilometerstanden og skader som allerede finnes. Det er dokumentasjonen din hvis en ripe blir diskutert.",
+      "step2": "Følg listen på telefonen: ett bilde av hver side, kilometerstanden og eventuelle skader.",
+      "scope": "Lenken legger bare til innleveringsbilder på denne arbeidsordren, og slutter å virke etter 30 minutter.",
+      "pageTitle": "Bilen ved innlevering",
+      "pageBody": "Ta bildene etter tur. Legg til et nærbilde av skader som allerede finnes.",
+      "retake": "Ta på nytt",
+      "slots": {
+        "front": "Foran",
+        "right": "Høyre side",
+        "rear": "Bak",
+        "left": "Venstre side",
+        "odometer": "Kilometerstand",
+        "other": "Skade eller annet"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Betalingskode",
+    "title": "Betal ved å skanne",
+    "body": "Kunden skanner denne med mobilkameraet, fakturaen åpnes, og de betaler der.",
+    "sharesInvoice": "Når koden vises, deles fakturaen med kunden, på samme måte som når den sendes. Fra da av beholder fakturaen opplysningene den har nå.",
+    "show": "Vis koden",
+    "failed": "Koden kunne ikke lages. Prøv igjen.",
+    "toPay": "Å betale",
+    "scanHint": "Skann med mobilkameraet",
+    "copyLink": "Kopier lenke",
+    "linkCopied": "Lenke kopiert",
+    "waiting": "Venter på betalingen. Dette oppdateres av seg selv.",
+    "paid": "Betaling mottatt",
+    "paidBody": "Betalingen er ført på arbeidsordren.",
+    "done": "Ferdig"
   }
 }

+ 41 - 4
messages/nl/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Foto's",
-        "diagnostics": "Diagnose"
+        "diagnostics": "Diagnose",
+        "dropoff": "Inname"
       },
       "summary": "{count, plural, one {# bestand} other {# bestanden}} · alleen gemarkeerde worden met de klant gedeeld",
       "customerCanSee": "Zichtbaar voor klant",
@@ -615,13 +616,15 @@
         "image": "Foto's toevoegen",
         "document": "Documenten toevoegen",
         "diagnostic": "Diagnostisch verslag toevoegen",
-        "video": "Video toevoegen"
+        "video": "Video toevoegen",
+        "dropoff": "Innamefoto’s toevoegen"
       },
       "dropHint": "Sleep bestanden hierheen of klik om te bladeren",
       "added": "{count, plural, one {# bestand toegevoegd} other {# bestanden toegevoegd}}",
       "failed": "Kon {name} niet uploaden",
       "wrongType": "Niet het juiste soort bestand voor dit tabblad: {names}",
-      "limit": "Limiet bereikt ({count}/{max})"
+      "limit": "Limiet bereikt ({count}/{max})",
+      "dropoffHint": "De auto zoals die binnenkwam, met het tijdstip van elke foto. Bewaard voor de werkplaats, niet zichtbaar voor de klant tenzij u dat kiest."
     },
     "moneyBar": {
       "total": "Totaal",
@@ -688,6 +691,40 @@
     "scope": "De link voegt alleen foto’s en documenten toe aan deze werkorder en werkt 30 minuten.",
     "scanHint": "Scan met de camera van de telefoon",
     "chooseDocument": "Document uploaden",
-    "privacy": "Alleen de werkplaats ziet wat u hier toevoegt."
+    "privacy": "Alleen de werkplaats ziet wat u hier toevoegt.",
+    "dropoff": {
+      "button": "Innamefoto’s",
+      "dialogTitle": "Fotografeer de auto bij binnenkomst",
+      "dialogBody": "Een rondje om de auto voordat het werk begint: vier zijden, de kilometerstand en schade die er al is. Het is uw bewijs als er ooit over een kras wordt gediscussieerd.",
+      "step2": "Volg de lijst op de telefoon: één foto per zijde, de kilometerstand en eventuele schade.",
+      "scope": "De link voegt alleen innamefoto’s toe aan deze werkorder en werkt na 30 minuten niet meer.",
+      "pageTitle": "De auto bij binnenkomst",
+      "pageBody": "Maak de foto’s op volgorde. Voeg een close-up toe van schade die er al is.",
+      "retake": "Opnieuw",
+      "slots": {
+        "front": "Voorkant",
+        "right": "Rechterzijde",
+        "rear": "Achterkant",
+        "left": "Linkerzijde",
+        "odometer": "Kilometerstand",
+        "other": "Schade of anders"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Betaalcode",
+    "title": "Betalen door te scannen",
+    "body": "De klant scant dit met de camera van de telefoon, de factuur opent en daar wordt betaald.",
+    "sharesInvoice": "Door de code te tonen wordt de factuur met de klant gedeeld, net als bij verzenden. Vanaf dan behoudt de factuur de gegevens die ze nu heeft.",
+    "show": "Code tonen",
+    "failed": "De code kon niet worden gemaakt. Probeer het opnieuw.",
+    "toPay": "Te betalen",
+    "scanHint": "Scan met de camera van de telefoon",
+    "copyLink": "Link kopiëren",
+    "linkCopied": "Link gekopieerd",
+    "waiting": "Wachten op de betaling. Dit werkt zichzelf bij.",
+    "paid": "Betaling ontvangen",
+    "paidBody": "De betaling staat op de werkorder.",
+    "done": "Klaar"
   }
 }

+ 41 - 4
messages/pl/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Zdjęcia",
-        "diagnostics": "Diagnostyka"
+        "diagnostics": "Diagnostyka",
+        "dropoff": "Przyjęcie"
       },
       "summary": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}} · widoczne dla klienta tylko po oznaczeniu",
       "customerCanSee": "Widoczne dla klienta",
@@ -615,13 +616,15 @@
         "image": "Dodaj zdjęcia",
         "document": "Dodaj dokumenty",
         "diagnostic": "Dodaj raport diagnostyczny",
-        "video": "Dodaj wideo"
+        "video": "Dodaj wideo",
+        "dropoff": "Dodaj zdjęcia przyjęcia"
       },
       "dropHint": "Upuść pliki lub kliknij, aby wybrać",
       "added": "{count, plural, one {Dodano # plik} few {Dodano # pliki} many {Dodano # plików} other {Dodano # pliku}}",
       "failed": "Nie udało się przesłać {name}",
       "wrongType": "Nieodpowiedni rodzaj pliku dla tej karty: {names}",
-      "limit": "Osiągnięto limit ({count}/{max})"
+      "limit": "Osiągnięto limit ({count}/{max})",
+      "dropoffHint": "Auto w stanie, w jakim przyjechało, z godziną każdego zdjęcia. Dla warsztatu, niewidoczne dla klienta, chyba że zdecydujesz inaczej."
     },
     "moneyBar": {
       "total": "Razem",
@@ -688,6 +691,40 @@
     "scope": "Link tylko dodaje zdjęcia i dokumenty do tego zlecenia i przestaje działać po 30 minutach.",
     "scanHint": "Zeskanuj aparatem telefonu",
     "chooseDocument": "Prześlij dokument",
-    "privacy": "To, co tu dodasz, widzi tylko warsztat."
+    "privacy": "To, co tu dodasz, widzi tylko warsztat.",
+    "dropoff": {
+      "button": "Zdjęcia przyjęcia",
+      "dialogTitle": "Sfotografuj auto przy przyjęciu",
+      "dialogBody": "Obejście auta przed rozpoczęciem pracy: cztery strony, licznik i istniejące uszkodzenia. To Twój dowód, gdyby rysa została kiedyś zakwestionowana.",
+      "step2": "Postępuj według listy w telefonie: po jednym zdjęciu każdej strony, licznik i uszkodzenia.",
+      "scope": "Link dodaje tylko zdjęcia przyjęcia do tego zlecenia i przestaje działać po 30 minutach.",
+      "pageTitle": "Auto przy przyjęciu",
+      "pageBody": "Rób zdjęcia po kolei. Dodaj zbliżenie istniejących uszkodzeń.",
+      "retake": "Powtórz",
+      "slots": {
+        "front": "Przód",
+        "right": "Prawa strona",
+        "rear": "Tył",
+        "left": "Lewa strona",
+        "odometer": "Licznik",
+        "other": "Uszkodzenie lub inne"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Kod płatności",
+    "title": "Zapłać, skanując",
+    "body": "Klient skanuje kod aparatem telefonu, otwiera się faktura i płaci na miejscu.",
+    "sharesInvoice": "Pokazanie kodu udostępnia fakturę klientowi, tak samo jak jej wysłanie. Od tej chwili faktura zachowuje dane, które ma teraz.",
+    "show": "Pokaż kod",
+    "failed": "Nie udało się utworzyć kodu. Spróbuj ponownie.",
+    "toPay": "Do zapłaty",
+    "scanHint": "Zeskanuj aparatem telefonu",
+    "copyLink": "Kopiuj link",
+    "linkCopied": "Link skopiowany",
+    "waiting": "Oczekiwanie na płatność. Odświeża się samo.",
+    "paid": "Płatność otrzymana",
+    "paidBody": "Płatność jest na zleceniu.",
+    "done": "Gotowe"
   }
 }

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

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Fotos",
-        "diagnostics": "Diagnóstico"
+        "diagnostics": "Diagnóstico",
+        "dropoff": "Entrada"
       },
       "summary": "{count, plural, one {# arquivo} other {# arquivos}} · o cliente só vê o que estiver marcado",
       "customerCanSee": "Visível para o cliente",
@@ -615,13 +616,15 @@
         "image": "Adicionar fotos",
         "document": "Adicionar documentos",
         "diagnostic": "Adicionar relatório de diagnóstico",
-        "video": "Adicionar vídeo"
+        "video": "Adicionar vídeo",
+        "dropoff": "Adicionar fotos de entrada"
       },
       "dropHint": "Arraste arquivos aqui ou clique para procurar",
       "added": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}",
       "failed": "Não foi possível enviar {name}",
       "wrongType": "Tipo de arquivo errado para esta aba: {names}",
-      "limit": "Limite atingido ({count}/{max})"
+      "limit": "Limite atingido ({count}/{max})",
+      "dropoffHint": "O carro como chegou, com a hora de cada foto. Fica com a oficina e não é mostrado ao cliente, a menos que você escolha."
     },
     "moneyBar": {
       "total": "Total",
@@ -688,6 +691,40 @@
     "scope": "O link só adiciona fotos e documentos a esta ordem de serviço e para de funcionar após 30 minutos.",
     "scanHint": "Escaneie com a câmera do celular",
     "chooseDocument": "Enviar um documento",
-    "privacy": "Só a oficina vê o que você adiciona aqui."
+    "privacy": "Só a oficina vê o que você adiciona aqui.",
+    "dropoff": {
+      "button": "Fotos de entrada",
+      "dialogTitle": "Fotografe o carro na chegada",
+      "dialogBody": "Uma volta no carro antes de começar: os quatro lados, o hodômetro e os danos que já existem. É a sua prova se um risco for questionado.",
+      "step2": "Siga a lista no celular: uma foto de cada lado, o hodômetro e os danos.",
+      "scope": "O link só adiciona fotos de entrada a esta ordem de serviço e para de funcionar após 30 minutos.",
+      "pageTitle": "O carro na chegada",
+      "pageBody": "Tire cada foto em ordem. Adicione um close dos danos que já existem.",
+      "retake": "Refazer",
+      "slots": {
+        "front": "Frente",
+        "right": "Lado direito",
+        "rear": "Traseira",
+        "left": "Lado esquerdo",
+        "odometer": "Hodômetro",
+        "other": "Dano ou outro"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Código de pagamento",
+    "title": "Pagar escaneando",
+    "body": "O cliente escaneia com a câmera do celular, a fatura abre e ele paga ali.",
+    "sharesInvoice": "Mostrar o código compartilha a fatura com o cliente, do mesmo modo que enviá-la. A partir daí a fatura mantém os dados que tem agora.",
+    "show": "Mostrar o código",
+    "failed": "Não foi possível criar o código. Tente novamente.",
+    "toPay": "A pagar",
+    "scanHint": "Escaneie com a câmera do celular",
+    "copyLink": "Copiar link",
+    "linkCopied": "Link copiado",
+    "waiting": "Aguardando o pagamento. Atualiza sozinho.",
+    "paid": "Pagamento recebido",
+    "paidBody": "O pagamento está na ordem de serviço.",
+    "done": "Concluir"
   }
 }

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

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Fotografias",
-        "diagnostics": "Diagnóstico"
+        "diagnostics": "Diagnóstico",
+        "dropoff": "Receção"
       },
       "summary": "{count, plural, one {# ficheiro} other {# ficheiros}} · o cliente só vê o que estiver marcado",
       "customerCanSee": "Visível para o cliente",
@@ -615,13 +616,15 @@
         "image": "Adicionar fotografias",
         "document": "Adicionar documentos",
         "diagnostic": "Adicionar relatório de diagnóstico",
-        "video": "Adicionar vídeo"
+        "video": "Adicionar vídeo",
+        "dropoff": "Adicionar fotos de receção"
       },
       "dropHint": "Largue ficheiros aqui ou clique para procurar",
       "added": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}",
       "failed": "Não foi possível carregar {name}",
       "wrongType": "Tipo de ficheiro errado para este separador: {names}",
-      "limit": "Limite atingido ({count}/{max})"
+      "limit": "Limite atingido ({count}/{max})",
+      "dropoffHint": "A viatura como chegou, com a hora de cada foto. Fica para a oficina e não é mostrada ao cliente, salvo se o escolher."
     },
     "moneyBar": {
       "total": "Total",
@@ -688,6 +691,40 @@
     "scope": "A ligação só adiciona fotografias e documentos a esta ordem de trabalho e deixa de funcionar ao fim de 30 minutos.",
     "scanHint": "Leia com a câmara do telemóvel",
     "chooseDocument": "Carregar um documento",
-    "privacy": "Só a oficina vê o que adiciona aqui."
+    "privacy": "Só a oficina vê o que adiciona aqui.",
+    "dropoff": {
+      "button": "Fotos de receção",
+      "dialogTitle": "Fotografe a viatura à chegada",
+      "dialogBody": "Uma volta à viatura antes de começar: os quatro lados, o conta-quilómetros e os danos já existentes. É a sua prova se um risco for contestado.",
+      "step2": "Siga a lista no telemóvel: uma foto de cada lado, o conta-quilómetros e os danos.",
+      "scope": "A ligação só adiciona fotos de receção a esta ordem de reparação e deixa de funcionar ao fim de 30 minutos.",
+      "pageTitle": "A viatura à chegada",
+      "pageBody": "Tire cada foto por ordem. Adicione um grande plano dos danos já existentes.",
+      "retake": "Repetir",
+      "slots": {
+        "front": "Frente",
+        "right": "Lado direito",
+        "rear": "Traseira",
+        "left": "Lado esquerdo",
+        "odometer": "Conta-quilómetros",
+        "other": "Dano ou outro"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Código de pagamento",
+    "title": "Pagar com leitura do código",
+    "body": "O cliente lê o código com a câmara do telemóvel, a fatura abre e paga aí.",
+    "sharesInvoice": "Mostrar o código partilha a fatura com o cliente, tal como enviá-la. A partir daí a fatura mantém os dados que tem agora.",
+    "show": "Mostrar o código",
+    "failed": "Não foi possível criar o código. Tente novamente.",
+    "toPay": "A pagar",
+    "scanHint": "Leia com a câmara do telemóvel",
+    "copyLink": "Copiar ligação",
+    "linkCopied": "Ligação copiada",
+    "waiting": "A aguardar o pagamento. Atualiza sozinho.",
+    "paid": "Pagamento recebido",
+    "paidBody": "O pagamento está na ordem de reparação.",
+    "done": "Concluir"
   }
 }

+ 41 - 4
messages/ru/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Фото",
-        "diagnostics": "Диагностика"
+        "diagnostics": "Диагностика",
+        "dropoff": "Приёмка"
       },
       "summary": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}} · клиент видит только отмеченные",
       "customerCanSee": "Видно клиенту",
@@ -615,13 +616,15 @@
         "image": "Добавить фото",
         "document": "Добавить документы",
         "diagnostic": "Добавить диагностический отчёт",
-        "video": "Добавить видео"
+        "video": "Добавить видео",
+        "dropoff": "Добавить фото приёмки"
       },
       "dropHint": "Перетащите файлы или нажмите, чтобы выбрать",
       "added": "{count, plural, one {Добавлен # файл} few {Добавлено # файла} many {Добавлено # файлов} other {Добавлено # файла}}",
       "failed": "Не удалось загрузить {name}",
       "wrongType": "Эти файлы не подходят для этой вкладки: {names}",
-      "limit": "Достигнут лимит ({count}/{max})"
+      "limit": "Достигнут лимит ({count}/{max})",
+      "dropoffHint": "Автомобиль в том виде, в каком он прибыл, со временем каждого снимка. Хранится для мастерской и не показывается клиенту, если вы не решите иначе."
     },
     "moneyBar": {
       "total": "Итого",
@@ -688,6 +691,40 @@
     "scope": "Ссылка только добавляет фото и документы в этот заказ-наряд и перестаёт работать через 30 минут.",
     "scanHint": "Отсканируйте камерой телефона",
     "chooseDocument": "Загрузить документ",
-    "privacy": "То, что вы добавляете здесь, видит только сервис."
+    "privacy": "То, что вы добавляете здесь, видит только сервис.",
+    "dropoff": {
+      "button": "Фото при приёмке",
+      "dialogTitle": "Сфотографируйте автомобиль при приёмке",
+      "dialogBody": "Обход автомобиля до начала работ: четыре стороны, одометр и уже имеющиеся повреждения. Это ваше подтверждение, если царапину когда-нибудь оспорят.",
+      "step2": "Следуйте списку на телефоне: по одному фото каждой стороны, одометр и повреждения.",
+      "scope": "Ссылка только добавляет фото приёмки к этому заказ-наряду и перестаёт работать через 30 минут.",
+      "pageTitle": "Автомобиль при приёмке",
+      "pageBody": "Делайте снимки по порядку. Добавьте крупный план уже имеющихся повреждений.",
+      "retake": "Переснять",
+      "slots": {
+        "front": "Спереди",
+        "right": "Правая сторона",
+        "rear": "Сзади",
+        "left": "Левая сторона",
+        "odometer": "Одометр",
+        "other": "Повреждение или другое"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Код для оплаты",
+    "title": "Оплата по коду",
+    "body": "Клиент сканирует код камерой телефона, открывается счёт, и он оплачивает его там.",
+    "sharesInvoice": "Показ кода открывает счёт клиенту, так же как отправка. С этого момента счёт сохраняет данные, которые в нём есть сейчас.",
+    "show": "Показать код",
+    "failed": "Не удалось создать код. Попробуйте ещё раз.",
+    "toPay": "К оплате",
+    "scanHint": "Сканируйте камерой телефона",
+    "copyLink": "Копировать ссылку",
+    "linkCopied": "Ссылка скопирована",
+    "waiting": "Ожидаем оплату. Обновляется автоматически.",
+    "paid": "Оплата получена",
+    "paidBody": "Оплата отражена в заказ-наряде.",
+    "done": "Готово"
   }
 }

+ 41 - 4
messages/tr/service.json

@@ -605,7 +605,8 @@
     "media": {
       "tabs": {
         "photos": "Fotoğraflar",
-        "diagnostics": "Tanı"
+        "diagnostics": "Tanı",
+        "dropoff": "Teslim alma"
       },
       "summary": "{count, plural, one {# dosya} other {# dosya}} · yalnızca işaretlenenler müşteriyle paylaşılır",
       "customerCanSee": "Müşteri görebilir",
@@ -615,13 +616,15 @@
         "image": "Fotoğraf ekle",
         "document": "Belge ekle",
         "diagnostic": "Tanı raporu ekle",
-        "video": "Video ekle"
+        "video": "Video ekle",
+        "dropoff": "Teslim alma fotoğrafı ekle"
       },
       "dropHint": "Dosyaları bırakın veya göz atmak için tıklayın",
       "added": "{count, plural, one {# dosya eklendi} other {# dosya eklendi}}",
       "failed": "{name} yüklenemedi",
       "wrongType": "Bu sekmeye uygun dosya türü değil: {names}",
-      "limit": "Sınıra ulaşıldı ({count}/{max})"
+      "limit": "Sınıra ulaşıldı ({count}/{max})",
+      "dropoffHint": "Aracın geldiği hali, her fotoğrafın çekildiği saatle. Atölye için saklanır, siz seçmedikçe müşteriye gösterilmez."
     },
     "moneyBar": {
       "total": "Toplam",
@@ -688,6 +691,40 @@
     "scope": "Bağlantı yalnızca bu iş emrine fotoğraf ve belge ekler ve 30 dakika sonra çalışmaz.",
     "scanHint": "Telefon kamerasıyla okutun",
     "chooseDocument": "Belge yükle",
-    "privacy": "Buraya eklediklerinizi yalnızca servis görür."
+    "privacy": "Buraya eklediklerinizi yalnızca servis görür.",
+    "dropoff": {
+      "button": "Teslim alma fotoğrafları",
+      "dialogTitle": "Aracı geldiği haliyle fotoğraflayın",
+      "dialogBody": "İşe başlamadan önce aracın çevresinde bir tur: dört taraf, kilometre sayacı ve mevcut hasarlar. Bir çizik tartışma konusu olursa kanıtınız budur.",
+      "step2": "Telefondaki listeyi izleyin: her taraf için bir fotoğraf, kilometre sayacı ve hasarlar.",
+      "scope": "Bağlantı bu iş emrine yalnızca teslim alma fotoğrafı ekler ve 30 dakika sonra çalışmaz.",
+      "pageTitle": "Araç geldiği haliyle",
+      "pageBody": "Fotoğrafları sırayla çekin. Mevcut hasarların yakın çekimini ekleyin.",
+      "retake": "Yeniden çek",
+      "slots": {
+        "front": "Ön",
+        "right": "Sağ taraf",
+        "rear": "Arka",
+        "left": "Sol taraf",
+        "odometer": "Kilometre sayacı",
+        "other": "Hasar veya diğer"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Ödeme kodu",
+    "title": "Tarayarak öde",
+    "body": "Müşteri bunu telefon kamerasıyla tarar, fatura açılır ve oradan öder.",
+    "sharesInvoice": "Kodu göstermek, faturayı göndermekle aynı şekilde müşteriyle paylaşır. O andan sonra fatura şu anki bilgilerini korur.",
+    "show": "Kodu göster",
+    "failed": "Kod oluşturulamadı. Tekrar deneyin.",
+    "toPay": "Ödenecek",
+    "scanHint": "Telefon kamerasıyla tarayın",
+    "copyLink": "Bağlantıyı kopyala",
+    "linkCopied": "Bağlantı kopyalandı",
+    "waiting": "Ödeme bekleniyor. Kendiliğinden güncellenir.",
+    "paid": "Ödeme alındı",
+    "paidBody": "Ödeme iş emrine işlendi.",
+    "done": "Tamam"
   }
 }

+ 56 - 1
src/__tests__/api/photo-handoff.test.ts

@@ -41,12 +41,15 @@ const HANDOFF = {
   organizationId: 'org-1',
   serviceRecordId: 'job-1',
   concernId: 'concern-1',
+  purpose: 'photos' as const,
   userId: 'user-1',
 }
+const DROPOFF = { ...HANDOFF, concernId: null, purpose: 'dropoff' as const }
 
-function post(token: string, file?: File) {
+function post(token: string, file?: File, slot?: string) {
   const body = new FormData()
   if (file) body.append('file', file)
+  if (slot) body.append('slot', slot)
   return POST(
     new Request(`http://app.test/api/public/photo-handoff/${token}`, { method: 'POST', body }),
     {
@@ -255,4 +258,56 @@ describe('the phone upload route', () => {
     }
     expect(Math.abs(sinceShown.gte.getTime() - now)).toBeLessThan(1000)
   })
+  // The walk round the car as it arrives: a code of its own, signed as such.
+  it('carries its purpose in the signature, and an ordinary code stays an ordinary code', () => {
+    const dropoff = verifyPhotoHandoffToken(createPhotoHandoffToken(DROPOFF).token)
+    expect(dropoff.ok && dropoff.handoff.purpose).toBe('dropoff')
+    const ordinary = verifyPhotoHandoffToken(createPhotoHandoffToken(HANDOFF).token)
+    expect(ordinary.ok && ordinary.handoff.purpose).toBe('photos')
+  })
+
+  it('files a drop-off photo on its own, off the invoice, named for its shot', async () => {
+    const { token } = createPhotoHandoffToken(DROPOFF)
+    const res = await post(token, photo(), 'odometer')
+    expect(res.status).toBe(201)
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data).toMatchObject({
+      category: 'dropoff',
+      includeInInvoice: false,
+      description: 'odometer',
+      concernId: null,
+    })
+  })
+
+  it('takes nothing the phone says about the shot except a name from the list', async () => {
+    const { token } = createPhotoHandoffToken(DROPOFF)
+    await post(token, photo(), '<b>front</b>')
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data.description).toBe('other')
+  })
+
+  it('ignores a shot name on an ordinary code', async () => {
+    const { token } = createPhotoHandoffToken(HANDOFF)
+    await post(token, photo(), 'front')
+    expect(vi.mocked(db.serviceAttachment.create).mock.calls[0][0].data).toMatchObject({
+      category: 'image',
+      includeInInvoice: true,
+      description: null,
+    })
+  })
+
+  it('refuses a document on a drop-off code', async () => {
+    const { token } = createPhotoHandoffToken(DROPOFF)
+    const res = await post(token, pdf())
+    expect(res.status).toBe(400)
+    expect(await res.json()).toEqual({ code: 'type' })
+    expect(db.serviceAttachment.create).not.toHaveBeenCalled()
+  })
+
+  it('stops at thirty drop-off photos on a job, whatever the plan allows for photos', async () => {
+    vi.mocked(db.serviceAttachment.count).mockResolvedValueOnce(30).mockResolvedValueOnce(0)
+    const { token } = createPhotoHandoffToken(DROPOFF)
+    expect(await (await post(token, photo(), 'front')).json()).toEqual({ code: 'limit' })
+    expect(vi.mocked(db.serviceAttachment.count).mock.calls[0][0]?.where).toMatchObject({
+      category: 'dropoff',
+    })
+  })
 })

+ 1 - 0
src/app/(public)/p/[token]/page.tsx

@@ -59,6 +59,7 @@ export default async function PhotoHandoffPage({ params }: { params: Promise<{ t
     <PhotoHandoffClient
       token={token}
       brand={brand}
+      purpose={check.handoff.purpose}
       job={{
         number: job.invoiceNumber?.trim() || null,
         plate: job.vehicle?.licensePlate ?? null,

+ 90 - 1
src/app/(public)/p/[token]/photo-handoff-client.tsx

@@ -13,6 +13,7 @@ import {
   TriangleAlert,
 } from 'lucide-react'
 import { compressImage } from '@/lib/compress-image'
+import { DROPOFF_SLOTS, type DropoffSlot } from '@/lib/dropoff-slots'
 import { cn } from '@/lib/utils'
 
 type Problem = 'expired' | 'invalid'
@@ -22,6 +23,8 @@ interface Upload {
   file: File
   /** An object URL for a photo; a document has no picture to show. */
   preview: string | null
+  /** Which shot of the drop-off walk this is; absent on an ordinary code. */
+  slot?: DropoffSlot
   status: 'queued' | 'uploading' | 'added' | 'failed'
   error?: string
 }
@@ -53,6 +56,10 @@ function initials(name: string): string {
  * it is picked, one at a time so a weak signal outdoors is not swamped, and
  * each tile says whether it made it, with a retry when it did not.
  *
+ * A drop-off code asks for something narrower: the walk round the car as it
+ * arrives. The page lists the shots, each opens the camera, and each is ticked
+ * off as it lands, so whoever holds the phone knows when they are done.
+ *
  * It is the workshop's page as far as the person holding the phone can tell:
  * its name and logo at the top, the car underneath, and nothing of the job's
  * contents.
@@ -61,11 +68,13 @@ export function PhotoHandoffClient({
   token,
   brand,
   job,
+  purpose = 'photos',
   problem: initialProblem,
 }: {
   token: string
   brand: { name: string; logoUrl: string | null } | null
   job?: { number: string | null; plate: string | null; vehicle: string | null }
+  purpose?: 'photos' | 'dropoff'
   problem?: Problem
 }) {
   const t = useTranslations('service.photoHandoff')
@@ -76,6 +85,10 @@ export function PhotoHandoffClient({
   const libraryRef = useRef<HTMLInputElement>(null)
   const documentRef = useRef<HTMLInputElement>(null)
   const sending = useRef(false)
+  // The shot the camera was opened for. A ref, because the file input's
+  // change event arrives after the camera closes and must still know.
+  const pendingSlot = useRef<DropoffSlot | null>(null)
+  const dropoff = purpose === 'dropoff'
   const uploadsRef = useRef<Upload[]>([])
   uploadsRef.current = uploads
 
@@ -99,6 +112,7 @@ export function PhotoHandoffClient({
       }
       const body = new FormData()
       body.append('file', file)
+      if (item.slot) body.append('slot', item.slot)
       try {
         const res = await fetch(`/api/public/photo-handoff/${encodeURIComponent(token)}`, {
           method: 'POST',
@@ -156,11 +170,22 @@ export function PhotoHandoffClient({
         key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
         file,
         preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
+        slot: pendingSlot.current ?? undefined,
         status: 'queued' as const,
       }))
     setUploads((prev) => [...added.reverse(), ...prev])
+    pendingSlot.current = null
+  }
+
+  const shoot = (slot: DropoffSlot) => {
+    pendingSlot.current = slot
+    cameraRef.current?.click()
   }
 
+  /** How the latest photo for a shot is doing; 'other' is never finished. */
+  const slotState = (slot: DropoffSlot) =>
+    slot === 'other' ? undefined : uploads.find((item) => item.slot === slot)?.status
+
   const onPicked = (e: React.ChangeEvent<HTMLInputElement>) => {
     add(e.target.files)
     e.target.value = ''
@@ -197,7 +222,9 @@ export function PhotoHandoffClient({
         )}
 
         <section className="rounded-2xl border border-border/70 bg-card p-5 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_16px_40px_-20px_rgb(0_0_0/0.25)]">
-          <h1 className="text-xl font-semibold tracking-tight">{t('pageTitle')}</h1>
+          <h1 className="text-xl font-semibold tracking-tight">
+            {t(dropoff ? 'dropoff.pageTitle' : 'pageTitle')}
+          </h1>
           {job && (job.number || job.plate || job.vehicle) && (
             <div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm text-muted-foreground">
               {job.plate && (
@@ -229,6 +256,68 @@ export function PhotoHandoffClient({
                 </p>
               </div>
             </div>
+          ) : dropoff ? (
+            <>
+              <p className="mt-3 text-sm text-muted-foreground">{t('dropoff.pageBody')}</p>
+              <ul className="mt-4 space-y-2" data-testid="dropoff-slots">
+                {DROPOFF_SLOTS.map((slot) => {
+                  const state = slotState(slot)
+                  const done = state === 'added'
+                  return (
+                    <li key={slot}>
+                      <button
+                        type="button"
+                        onClick={() => shoot(slot)}
+                        data-slot={slot}
+                        data-state={state ?? 'todo'}
+                        className={cn(
+                          'flex h-14 w-full cursor-pointer items-center gap-3 rounded-xl border px-4 text-left text-base font-medium transition-colors active:bg-muted',
+                          done ? 'border-emerald-600/40 bg-emerald-600/10' : 'bg-background',
+                          slot === 'other' && 'border-dashed'
+                        )}
+                      >
+                        <span
+                          className={cn(
+                            'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
+                            done ? 'bg-emerald-600 text-white' : 'bg-primary/10 text-primary'
+                          )}
+                        >
+                          {done ? (
+                            <Check className="h-4 w-4" aria-hidden="true" />
+                          ) : state === 'uploading' || state === 'queued' ? (
+                            <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
+                          ) : (
+                            <Camera className="h-4 w-4" aria-hidden="true" />
+                          )}
+                        </span>
+                        <span className="min-w-0 flex-1 truncate">
+                          {t(`dropoff.slots.${slot}`)}
+                        </span>
+                        {done && (
+                          <span className="shrink-0 text-xs font-normal text-muted-foreground">
+                            {t('dropoff.retake')}
+                          </span>
+                        )}
+                        {state === 'failed' && (
+                          <span className="shrink-0 text-xs font-normal text-destructive">
+                            {t('notAdded')}
+                          </span>
+                        )}
+                      </button>
+                    </li>
+                  )
+                })}
+              </ul>
+              <input
+                ref={cameraRef}
+                type="file"
+                accept="image/*"
+                capture="environment"
+                className="hidden"
+                data-testid="photo-handoff-camera"
+                onChange={onPicked}
+              />
+            </>
           ) : (
             <>
               <div className="mt-5 grid grid-cols-2 gap-2.5">

+ 24 - 7
src/app/api/public/photo-handoff/[token]/route.ts

@@ -7,6 +7,7 @@ import { db } from '@/lib/db'
 import { getFeatures } from '@/lib/features'
 import { discardUnsavedUpload } from '@/lib/files/manager'
 import { compressPhoto } from '@/lib/image-upload.server'
+import { type DropoffSlot, isDropoffSlot } from '@/lib/dropoff-slots'
 import { PHOTO_HANDOFF_TTL_SECONDS, verifyPhotoHandoffToken } from '@/lib/photo-handoff'
 import { rateLimit } from '@/lib/rate-limit'
 import { uploadsRoot } from '@/lib/upload-root'
@@ -53,6 +54,8 @@ const MAX_PDF_BYTES = 10 * 1024 * 1024
 const PER_CODE = 30
 const PHOTOS_PER_JOB = 100
 const PDFS_PER_JOB = 20
+/** A walk round a car is six shots and a few of the damage; thirty is generous. */
+const DROPOFF_PER_JOB = 30
 const RATE_PER_MINUTE = 30
 
 function refuse(status: number, code: string) {
@@ -72,7 +75,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   const { token } = await params
   const check = verifyPhotoHandoffToken(token)
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
-  const { organizationId, serviceRecordId, concernId, userId, expiresAt } = check.handoff
+  const { organizationId, serviceRecordId, concernId, purpose, userId, expiresAt } = check.handoff
+  const dropoff = purpose === 'dropoff'
 
   const job = await db.serviceRecord.findFirst({
     where: { id: serviceRecordId, organizationId },
@@ -93,10 +97,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   const file = form?.get('file')
   if (!(file instanceof File)) return refuse(400, 'noFile')
   const isPdf = file.type === PDF_TYPE
+  // The walk round the car is photographs. Paperwork has the ordinary code.
+  if (dropoff && isPdf) return refuse(400, 'type')
   if (!isPdf && !PHOTO_TYPES.has(file.type)) return refuse(400, 'type')
   if (file.size === 0) return refuse(400, 'empty')
   if (file.size > (isPdf ? MAX_PDF_BYTES : MAX_BYTES)) return refuse(400, 'tooLarge')
-  const category = isPdf ? 'document' : 'image'
+  const category = dropoff ? 'dropoff' : isPdf ? 'document' : 'image'
+  // Which shot this is, from the fixed list and nothing else: the value is
+  // stored and shown on the work order.
+  const slotField = form?.get('slot')
+  const slot: DropoffSlot | null = !dropoff ? null : isDropoffSlot(slotField) ? slotField : 'other'
 
   // Counted before anything is decoded or written: the cheap refusals first.
   const issuedAt = new Date((expiresAt - PHOTO_HANDOFF_TTL_SECONDS) * 1000)
@@ -106,14 +116,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
     db.serviceAttachment.count({
       where: {
         serviceRecordId: job.id,
-        category: { in: ['image', 'document'] },
+        category: { in: ['image', 'document', 'dropoff'] },
         createdAt: { gte: issuedAt },
       },
     }),
   ])
-  const jobCap = isPdf
-    ? Math.min(features.maxDocumentsPerService, PDFS_PER_JOB)
-    : Math.min(features.maxImagesPerService, PHOTOS_PER_JOB)
+  const jobCap = dropoff
+    ? DROPOFF_PER_JOB
+    : isPdf
+      ? Math.min(features.maxDocumentsPerService, PDFS_PER_JOB)
+      : Math.min(features.maxImagesPerService, PHOTOS_PER_JOB)
   if (onJob >= jobCap) return refuse(409, 'limit')
   if (sinceCode >= PER_CODE) return refuse(409, 'codeLimit')
 
@@ -155,7 +167,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
         // added on the page does. A document does not: an invoice download
         // appends every attached PDF, and a customer's paperwork should not
         // reach the invoice before the workshop has looked at it.
-        includeInInvoice: !isPdf,
+        //
+        // Nor does a drop-off photo. It is the workshop's record of the car as
+        // it arrived, for the day somebody says a scratch is new; the desk can
+        // still choose to show one.
+        includeInInvoice: !isPdf && !dropoff,
+        description: slot,
         serviceRecordId: job.id,
         concernId: concern?.id ?? null,
       },

+ 27 - 0
src/features/payments/Actions/paymentActions.ts

@@ -132,3 +132,30 @@ export async function deletePayment(paymentId: string) {
     }
   )
 }
+
+/**
+ * How much has been paid on one invoice, for the pay code dialog to watch
+ * while a customer pays on their own phone. The payment is booked by the
+ * vendor's webhook, not by this browser, so the page has to ask; asking for
+ * one sum every few seconds is a great deal cheaper than re-rendering the
+ * work order to find out.
+ */
+export async function getPaidTotal(serviceRecordId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const record = await db.serviceRecord.findFirst({
+        where: { id: serviceRecordId, organizationId },
+        select: { manuallyPaid: true, payments: { select: { amount: true } } },
+      })
+      if (!record) throw new Error('Service record not found')
+      return {
+        paid: Math.round(record.payments.reduce((sum, p) => sum + p.amount, 0) * 100) / 100,
+        payments: record.payments.length,
+        manuallyPaid: record.manuallyPaid,
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 7 - 3
src/features/vehicles/Actions/photoHandoffActions.ts

@@ -3,7 +3,7 @@
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { createPhotoHandoffToken } from '@/lib/photo-handoff'
+import { createPhotoHandoffToken, type PhotoHandoffPurpose } from '@/lib/photo-handoff'
 
 /**
  * A code for the work order's "Add photos from phone": a signed link that
@@ -16,6 +16,8 @@ import { createPhotoHandoffToken } from '@/lib/photo-handoff'
 export async function createPhotoHandoffLink(input: {
   serviceRecordId: string
   concernId?: string | null
+  /** 'dropoff' for the walk round the car as it arrives; see lib/photo-handoff.ts. */
+  purpose?: PhotoHandoffPurpose
 }) {
   return withAuth(
     async ({ organizationId, userId }) => {
@@ -38,7 +40,9 @@ export async function createPhotoHandoffLink(input: {
       const { token, expiresAt } = createPhotoHandoffToken({
         organizationId,
         serviceRecordId: job.id,
-        concernId,
+        // Drop-off photos are of the whole car, never of one complaint.
+        concernId: input.purpose === 'dropoff' ? null : concernId,
+        purpose: input.purpose === 'dropoff' ? 'dropoff' : 'photos',
         userId,
       })
       return { token, expiresAt: expiresAt.toISOString() }
@@ -65,7 +69,7 @@ export async function countPhotosSince(input: { serviceRecordId: string; since:
         where: {
           serviceRecordId: input.serviceRecordId,
           serviceRecord: { organizationId },
-          category: { in: ['image', 'document'] },
+          category: { in: ['image', 'document', 'dropoff'] },
           createdAt: { gte: since },
         },
       })

+ 12 - 5
src/features/vehicles/Components/service-detail/PaymentsSection.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useEffect, useRef, useState } from 'react'
+import { type ReactNode, useEffect, useRef, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
@@ -46,6 +46,8 @@ interface PaymentsSectionProps {
    * brought into view. The number only has to change; its value means nothing.
    */
   openFormSignal?: number
+  /** One more way to be paid, drawn beside "Record payment": the pay code. */
+  extraAction?: ReactNode
 }
 
 export function PaymentsSection({
@@ -62,6 +64,7 @@ export function PaymentsSection({
   paymentLoading,
   deletingPayment,
   openFormSignal = 0,
+  extraAction = null,
 }: PaymentsSectionProps) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('service.payments')
@@ -258,10 +261,13 @@ export function PaymentsSection({
         {showForm ? (
           paymentForm
         ) : (
-          <Button type="button" className="h-11 w-full" onClick={() => setShowForm(true)}>
-            <Plus className="mr-1 h-4 w-4" />
-            {t('recordPayment')}
-          </Button>
+          <div className="flex flex-col gap-2">
+            <Button type="button" className="h-11 w-full" onClick={() => setShowForm(true)}>
+              <Plus className="mr-1 h-4 w-4" />
+              {t('recordPayment')}
+            </Button>
+            {extraAction && <div className="[&>button]:h-10 [&>button]:w-full">{extraAction}</div>}
+          </div>
         )}
       </div>
     )
@@ -311,6 +317,7 @@ export function PaymentsSection({
               <Plus className="mr-1 h-3 w-3" />
               {t('recordPayment')}
             </Button>
+            {extraAction && <div className="[&>button]:h-7 [&>button]:text-xs">{extraAction}</div>}
           </div>
         </div>
 

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

@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react'
 import { PartsEditor } from '../service-edit/PartsEditor'
 import { LaborEditor } from '../service-edit/LaborEditor'
 import { ConcernsSection } from '../service-edit/ConcernsSection'
@@ -58,6 +59,8 @@ interface DetailsLeftColumnProps {
   jobClock: JobClock
   /** A locked invoice: the notes editors go read-only, which the fieldset cannot do for them. */
   locked?: boolean
+  /** The pay code button, when online payment is connected. Drawn where payments are taken. */
+  payCode?: ReactNode
 }
 
 export function DetailsLeftColumn({
@@ -85,6 +88,7 @@ export function DetailsLeftColumn({
   onShowExistingObservations,
   jobClock,
   locked = false,
+  payCode = null,
 }: DetailsLeftColumnProps) {
   const tClock = useTranslations('timeTracking.job')
   // Which concerns somebody has actually looked at. Counted here rather than
@@ -213,6 +217,7 @@ export function DetailsLeftColumn({
           onTogglePaid={actions.handleTogglePaid}
           paymentLoading={actions.paymentLoading}
           deletingPayment={actions.deletingPayment}
+          extraAction={payCode}
         />
         <InvoiceSummary
           hasPartItems={formState.partItems.length > 0}

+ 217 - 0
src/features/vehicles/Components/service-page/PayCodeButton.tsx

@@ -0,0 +1,217 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { QRCodeSVG } from 'qrcode.react'
+import { Check, CheckCircle2, Copy, Loader2, QrCode, ShieldCheck } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { generatePublicLink } from '@/features/vehicles/Actions/serviceActions'
+import { getPaidTotal } from '@/features/payments/Actions/paymentActions'
+
+/** How often the dialog asks whether the customer has paid. */
+const POLL_MS = 4_000
+
+/**
+ * "Pay code": the invoice's own share link as a QR code, for the customer
+ * standing at the desk. They scan it, the invoice opens on their phone, and
+ * they pay there with whatever the workshop has connected. Nothing new is
+ * built behind it: it is the link "Send to customer" makes, shown instead of
+ * sent, so it follows the same rule and showing it for the first time shares
+ * the invoice. That is said before the link is made, not after.
+ *
+ * While the code is up the dialog watches for the payment, which a vendor's
+ * webhook books, and says so when it lands: the desk should not have to
+ * reload a page to find out the customer is done.
+ *
+ * Only offered where online payment is connected; without it the link opens
+ * an invoice with no way to pay, which is what "Send to customer" is for.
+ */
+export function PayCodeButton({
+  serviceRecordId,
+  organizationId,
+  publicToken,
+  balance,
+  currencyCode,
+  size = 'sm',
+  className,
+}: {
+  serviceRecordId: string
+  organizationId: string
+  publicToken: string | null
+  balance: number
+  currencyCode: string
+  size?: 'sm' | 'default'
+  className?: string
+}) {
+  const t = useTranslations('service.payCode')
+  const router = useRouter()
+  const formatCurrency = useFormatCurrency()
+  const [open, setOpen] = useState(false)
+  const [token, setToken] = useState(publicToken)
+  const [making, setMaking] = useState(false)
+  const [failed, setFailed] = useState(false)
+  const [copied, setCopied] = useState(false)
+  const [paidNow, setPaidNow] = useState(false)
+  const paidAtOpen = useRef<number | null>(null)
+
+  useEffect(() => setToken(publicToken), [publicToken])
+
+  useEffect(() => {
+    if (open) return
+    setFailed(false)
+    setPaidNow(false)
+    paidAtOpen.current = null
+  }, [open])
+
+  // Watch for the payment while the code is on screen.
+  useEffect(() => {
+    if (!open || !token || paidNow) return
+    let cancelled = false
+    const ask = async () => {
+      const result = await getPaidTotal(serviceRecordId).catch(() => null)
+      if (cancelled || !result?.success || !result.data) return
+      if (paidAtOpen.current === null) {
+        paidAtOpen.current = result.data.paid
+        return
+      }
+      if (result.data.paid > paidAtOpen.current) {
+        setPaidNow(true)
+        router.refresh()
+      }
+    }
+    void ask()
+    const timer = setInterval(ask, POLL_MS)
+    return () => {
+      cancelled = true
+      clearInterval(timer)
+    }
+  }, [open, token, paidNow, serviceRecordId, router])
+
+  useEffect(() => {
+    if (!copied) return
+    const timer = setTimeout(() => setCopied(false), 1500)
+    return () => clearTimeout(timer)
+  }, [copied])
+
+  const url =
+    token && typeof window !== 'undefined'
+      ? `${window.location.origin}/share/invoice/${organizationId}/${token}`
+      : null
+
+  const makeLink = async () => {
+    setMaking(true)
+    setFailed(false)
+    const result = await generatePublicLink(serviceRecordId)
+    setMaking(false)
+    if (result.success && result.data) {
+      setToken(result.data.token)
+      router.refresh()
+    } else {
+      setFailed(true)
+    }
+  }
+
+  return (
+    <>
+      <Button
+        type="button"
+        variant="outline"
+        size={size}
+        className={className}
+        onClick={() => setOpen(true)}
+        data-testid="pay-code-open"
+      >
+        <QrCode className="mr-1.5 h-4 w-4" aria-hidden="true" />
+        {t('button')}
+      </Button>
+
+      <Dialog open={open} onOpenChange={setOpen}>
+        <DialogContent className="sm:max-w-md" data-testid="pay-code-dialog">
+          <DialogHeader>
+            <DialogTitle>{t('title')}</DialogTitle>
+            <DialogDescription>{t('body')}</DialogDescription>
+          </DialogHeader>
+
+          {paidNow ? (
+            <div className="flex flex-col items-center gap-3 py-8 text-center">
+              <CheckCircle2 className="h-14 w-14 text-emerald-600 dark:text-emerald-400" />
+              <p className="text-lg font-semibold">{t('paid')}</p>
+              <p className="text-sm text-muted-foreground">{t('paidBody')}</p>
+              <Button type="button" onClick={() => setOpen(false)}>
+                {t('done')}
+              </Button>
+            </div>
+          ) : !url ? (
+            // Not shared yet. Showing the code shares it, so that is said
+            // first and the link is only made on a deliberate click.
+            <div className="space-y-4">
+              <div className="flex gap-3 rounded-lg border border-card-edge bg-muted/40 p-3">
+                <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                <p className="text-sm text-muted-foreground">{t('sharesInvoice')}</p>
+              </div>
+              {failed && <p className="text-sm text-destructive">{t('failed')}</p>}
+              <Button type="button" className="w-full" onClick={makeLink} disabled={making}>
+                {making && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {t('show')}
+              </Button>
+            </div>
+          ) : (
+            <div className="flex flex-col items-center gap-4">
+              <div className="text-center">
+                <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
+                  {t('toPay')}
+                </p>
+                <p className="order-number text-3xl font-bold tabular-nums">
+                  {formatCurrency(Math.max(0, balance), currencyCode)}
+                </p>
+              </div>
+              {/* White behind the code in either theme: a phone camera reads
+                  dark on light. */}
+              <div className="rounded-2xl bg-white p-4 shadow-[0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-12px_rgb(0_0_0/0.25)] ring-1 ring-black/5">
+                <QRCodeSVG value={url} size={220} marginSize={0} />
+              </div>
+              <p className="text-xs font-medium text-muted-foreground">{t('scanHint')}</p>
+              <div className="flex w-full items-center gap-1">
+                <input
+                  readOnly
+                  value={url}
+                  aria-label={t('copyLink')}
+                  onFocus={(e) => e.target.select()}
+                  className="min-w-0 flex-1 truncate rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px]"
+                />
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="icon-sm"
+                  aria-label={copied ? t('linkCopied') : t('copyLink')}
+                  title={copied ? t('linkCopied') : t('copyLink')}
+                  onClick={() => {
+                    navigator.clipboard
+                      .writeText(url)
+                      .then(() => setCopied(true))
+                      .catch(() => undefined)
+                  }}
+                >
+                  {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
+                </Button>
+              </div>
+              <p className="flex items-center gap-2 text-xs text-muted-foreground">
+                <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
+                {t('waiting')}
+              </p>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 19 - 8
src/features/vehicles/Components/service-page/PhotoHandoffButton.tsx

@@ -43,6 +43,7 @@ export function PhotoHandoffButton({
   serviceRecordId,
   concernId = null,
   concernLabel,
+  purpose = 'photos',
   variant = 'button',
   disabled = false,
   disabledReason,
@@ -51,6 +52,11 @@ export function PhotoHandoffButton({
   concernId?: string | null
   /** The concern in the customer's words, so both screens say which one the photos go under. */
   concernLabel?: string
+  /**
+   * 'dropoff' hands the phone the walk round the car as it arrives: a fixed
+   * list of shots, kept under the job's Drop-off tab and off the invoice.
+   */
+  purpose?: 'photos' | 'dropoff'
   /** A small button in a card header, or a quiet text link in a row. */
   variant?: 'button' | 'link'
   disabled?: boolean
@@ -74,7 +80,7 @@ export function PhotoHandoffButton({
     setError(false)
     setReceived(0)
     receivedRef.current = 0
-    createPhotoHandoffLink({ serviceRecordId, concernId })
+    createPhotoHandoffLink({ serviceRecordId, concernId, purpose })
       .then((result) => {
         if (cancelled) return
         if (!result.success || !result.data) {
@@ -96,7 +102,7 @@ export function PhotoHandoffButton({
     return () => {
       cancelled = true
     }
-  }, [open, serviceRecordId, concernId])
+  }, [open, serviceRecordId, concernId, purpose])
 
   // Photos arriving while the code is up are shown as they land.
   useEffect(() => {
@@ -132,7 +138,7 @@ export function PhotoHandoffButton({
         className="flex cursor-pointer items-center gap-1 rounded px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-default disabled:opacity-50 disabled:hover:text-muted-foreground"
       >
         <Smartphone className="h-3 w-3" aria-hidden="true" />
-        {t('button')}
+        {t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
       </button>
     ) : (
       <Button
@@ -145,7 +151,7 @@ export function PhotoHandoffButton({
         className="h-7 gap-1.5 text-xs"
       >
         <Smartphone className="h-3.5 w-3.5" aria-hidden="true" />
-        {t('button')}
+        {t(purpose === 'dropoff' ? 'dropoff.button' : 'button')}
       </Button>
     )
 
@@ -160,7 +166,8 @@ export function PhotoHandoffButton({
         : concernLabel.trim()
       : null
 
-  const steps = [t('step1'), t('step2'), t('step3')]
+  const dropoff = purpose === 'dropoff'
+  const steps = [t('step1'), t(dropoff ? 'dropoff.step2' : 'step2'), t('step3')]
 
   return (
     <>
@@ -178,8 +185,12 @@ export function PhotoHandoffButton({
                   <Smartphone className="h-5 w-5" aria-hidden="true" />
                 </span>
                 <div className="space-y-1.5">
-                  <DialogTitle className="text-lg">{t('dialogTitle')}</DialogTitle>
-                  <DialogDescription>{t('dialogBody')}</DialogDescription>
+                  <DialogTitle className="text-lg">
+                    {t(dropoff ? 'dropoff.dialogTitle' : 'dialogTitle')}
+                  </DialogTitle>
+                  <DialogDescription>
+                    {t(dropoff ? 'dropoff.dialogBody' : 'dialogBody')}
+                  </DialogDescription>
                 </div>
               </DialogHeader>
 
@@ -219,7 +230,7 @@ export function PhotoHandoffButton({
                 <div className="space-y-1 text-xs text-muted-foreground">
                   <p className="font-medium text-foreground">{t('noSignIn')}</p>
                   <p>{t('customerToo')}</p>
-                  <p>{t('scope')}</p>
+                  <p>{t(dropoff ? 'dropoff.scope' : 'scope')}</p>
                 </div>
               </div>
             </div>

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

@@ -3,6 +3,7 @@
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { InvoiceDesignMenu } from './InvoiceDesignMenu'
+import { PayCodeButton } from './PayCodeButton'
 import { useState, useCallback, useMemo, useRef, useEffect, type ComponentProps } from 'react'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
@@ -76,6 +77,7 @@ export function ServicePageClient({
   unitSystem,
   warrantyTexts,
   tireHotelEnabled = false,
+  onlinePayments = false,
   videoCall = { link: null, providers: [] },
   tireThresholds,
   defaultTaxRate,
@@ -89,6 +91,7 @@ export function ServicePageClient({
   orgMembers = [],
   currentUserName,
   imageAttachmentsForManager,
+  dropoffAttachments = [],
   videoAttachments,
   documentAttachments,
   maxImagesPerService,
@@ -495,6 +498,19 @@ export function ServicePageClient({
   // One set of props for each column, handed to whichever layout is showing:
   // the classic two columns take them as they are, the overhauled page takes
   // both and lays the same sections out its own way.
+  // The invoice's link as a code for the customer at the desk. One element,
+  // drawn wherever a payment is taken: beside "Record payment" on both
+  // layouts, and on the overhauled page's money bar.
+  const payCode = onlinePayments ? (
+    <PayCodeButton
+      serviceRecordId={record.id}
+      organizationId={organizationId}
+      publicToken={record.publicToken ?? null}
+      balance={formState.balanceDue}
+      currencyCode={currencyCode}
+    />
+  ) : null
+
   const leftColumnProps: ComponentProps<typeof DetailsLeftColumn> = {
     formState,
     actions,
@@ -520,6 +536,7 @@ export function ServicePageClient({
     onShowExistingObservations: () => obsControlsRef.current?.onShowExistingObservations(),
     jobClock,
     locked: lockState.locked,
+    payCode,
   }
 
   const rightColumnProps: ComponentProps<typeof DetailsRightColumn> = {
@@ -623,6 +640,7 @@ export function ServicePageClient({
               }}
               files={{
                 images: imageAttachmentsForManager,
+                dropoff: dropoffAttachments,
                 videos: videoAttachments,
                 documents: documentAttachments,
                 maxImages: maxImagesPerService,

+ 13 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -32,6 +32,7 @@ import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { ServicePageClient } from '@/features/vehicles/Components/service-page/ServicePageClient'
 import { listDesignOptions } from '@/features/invoice-designer/Actions/documentDesignActions'
 import { rendersFromIssue } from '@/features/invoices/Lib/issuedInvoice'
+import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 import { PageHeader } from '@/components/page-header'
 import { getTranslations } from 'next-intl/server'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -146,6 +147,11 @@ export async function ServiceRecordPage({
     (b) => ({ id: b.id, name: b.name })
   )
   const organizationId = authContext?.organizationId || ''
+  // Whether a customer can pay the shared invoice online, which is what the
+  // pay code at the desk is for.
+  const onlinePayments = organizationId
+    ? (await offeredPaymentProviders(organizationId)).length > 0
+    : false
   const lockState = organizationId
     ? await getInvoiceLockState(serviceId, organizationId)
     : { locked: false, reason: null, unlockedAt: null }
@@ -303,6 +309,11 @@ export async function ServiceRecordPage({
         a.category === 'image' || (a.category === 'tire_hotel' && a.fileType.startsWith('image/'))
     )
     .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
+  // The car as it arrived. Its own list: these are not the job's photos and
+  // do not go on the invoice unless somebody chooses to show one.
+  const dropoffAttachments = allAttachments
+    .filter((a) => a.category === 'dropoff')
+    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? false }))
   const videoAttachments = allAttachments
     .filter((a) => a.category === 'video')
     .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
@@ -367,6 +378,7 @@ export async function ServiceRecordPage({
         orgMembers={orgMembersResult.success && orgMembersResult.data ? orgMembersResult.data : []}
         currentUserName={currentUserName}
         imageAttachmentsForManager={imageAttachmentsForManager}
+        dropoffAttachments={dropoffAttachments}
         videoAttachments={videoAttachments}
         documentAttachments={documentAttachments}
         maxImagesPerService={features?.maxImagesPerService ?? 999999}
@@ -379,6 +391,7 @@ export async function ServiceRecordPage({
         aiTranscription={aiTranscription}
         dictationMode={dictation.mode}
         tireHotelEnabled={tireHotel.enabled}
+        onlinePayments={onlinePayments}
         tireThresholds={{
           summerReplace: tireHotel.summerReplaceMm,
           winterReplace: tireHotel.winterReplaceMm,

+ 29 - 3
src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx

@@ -9,7 +9,10 @@ import { MediaGrid } from './MediaGrid'
 import { PhotoHandoffButton } from '../PhotoHandoffButton'
 import type { ServicePageClientProps } from '../service-page-types'
 
-type FileTab = 'images' | 'documents' | 'diagnostics' | 'video' | 'statusReports'
+type FileTab = 'images' | 'dropoff' | 'documents' | 'diagnostics' | 'video' | 'statusReports'
+
+/** A walk round a car is a handful of shots; this is the phone route's cap too. */
+const MAX_DROPOFF = 30
 
 /** A plan with no cap says so with a number nobody will reach. */
 const capOf = (max: number) => (max >= 999999 ? undefined : max)
@@ -18,6 +21,8 @@ interface FilesMediaCardProps {
   serviceRecordId: string
   customerId?: string
   images: ServicePageClientProps['imageAttachmentsForManager']
+  /** Photos of the car as it arrived. Absent on a page that does not load them. */
+  dropoff?: ServicePageClientProps['imageAttachmentsForManager']
   videos: ServicePageClientProps['videoAttachments']
   documents: ServicePageClientProps['documentAttachments']
   maxImages: number
@@ -50,6 +55,7 @@ export function FilesMediaCard({
   serviceRecordId,
   customerId,
   images,
+  dropoff = [],
   videos,
   documents,
   maxImages,
@@ -74,6 +80,7 @@ export function FilesMediaCard({
       count: images.length,
       max: capOf(maxImages),
     },
+    { value: 'dropoff', label: t('modern.media.tabs.dropoff'), count: dropoff.length },
     {
       value: 'documents',
       label: t('header.tabs.documents'),
@@ -97,7 +104,7 @@ export function FilesMediaCard({
         ]
       : []),
   ]
-  const total = images.length + videos.length + documents.length
+  const total = images.length + dropoff.length + videos.length + documents.length
 
   return (
     <div
@@ -122,7 +129,12 @@ export function FilesMediaCard({
               {t('modern.media.summary', { count: total })}
             </span>
             {/* For the photo taken out at the car: a code the phone scans. */}
-            <PhotoHandoffButton serviceRecordId={serviceRecordId} />
+            {/* On the drop-off tab the same button hands over the walk round
+                the car instead. */}
+            <PhotoHandoffButton
+              serviceRecordId={serviceRecordId}
+              purpose={tab === 'dropoff' ? 'dropoff' : 'photos'}
+            />
           </div>
         }
         contentClassName="p-0"
@@ -167,6 +179,20 @@ export function FilesMediaCard({
               customerId={customerId}
             />
           )}
+          {tab === 'dropoff' && (
+            <>
+              <p className="px-5 pt-4 text-[13px] text-muted-foreground">
+                {t('modern.media.dropoffHint')}
+              </p>
+              <MediaGrid
+                key="dropoff"
+                kind="dropoff"
+                serviceRecordId={serviceRecordId}
+                files={dropoff}
+                max={MAX_DROPOFF}
+              />
+            </>
+          )}
           {tab === 'documents' && (
             <MediaGrid
               key="document"

+ 31 - 9
src/features/vehicles/Components/service-page/modern/MediaGrid.tsx

@@ -23,13 +23,14 @@ import { updateServiceAttachment } from '@/features/vehicles/Actions/updateServi
 import { deleteServiceAttachment } from '@/features/vehicles/Actions/serviceActions'
 import { SendPhotoToWhatsapp } from '@/features/whatsapp/Components/SendPhotoToWhatsapp'
 import { compressImage } from '@/lib/compress-image'
+import { isDropoffSlot } from '@/lib/dropoff-slots'
 import { useFormatDate } from '@/lib/use-format-date'
 import { cn } from '@/lib/utils'
 import { ImageCarousel } from '../../service-detail/ImageCarousel'
 import { formatFileSize } from '../../service-detail/types'
 import type { Attachment } from '../service-page-types'
 
-export type MediaKind = 'image' | 'document' | 'diagnostic' | 'video'
+export type MediaKind = 'image' | 'document' | 'diagnostic' | 'video' | 'dropoff'
 
 const KINDS: Record<
   MediaKind,
@@ -53,6 +54,14 @@ const KINDS: Record<
     types: () => true,
     tint: 'bg-teal-500/10 text-teal-700 dark:text-teal-300',
   },
+  // The car as it arrived. Photographs, like 'image', but a record for the
+  // workshop rather than something for the invoice.
+  dropoff: {
+    icon: Camera,
+    accept: '.jpg,.jpeg,.png,.webp',
+    types: (type) => ['image/jpeg', 'image/png', 'image/webp'].includes(type),
+    tint: 'bg-sky-500/10 text-sky-700 dark:text-sky-300',
+  },
   video: {
     icon: Film,
     accept: '.mp4,.webm,.mov',
@@ -84,7 +93,7 @@ interface MediaGridProps {
 export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: MediaGridProps) {
   const t = useTranslations('service')
   const router = useRouter()
-  const { formatDate } = useFormatDate()
+  const { formatDate, formatDateTime } = useFormatDate()
   const inputRef = useRef<HTMLInputElement>(null)
   const [items, setItems] = useState<Attachment[]>(files)
   const [uploading, setUploading] = useState(false)
@@ -103,6 +112,15 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
   const config = KINDS[kind]
   const Icon = config.icon
   const atLimit = max !== undefined && items.length >= max
+  const isPhotos = kind === 'image' || kind === 'dropoff'
+
+  // A drop-off photo taken from the phone's list carries which shot it is as
+  // its description ('front', 'odometer'). That is shown in words, and stays
+  // the shot's name until somebody types a caption of their own.
+  const captionOf = (file: Attachment) =>
+    kind === 'dropoff' && isDropoffSlot(file.description)
+      ? t(`photoHandoff.dropoff.slots.${file.description}`)
+      : file.description || ''
 
   const upload = async (list: FileList | File[]) => {
     let chosen = Array.from(list)
@@ -119,7 +137,7 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
       }
       if (chosen.length > room) {
         toast.warning(
-          t(kind === 'image' ? 'images.onlyUploading' : 'documents.onlyUploading', {
+          t(isPhotos ? 'images.onlyUploading' : 'documents.onlyUploading', {
             count: room,
           })
         )
@@ -132,7 +150,7 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
     let added = 0
     for (let file of chosen) {
       try {
-        if (kind === 'image') file = await compressImage(file)
+        if (isPhotos) file = await compressImage(file)
         const body = new FormData()
         body.append('file', file)
         const res = await fetch('/api/protected/upload/service-files', { method: 'POST', body })
@@ -153,7 +171,8 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
             // Shown to the customer unless somebody says otherwise, as the
             // classic tabs do. For a video that means the shared link, which
             // plays it; the printed invoice has nothing to draw for one.
-            includeInInvoice: true,
+            // A drop-off photo is the workshop's own record, so it starts hidden.
+            includeInInvoice: kind !== 'dropoff',
           },
         })
         if (result.success && result.data) {
@@ -198,7 +217,7 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
   }
 
   const saveCaption = async (file: Attachment, caption: string) => {
-    if ((file.description ?? '') === caption) return
+    if ((file.description ?? '') === caption || captionOf(file) === caption) return
     setItems((prev) => prev.map((f) => (f.id === file.id ? { ...f, description: caption } : f)))
     await updateServiceAttachment({ id: file.id, description: caption })
   }
@@ -316,7 +335,7 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
               {/* The caption is the file's name until somebody gives it a
                   better one; it reads as text and is a field when clicked. */}
               <input
-                defaultValue={file.description || ''}
+                defaultValue={captionOf(file)}
                 placeholder={file.fileName}
                 aria-label={t('images.description')}
                 onBlur={(e) => void saveCaption(file, e.target.value.trim())}
@@ -324,7 +343,10 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
               />
               <div className="flex items-center justify-between gap-2">
                 <p className="truncate text-xs text-muted-foreground" suppressHydrationWarning>
-                  {formatDate(file.createdAt)} · {formatFileSize(file.fileSize)}
+                  {/* To the minute for a drop-off photo: when it was taken is
+                      the point of it. */}
+                  {kind === 'dropoff' ? formatDateTime(file.createdAt) : formatDate(file.createdAt)}{' '}
+                  · {formatFileSize(file.fileSize)}
                 </p>
                 {isImage && customerId && (
                   <SendPhotoToWhatsapp
@@ -411,7 +433,7 @@ export function MediaGrid({ kind, serviceRecordId, files, max, customerId }: Med
         </DialogContent>
       </Dialog>
 
-      {kind === 'image' && (
+      {isPhotos && (
         <ImageCarousel
           images={items}
           currentIndex={carouselIndex}

+ 2 - 0
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -481,6 +481,7 @@ export function ModernDetails(props: ModernDetailsProps) {
                       paymentLoading={actions.paymentLoading}
                       deletingPayment={actions.deletingPayment}
                       openFormSignal={paymentSignal}
+                      extraAction={props.payCode}
                     />
                   </div>
                 </AppCard>
@@ -562,6 +563,7 @@ export function ModernDetails(props: ModernDetailsProps) {
         balance={formState.balanceDue}
         currencyCode={currencyCode}
         onTakePayment={() => setPaymentSignal((n) => n + 1)}
+        payCode={props.payCode}
         onPreview={props.onPreviewInvoice}
         onSend={props.onSendToCustomer}
       />

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

@@ -1,5 +1,6 @@
 'use client'
 
+import type { ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
 import { CreditCard, Eye, Globe } from 'lucide-react'
 import { Button } from '@/components/ui/button'
@@ -14,6 +15,8 @@ interface MoneyBarProps {
   onTakePayment: () => void
   onPreview: () => void
   onSend: () => void
+  /** The pay code button, when online payment is connected. */
+  payCode?: ReactNode
 }
 
 /**
@@ -36,6 +39,7 @@ export function MoneyBar({
   onTakePayment,
   onPreview,
   onSend,
+  payCode,
 }: MoneyBarProps) {
   const t = useTranslations('service.modern.moneyBar')
   const formatCurrency = useFormatCurrency()
@@ -89,6 +93,7 @@ export function MoneyBar({
             <CreditCard className="mr-1.5 h-4 w-4" />
             {t('takePayment')}
           </Button>
+          {payCode}
           <Button type="button" size="sm" variant="outline" onClick={onPreview}>
             <Eye className="mr-1.5 h-4 w-4" />
             <span className="hidden sm:inline">{t('preview')}</span>

+ 4 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -77,6 +77,8 @@ export interface ServicePageClientProps {
   orgMembers?: OrgMemberOption[]
   currentUserName: string
   imageAttachmentsForManager: Attachment[]
+  /** Photos of the car as it arrived, kept apart from the job's photos. */
+  dropoffAttachments?: Attachment[]
   videoAttachments: Attachment[]
   documentAttachments: Attachment[]
   maxImagesPerService: number
@@ -93,6 +95,8 @@ export interface ServicePageClientProps {
   dictationMode?: 'ai' | 'choice'
   /** Gates the "Store tires" action, by plan and by the org's own switch. */
   tireHotelEnabled?: boolean
+  /** A payment vendor is connected, so the shared invoice can be paid online. */
+  onlinePayments?: boolean
   /** The work order's video call, and the connected services that could add one. */
   videoCall?: ServiceVideoCall
   /** The workshop's tread limits, so a set checked in here grades correctly. */

+ 3 - 1
src/features/vehicles/Schema/serviceSchema.ts

@@ -33,7 +33,9 @@ export const serviceAttachmentSchema = z.object({
   fileUrl: uploadUrlSchema,
   fileType: z.string(),
   fileSize: z.number(),
-  category: z.enum(['image', 'diagnostic', 'document', 'video']).default('diagnostic'),
+  // 'dropoff' is a photo of the car as it arrived: kept apart from the job's
+  // photos and off the invoice unless somebody chooses otherwise.
+  category: z.enum(['image', 'diagnostic', 'document', 'video', 'dropoff']).default('diagnostic'),
   description: z.string().optional(),
   includeInInvoice: z.boolean().default(true),
 })

+ 15 - 0
src/lib/dropoff-slots.ts

@@ -0,0 +1,15 @@
+/**
+ * The shots a drop-off walk asks for, in the order it asks: round the car
+ * clockwise from the front, then the odometer. 'other' is for the damage
+ * itself and may be used as often as needed.
+ *
+ * In a file of its own with nothing from Node in it, because the phone's page
+ * and the work order both read it, and lib/photo-handoff.ts signs with
+ * node:crypto and cannot be bundled for a browser.
+ */
+export const DROPOFF_SLOTS = ['front', 'right', 'rear', 'left', 'odometer', 'other'] as const
+export type DropoffSlot = (typeof DROPOFF_SLOTS)[number]
+
+export function isDropoffSlot(value: unknown): value is DropoffSlot {
+  return typeof value === 'string' && (DROPOFF_SLOTS as readonly string[]).includes(value)
+}

+ 24 - 4
src/lib/photo-handoff.ts

@@ -23,11 +23,21 @@ export const PHOTO_HANDOFF_TTL_SECONDS = 30 * 60
 
 const PREFIX = 'ph1'
 
+/**
+ * What the phone is being asked for. 'photos' is the ordinary code: any photo
+ * or PDF, filed with the job's files. 'dropoff' is the walk round the car as
+ * it arrives: photos only, a fixed list of shots, kept in a place of their own
+ * and off the invoice. It is part of the signed payload, so a phone cannot
+ * turn one kind of code into the other.
+ */
+export type PhotoHandoffPurpose = 'photos' | 'dropoff'
+
 export interface PhotoHandoff {
   organizationId: string
   serviceRecordId: string
   /** File the photos under this concern. */
   concernId: string | null
+  purpose: PhotoHandoffPurpose
   /** Who showed the code, for the record of who added the photos. */
   userId: string
   /** Unix seconds. */
@@ -56,6 +66,8 @@ export function createPhotoHandoffToken(
       o: handoff.organizationId,
       r: handoff.serviceRecordId,
       c: handoff.concernId,
+      // Left out of an ordinary code, so those stay as short as they were.
+      ...(handoff.purpose === 'dropoff' ? { k: 'dropoff' } : {}),
       u: handoff.userId,
       e: expiresAt,
     })
@@ -80,25 +92,33 @@ export function verifyPhotoHandoffToken(token: string, now = Date.now()): PhotoH
     return { ok: false, reason: 'invalid' }
   }
 
-  let payload: { o?: unknown; r?: unknown; c?: unknown; u?: unknown; e?: unknown }
+  let payload: { o?: unknown; r?: unknown; c?: unknown; k?: unknown; u?: unknown; e?: unknown }
   try {
     payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
   } catch {
     return { ok: false, reason: 'invalid' }
   }
-  const { o, r, c, u, e } = payload
+  const { o, r, c, k, u, e } = payload
   if (
     typeof o !== 'string' ||
     typeof r !== 'string' ||
     typeof u !== 'string' ||
     typeof e !== 'number' ||
-    (c !== null && typeof c !== 'string')
+    (c !== null && typeof c !== 'string') ||
+    (k !== undefined && k !== 'dropoff')
   ) {
     return { ok: false, reason: 'invalid' }
   }
   if (e * 1000 <= now) return { ok: false, reason: 'expired' }
   return {
     ok: true,
-    handoff: { organizationId: o, serviceRecordId: r, concernId: c, userId: u, expiresAt: e },
+    handoff: {
+      organizationId: o,
+      serviceRecordId: r,
+      concernId: c,
+      purpose: k === 'dropoff' ? 'dropoff' : 'photos',
+      userId: u,
+      expiresAt: e,
+    },
   }
 }