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

Book appointments from the calendar without opening a work order (#574)

* Book appointments from the calendar without opening a work order

The calendar's Create menu, its right-click day menu and its time slot
menu get "Book appointment": a small dialog that takes a customer and/or
a car, the work needed one line per item, a date, a start time and the
estimated hours. It books a scheduled job on the workshop's own clock
without opening the work order page. Each line becomes one of the job's
concerns and the text fills "Work requested" on the printed work order.
A customer whose car is not on file yet can be booked as well; the car is
added on the job when it arrives, and the booking still takes the shop
fee and warranty of work on a car.

A scheduled job could not be saved from the work order page: the page
sends the status back as it found it, and the form schema refused
`scheduled`. Online bookings had the same problem.

The vehicle list inside a dialog did not scroll with the mouse wheel. Its
popover is now modal, like the customer list's.

* Hold a bay or technician for a calendar booking, and refuse dates that do not exist

A booking from the calendar now holds what online booking counts: the
first bay free for the whole appointment, or in a shop without bays the
first free technician, the default technician first. Before, it could hold
neither, and the online booking page offered the same hour to a customer.
When everything is taken the office may still book it, and the dialog
says nobody is free then.

The date is checked as a real day: 2026-02-31 was accepted and booked on
3 March.

* Give the booking test the job number query it will meet once numbering reads by format
Bernt Christian Egeland 1 день назад
Родитель
Сommit
557f5b52a4
40 измененных файлов с 1431 добавлено и 19 удалено
  1. 1 0
      messages/de/audit.json
  2. 34 1
      messages/de/calendar.json
  3. 1 0
      messages/en/audit.json
  4. 34 1
      messages/en/calendar.json
  5. 1 0
      messages/es/audit.json
  6. 34 1
      messages/es/calendar.json
  7. 1 0
      messages/fr/audit.json
  8. 34 1
      messages/fr/calendar.json
  9. 1 0
      messages/it/audit.json
  10. 34 1
      messages/it/calendar.json
  11. 1 0
      messages/lt/audit.json
  12. 34 1
      messages/lt/calendar.json
  13. 1 0
      messages/nb/audit.json
  14. 34 1
      messages/nb/calendar.json
  15. 1 0
      messages/nl/audit.json
  16. 34 1
      messages/nl/calendar.json
  17. 1 0
      messages/pl/audit.json
  18. 34 1
      messages/pl/calendar.json
  19. 1 0
      messages/pt-BR/audit.json
  20. 34 1
      messages/pt-BR/calendar.json
  21. 1 0
      messages/pt-PT/audit.json
  22. 34 1
      messages/pt-PT/calendar.json
  23. 1 0
      messages/ru/audit.json
  24. 34 1
      messages/ru/calendar.json
  25. 1 0
      messages/tr/audit.json
  26. 34 1
      messages/tr/calendar.json
  27. 165 0
      src/__tests__/features/calendar/book-appointment-dialog.test.tsx
  28. 283 0
      src/__tests__/features/calendar/book-appointment.test.ts
  29. 9 0
      src/__tests__/features/calendar/time-grid-interactions.test.tsx
  30. 150 0
      src/features/calendar/Actions/bookAppointment.ts
  31. 252 0
      src/features/calendar/Components/BookAppointmentDialog.tsx
  32. 18 0
      src/features/calendar/Components/CalendarClient.tsx
  33. 6 1
      src/features/calendar/Components/CalendarToolbar.tsx
  34. 11 1
      src/features/calendar/Components/DayContextMenu.tsx
  35. 8 1
      src/features/calendar/Components/TimeGridView.tsx
  36. 23 0
      src/features/calendar/Lib/appointment.ts
  37. 33 0
      src/features/calendar/Schema/appointmentSchema.ts
  38. 4 1
      src/features/quotes/Components/VehicleCombobox.tsx
  39. 8 1
      src/features/vehicles/Lib/createDraftRecord.ts
  40. 6 1
      src/features/vehicles/Schema/serviceSchema.ts

+ 1 - 0
messages/de/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Geplante {channel, select, email {E-Mail} sms {SMS} telegram {Telegram-Nachricht} in_app {Hinweis in der App} other {{channel}}} vorzeitig gesendet",
     "scheduled_message_update": "Geplante {channel, select, email {E-Mail} sms {SMS} telegram {Telegram-Nachricht} in_app {Hinweis in der App} other {{channel}}} aktualisiert",
     "service_create": "Auftrag {ref} angelegt",
+    "service_bookAppointment": "Termin {ref} gebucht",
     "service_createCounterSale": "Thekenverkauf {ref} angelegt",
     "service_createDraft": "Auftragsentwurf {ref} angelegt",
     "service_createFromInspection": "Arbeitsauftrag {ref} aus einer Prüfung angelegt",

+ 34 - 1
messages/de/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Neuer Arbeitsauftrag",
     "newWorkOrderAt": "Neuer Arbeitsauftrag um {time}",
+    "bookAppointment": "Termin buchen",
+    "bookAppointmentAt": "Termin um {time} buchen",
     "newReminder": "Neue Erinnerung",
     "newQuote": "Neues Angebot",
     "openEvent": "Öffnen ({count})",
@@ -158,5 +160,36 @@
     "from": "Ab {time}",
     "until": "Bis {time}"
   },
-  "timeOffMissing": "Diese Abwesenheit wurde inzwischen geändert oder entfernt."
+  "timeOffMissing": "Diese Abwesenheit wurde inzwischen geändert oder entfernt.",
+  "appointment": {
+    "title": "Termin buchen",
+    "description": "Tragen Sie ein Fahrzeug oder einen Kunden mit den anstehenden Arbeiten und der voraussichtlichen Dauer in den Kalender ein. Jetzt ist noch kein Arbeitsauftrag auszufüllen: Der Auftrag wird geöffnet, wenn das Fahrzeug ankommt.",
+    "descriptionMarine": "Tragen Sie ein Boot oder einen Kunden mit den anstehenden Arbeiten und der voraussichtlichen Dauer in den Kalender ein. Jetzt ist noch kein Arbeitsauftrag auszufüllen: Der Auftrag wird geöffnet, wenn das Boot ankommt.",
+    "customer": "Kunde",
+    "selectCustomer": "Kunde auswählen",
+    "searchCustomer": "Kunden durchsuchen",
+    "noCustomers": "Keine Kunden gefunden.",
+    "vehicle": "Fahrzeug",
+    "vehicleMarine": "Boot",
+    "selectVehicle": "Fahrzeug auswählen",
+    "selectVehicleMarine": "Boot auswählen",
+    "searchVehicle": "Nach Kennzeichen, Marke oder Modell suchen",
+    "searchVehicleMarine": "Nach Registrierungsnummer, Marke oder Modell suchen",
+    "noVehicles": "Keine Fahrzeuge gefunden.",
+    "noVehiclesMarine": "Keine Boote gefunden.",
+    "none": "Keine",
+    "whoHint": "Ein Kunde, ein Fahrzeug oder beides. Ein Fahrzeug, das noch nicht erfasst ist, kann bei der Ankunft zum Auftrag hinzugefügt werden.",
+    "whoHintMarine": "Ein Kunde, ein Boot oder beides. Ein Boot, das noch nicht erfasst ist, kann bei der Ankunft zum Auftrag hinzugefügt werden.",
+    "work": "Erforderliche Arbeiten",
+    "workPlaceholder": "Bremsen vorne quietschen\nÖlservice",
+    "workPlaceholderMarine": "Motor überhitzt bei hoher Fahrt\nJahresservice",
+    "workHint": "Eine Sache pro Zeile. Jede Zeile steht im Arbeitsauftrag unter „Gewünschte Arbeiten“.",
+    "date": "Datum",
+    "time": "Startzeit",
+    "hours": "Geschätzte Stunden",
+    "submit": "Termin buchen",
+    "booked": "Termin gebucht",
+    "bookedNobodyFree": "Termin gebucht, aber zu dieser Zeit ist weder ein Arbeitsplatz noch ein Mechaniker frei. Prüfen Sie das Auftragsboard.",
+    "failed": "Termin konnte nicht gebucht werden"
+  }
 }

+ 1 - 0
messages/en/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Sent a scheduled {channel, select, email {email} sms {text message} telegram {Telegram message} in_app {in-app note} other {{channel}}} ahead of time",
     "scheduled_message_update": "Updated a scheduled {channel, select, email {email} sms {text message} telegram {Telegram message} in_app {in-app note} other {{channel}}}",
     "service_create": "Created service record {ref}",
+    "service_bookAppointment": "Booked appointment {ref}",
     "service_createCounterSale": "Created counter sale {ref}",
     "service_createDraft": "Created draft service record {ref}",
     "service_createFromInspection": "Created work order {ref} from an inspection",

+ 34 - 1
messages/en/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "New work order",
     "newWorkOrderAt": "New work order at {time}",
+    "bookAppointment": "Book appointment",
+    "bookAppointmentAt": "Book appointment at {time}",
     "newReminder": "New reminder",
     "newQuote": "New quote",
     "openEvent": "Open ({count})",
@@ -158,5 +160,36 @@
     "from": "From {time}",
     "until": "Until {time}"
   },
-  "timeOffMissing": "That leave was changed or removed in the meantime."
+  "timeOffMissing": "That leave was changed or removed in the meantime.",
+  "appointment": {
+    "title": "Book appointment",
+    "description": "Put a car or a customer on the calendar with what needs doing and how long it should take. No work order to fill in now: the job opens when the car arrives.",
+    "descriptionMarine": "Put a boat or a customer on the calendar with what needs doing and how long it should take. No work order to fill in now: the job opens when the boat arrives.",
+    "customer": "Customer",
+    "selectCustomer": "Select customer",
+    "searchCustomer": "Search customers",
+    "noCustomers": "No customers found.",
+    "vehicle": "Vehicle",
+    "vehicleMarine": "Boat",
+    "selectVehicle": "Select vehicle",
+    "selectVehicleMarine": "Select boat",
+    "searchVehicle": "Search by plate, make or model",
+    "searchVehicleMarine": "Search by registration, make or model",
+    "noVehicles": "No vehicles found.",
+    "noVehiclesMarine": "No boats found.",
+    "none": "None",
+    "whoHint": "A customer, a car or both. A car that is not on file yet can be added to the job when it arrives.",
+    "whoHintMarine": "A customer, a boat or both. A boat that is not on file yet can be added to the job when it arrives.",
+    "work": "Work needed",
+    "workPlaceholder": "Front brakes squeal\nOil service",
+    "workPlaceholderMarine": "Engine overheats at speed\nAnnual service",
+    "workHint": "One thing per line. Each line is listed under work requested on the work order.",
+    "date": "Date",
+    "time": "Start time",
+    "hours": "Estimated hours",
+    "submit": "Book appointment",
+    "booked": "Appointment booked",
+    "bookedNobodyFree": "Appointment booked, but no bay or technician is free at that time. Check the work board.",
+    "failed": "Could not book the appointment"
+  }
 }

+ 1 - 0
messages/es/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "{channel, select, email {Correo} sms {SMS} telegram {Mensaje de Telegram} in_app {Aviso en la app} other {{channel}}} programado enviado antes de tiempo",
     "scheduled_message_update": "{channel, select, email {Correo} sms {SMS} telegram {Mensaje de Telegram} in_app {Aviso en la app} other {{channel}}} programado actualizado",
     "service_create": "Servicio {ref} creado",
+    "service_bookAppointment": "Cita {ref} reservada",
     "service_createCounterSale": "Venta en mostrador {ref} creada",
     "service_createDraft": "Borrador de servicio {ref} creado",
     "service_createFromInspection": "Orden de trabajo {ref} creada a partir de una inspección",

+ 34 - 1
messages/es/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nueva orden de trabajo",
     "newWorkOrderAt": "Nueva orden de trabajo a las {time}",
+    "bookAppointment": "Reservar cita",
+    "bookAppointmentAt": "Reservar cita a las {time}",
     "newReminder": "Nuevo recordatorio",
     "newQuote": "Nuevo presupuesto",
     "openEvent": "Abrir ({count})",
@@ -158,5 +160,36 @@
     "from": "Desde las {time}",
     "until": "Hasta las {time}"
   },
-  "timeOffMissing": "Esa ausencia se ha cambiado o eliminado mientras tanto."
+  "timeOffMissing": "Esa ausencia se ha cambiado o eliminado mientras tanto.",
+  "appointment": {
+    "title": "Reservar cita",
+    "description": "Pon un vehículo o un cliente en el calendario con lo que hay que hacer y cuánto debería llevar. No hace falta rellenar una orden de trabajo ahora: se abre cuando llega el vehículo.",
+    "descriptionMarine": "Pon una embarcación o un cliente en el calendario con lo que hay que hacer y cuánto debería llevar. No hace falta rellenar una orden de trabajo ahora: se abre cuando llega la embarcación.",
+    "customer": "Cliente",
+    "selectCustomer": "Seleccionar cliente",
+    "searchCustomer": "Buscar clientes",
+    "noCustomers": "No se encontraron clientes.",
+    "vehicle": "Vehículo",
+    "vehicleMarine": "Embarcación",
+    "selectVehicle": "Seleccionar vehículo",
+    "selectVehicleMarine": "Seleccionar embarcación",
+    "searchVehicle": "Buscar por placa, marca o modelo",
+    "searchVehicleMarine": "Buscar por matrícula, marca o modelo",
+    "noVehicles": "No se encontraron vehículos.",
+    "noVehiclesMarine": "No se encontraron embarcaciones.",
+    "none": "Ninguno",
+    "whoHint": "Un cliente, un vehículo o ambos. Un vehículo que aún no está registrado se puede añadir a la orden cuando llegue.",
+    "whoHintMarine": "Un cliente, una embarcación o ambos. Una embarcación que aún no está registrada se puede añadir a la orden cuando llegue.",
+    "work": "Trabajo necesario",
+    "workPlaceholder": "Chirrían los frenos delanteros\nCambio de aceite",
+    "workPlaceholderMarine": "El motor se recalienta a alta velocidad\nRevisión anual",
+    "workHint": "Una cosa por línea. Cada línea aparece en la orden de trabajo en «Trabajo solicitado».",
+    "date": "Fecha",
+    "time": "Hora de inicio",
+    "hours": "Horas estimadas",
+    "submit": "Reservar cita",
+    "booked": "Cita reservada",
+    "bookedNobodyFree": "Cita reservada, pero a esa hora no hay ningún puesto ni técnico libre. Revisa el tablero de trabajo.",
+    "failed": "No se pudo reservar la cita"
+  }
 }

+ 1 - 0
messages/fr/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "{channel, select, email {E-mail} sms {SMS} telegram {Message Telegram} in_app {Note dans l'application} other {{channel}}} programmé envoyé en avance",
     "scheduled_message_update": "{channel, select, email {E-mail} sms {SMS} telegram {Message Telegram} in_app {Note dans l'application} other {{channel}}} programmé mis à jour",
     "service_create": "Intervention {ref} créée",
+    "service_bookAppointment": "Rendez-vous {ref} enregistré",
     "service_createCounterSale": "Vente au comptoir {ref} créée",
     "service_createDraft": "Brouillon d'intervention {ref} créé",
     "service_createFromInspection": "Ordre de travail {ref} créé à partir d'un contrôle",

+ 34 - 1
messages/fr/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nouvel ordre de travail",
     "newWorkOrderAt": "Nouvel ordre de travail à {time}",
+    "bookAppointment": "Prendre rendez-vous",
+    "bookAppointmentAt": "Prendre rendez-vous à {time}",
     "newReminder": "Nouveau rappel",
     "newQuote": "Nouveau devis",
     "openEvent": "Ouvrir ({count})",
@@ -158,5 +160,36 @@
     "from": "À partir de {time}",
     "until": "Jusqu'à {time}"
   },
-  "timeOffMissing": "Cette absence a été modifiée ou supprimée entre-temps."
+  "timeOffMissing": "Cette absence a été modifiée ou supprimée entre-temps.",
+  "appointment": {
+    "title": "Prendre rendez-vous",
+    "description": "Placez un véhicule ou un client dans le calendrier, avec le travail à faire et sa durée prévue. Aucun ordre de travail à remplir maintenant : il s’ouvre à l’arrivée du véhicule.",
+    "descriptionMarine": "Placez un bateau ou un client dans le calendrier, avec le travail à faire et sa durée prévue. Aucun ordre de travail à remplir maintenant : il s’ouvre à l’arrivée du bateau.",
+    "customer": "Client",
+    "selectCustomer": "Sélectionner un client",
+    "searchCustomer": "Rechercher des clients",
+    "noCustomers": "Aucun client trouvé.",
+    "vehicle": "Véhicule",
+    "vehicleMarine": "Bateau",
+    "selectVehicle": "Sélectionner un véhicule",
+    "selectVehicleMarine": "Sélectionner un bateau",
+    "searchVehicle": "Rechercher par plaque, marque ou modèle",
+    "searchVehicleMarine": "Rechercher par immatriculation, marque ou modèle",
+    "noVehicles": "Aucun véhicule trouvé.",
+    "noVehiclesMarine": "Aucun bateau trouvé.",
+    "none": "Aucun",
+    "whoHint": "Un client, un véhicule ou les deux. Un véhicule qui n’est pas encore enregistré peut être ajouté à l’ordre à son arrivée.",
+    "whoHintMarine": "Un client, un bateau ou les deux. Un bateau qui n’est pas encore enregistré peut être ajouté à l’ordre à son arrivée.",
+    "work": "Travaux à faire",
+    "workPlaceholder": "Les freins avant grincent\nVidange",
+    "workPlaceholderMarine": "Le moteur chauffe à haute vitesse\nEntretien annuel",
+    "workHint": "Une chose par ligne. Chaque ligne figure sur l’ordre de travail sous « Travaux demandés ».",
+    "date": "Date",
+    "time": "Heure de début",
+    "hours": "Heures estimées",
+    "submit": "Prendre rendez-vous",
+    "booked": "Rendez-vous enregistré",
+    "bookedNobodyFree": "Rendez-vous enregistré, mais aucun poste ni technicien n’est libre à cette heure. Consultez le tableau de travail.",
+    "failed": "Impossible d’enregistrer le rendez-vous"
+  }
 }

+ 1 - 0
messages/it/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "{channel, select, email {E-mail} sms {SMS} telegram {Messaggio Telegram} in_app {Avviso nell'app} other {{channel}}} programmato inviato in anticipo",
     "scheduled_message_update": "{channel, select, email {E-mail} sms {SMS} telegram {Messaggio Telegram} in_app {Avviso nell'app} other {{channel}}} programmato aggiornato",
     "service_create": "Intervento {ref} creato",
+    "service_bookAppointment": "Appuntamento {ref} prenotato",
     "service_createCounterSale": "Vendita al banco {ref} creata",
     "service_createDraft": "Bozza di intervento {ref} creata",
     "service_createFromInspection": "Ordine di lavoro {ref} creato da un controllo",

+ 34 - 1
messages/it/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nuovo ordine di lavoro",
     "newWorkOrderAt": "Nuovo ordine di lavoro alle {time}",
+    "bookAppointment": "Prenota appuntamento",
+    "bookAppointmentAt": "Prenota appuntamento alle {time}",
     "newReminder": "Nuovo promemoria",
     "newQuote": "Nuovo preventivo",
     "openEvent": "Apri ({count})",
@@ -158,5 +160,36 @@
     "from": "Dalle {time}",
     "until": "Fino alle {time}"
   },
-  "timeOffMissing": "Quell'assenza è stata modificata o rimossa nel frattempo."
+  "timeOffMissing": "Quell'assenza è stata modificata o rimossa nel frattempo.",
+  "appointment": {
+    "title": "Prenota appuntamento",
+    "description": "Metti in calendario un'auto o un cliente con il lavoro da fare e quanto dovrebbe durare. Nessun ordine di lavoro da compilare ora: si apre quando arriva l'auto.",
+    "descriptionMarine": "Metti in calendario un'imbarcazione o un cliente con il lavoro da fare e quanto dovrebbe durare. Nessun ordine di lavoro da compilare ora: si apre quando arriva l'imbarcazione.",
+    "customer": "Cliente",
+    "selectCustomer": "Seleziona cliente",
+    "searchCustomer": "Cerca clienti",
+    "noCustomers": "Nessun cliente trovato.",
+    "vehicle": "Veicolo",
+    "vehicleMarine": "Imbarcazione",
+    "selectVehicle": "Seleziona veicolo",
+    "selectVehicleMarine": "Seleziona imbarcazione",
+    "searchVehicle": "Cerca per targa, marca o modello",
+    "searchVehicleMarine": "Cerca per numero di registrazione, marca o modello",
+    "noVehicles": "Nessun veicolo trovato.",
+    "noVehiclesMarine": "Nessuna imbarcazione trovata.",
+    "none": "Nessuno",
+    "whoHint": "Un cliente, un'auto o entrambi. Un'auto non ancora registrata può essere aggiunta all'ordine quando arriva.",
+    "whoHintMarine": "Un cliente, un'imbarcazione o entrambi. Un'imbarcazione non ancora registrata può essere aggiunta all'ordine quando arriva.",
+    "work": "Lavori da fare",
+    "workPlaceholder": "I freni anteriori fischiano\nCambio olio",
+    "workPlaceholderMarine": "Il motore si surriscalda ad alta velocità\nTagliando annuale",
+    "workHint": "Una cosa per riga. Ogni riga compare nell'ordine di lavoro sotto «Lavoro richiesto».",
+    "date": "Data",
+    "time": "Ora di inizio",
+    "hours": "Ore stimate",
+    "submit": "Prenota appuntamento",
+    "booked": "Appuntamento prenotato",
+    "bookedNobodyFree": "Appuntamento prenotato, ma a quell'ora non ci sono postazioni né tecnici liberi. Controlla la bacheca lavori.",
+    "failed": "Impossibile prenotare l'appuntamento"
+  }
 }

+ 1 - 0
messages/lt/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Suplanuotas {channel, select, email {el. laiškas} sms {SMS} telegram {Telegram žinutė} in_app {pranešimas programoje} other {{channel}}} išsiųstas anksčiau laiko",
     "scheduled_message_update": "Atnaujintas suplanuotas {channel, select, email {el. laiškas} sms {SMS} telegram {Telegram žinutė} in_app {pranešimas programoje} other {{channel}}}",
     "service_create": "Sukurtas darbas {ref}",
+    "service_bookAppointment": "Užregistruotas vizitas {ref}",
     "service_createCounterSale": "Sukurtas pardavimas prie prekystalio {ref}",
     "service_createDraft": "Sukurtas darbo juodraštis {ref}",
     "service_createFromInspection": "Iš patikros sukurtas darbo užsakymas {ref}",

+ 34 - 1
messages/lt/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Naujas darbo užsakymas",
     "newWorkOrderAt": "Naujas darbo užsakymas {time}",
+    "bookAppointment": "Užregistruoti vizitą",
+    "bookAppointmentAt": "Užregistruoti vizitą {time}",
     "newReminder": "Naujas priminimas",
     "newQuote": "Naujas pasiūlymas",
     "openEvent": "Atidaryti ({count})",
@@ -158,5 +160,36 @@
     "from": "Nuo {time}",
     "until": "Iki {time}"
   },
-  "timeOffMissing": "Šis nebuvimas per tą laiką buvo pakeistas arba pašalintas."
+  "timeOffMissing": "Šis nebuvimas per tą laiką buvo pakeistas arba pašalintas.",
+  "appointment": {
+    "title": "Užregistruoti vizitą",
+    "description": "Įrašykite automobilį arba klientą į kalendorių su darbais, kuriuos reikia atlikti, ir numatoma trukme. Darbo užsakymo pildyti dabar nereikia: jis atidaromas, kai automobilis atvyksta.",
+    "descriptionMarine": "Įrašykite valtį arba klientą į kalendorių su darbais, kuriuos reikia atlikti, ir numatoma trukme. Darbo užsakymo pildyti dabar nereikia: jis atidaromas, kai valtis pristatoma.",
+    "customer": "Klientas",
+    "selectCustomer": "Pasirinkite klientą",
+    "searchCustomer": "Ieškoti klientų",
+    "noCustomers": "Klientų nerasta.",
+    "vehicle": "Transporto priemonė",
+    "vehicleMarine": "Valtis",
+    "selectVehicle": "Pasirinkite transporto priemonę",
+    "selectVehicleMarine": "Pasirinkite valtį",
+    "searchVehicle": "Ieškoti pagal numerį, markę ar modelį",
+    "searchVehicleMarine": "Ieškoti pagal registracijos numerį, markę ar modelį",
+    "noVehicles": "Transporto priemonių nerasta.",
+    "noVehiclesMarine": "Valčių nerasta.",
+    "none": "Nėra",
+    "whoHint": "Klientas, automobilis arba abu. Automobilį, kurio dar nėra sistemoje, galima pridėti prie užsakymo, kai jis atvyks.",
+    "whoHintMarine": "Klientas, valtis arba abu. Valtį, kurios dar nėra sistemoje, galima pridėti prie užsakymo, kai ji bus pristatyta.",
+    "work": "Reikalingi darbai",
+    "workPlaceholder": "Cypia priekiniai stabdžiai\nAlyvos keitimas",
+    "workPlaceholderMarine": "Didesniu greičiu perkaista variklis\nMetinė priežiūra",
+    "workHint": "Po vieną darbą eilutėje. Kiekviena eilutė darbo užsakyme įrašoma skiltyje „Užsakyti darbai“.",
+    "date": "Data",
+    "time": "Pradžios laikas",
+    "hours": "Numatomos valandos",
+    "submit": "Užregistruoti vizitą",
+    "booked": "Vizitas užregistruotas",
+    "bookedNobodyFree": "Vizitas užregistruotas, tačiau tuo metu nėra nei laisvos darbo vietos, nei laisvo meistro. Patikrinkite darbų lentą.",
+    "failed": "Nepavyko užregistruoti vizito"
+  }
 }

+ 1 - 0
messages/nb/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Sendte {channel, select, email {en planlagt e-post} sms {en planlagt SMS} telegram {en planlagt Telegram-melding} in_app {et planlagt varsel i appen} other {{channel}}} før tiden",
     "scheduled_message_update": "Oppdaterte {channel, select, email {en planlagt e-post} sms {en planlagt SMS} telegram {en planlagt Telegram-melding} in_app {et planlagt varsel i appen} other {{channel}}}",
     "service_create": "Opprettet jobb {ref}",
+    "service_bookAppointment": "Booket time {ref}",
     "service_createCounterSale": "Opprettet disksalg {ref}",
     "service_createDraft": "Opprettet utkast til jobb {ref}",
     "service_createFromInspection": "Opprettet arbeidsordre {ref} fra en kontroll",

+ 34 - 1
messages/nb/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Ny arbeidsordre",
     "newWorkOrderAt": "Ny arbeidsordre kl. {time}",
+    "bookAppointment": "Book time",
+    "bookAppointmentAt": "Book time kl. {time}",
     "newReminder": "Ny påminnelse",
     "newQuote": "Nytt tilbud",
     "openEvent": "Åpne ({count})",
@@ -158,5 +160,36 @@
     "from": "Fra {time}",
     "until": "Til {time}"
   },
-  "timeOffMissing": "Dette fraværet ble endret eller fjernet i mellomtiden."
+  "timeOffMissing": "Dette fraværet ble endret eller fjernet i mellomtiden.",
+  "appointment": {
+    "title": "Book time",
+    "description": "Sett en bil eller en kunde i kalenderen med hva som skal gjøres og hvor lang tid det bør ta. Ingen arbeidsordre å fylle ut nå: jobben åpnes når bilen kommer.",
+    "descriptionMarine": "Sett en båt eller en kunde i kalenderen med hva som skal gjøres og hvor lang tid det bør ta. Ingen arbeidsordre å fylle ut nå: jobben åpnes når båten kommer.",
+    "customer": "Kunde",
+    "selectCustomer": "Velg kunde",
+    "searchCustomer": "Søk etter kunder",
+    "noCustomers": "Ingen kunder funnet.",
+    "vehicle": "Kjøretøy",
+    "vehicleMarine": "Båt",
+    "selectVehicle": "Velg kjøretøy",
+    "selectVehicleMarine": "Velg båt",
+    "searchVehicle": "Søk etter skilt, merke eller modell",
+    "searchVehicleMarine": "Søk etter registreringsnummer, merke eller modell",
+    "noVehicles": "Ingen kjøretøy funnet.",
+    "noVehiclesMarine": "Ingen båter funnet.",
+    "none": "Ingen",
+    "whoHint": "En kunde, en bil eller begge. En bil som ikke er registrert ennå, kan legges til jobben når den kommer.",
+    "whoHintMarine": "En kunde, en båt eller begge. En båt som ikke er registrert ennå, kan legges til jobben når den kommer.",
+    "work": "Arbeid som trengs",
+    "workPlaceholder": "Bremsene foran piper\nOljeskift",
+    "workPlaceholderMarine": "Motoren blir for varm i høy fart\nÅrsservice",
+    "workHint": "Én ting per linje. Hver linje står under «Ønsket arbeid» på arbeidsordren.",
+    "date": "Dato",
+    "time": "Starttid",
+    "hours": "Estimerte timer",
+    "submit": "Book time",
+    "booked": "Time booket",
+    "bookedNobodyFree": "Time booket, men ingen arbeidsplass eller mekaniker er ledig på det tidspunktet. Sjekk arbeidstavlen.",
+    "failed": "Kunne ikke booke timen"
+  }
 }

+ 1 - 0
messages/nl/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Ingeplande {channel, select, email {e-mail} sms {sms} telegram {Telegram-bericht} in_app {melding in de app} other {{channel}}} eerder verstuurd",
     "scheduled_message_update": "Ingeplande {channel, select, email {e-mail} sms {sms} telegram {Telegram-bericht} in_app {melding in de app} other {{channel}}} bijgewerkt",
     "service_create": "Opdracht {ref} aangemaakt",
+    "service_bookAppointment": "Afspraak {ref} ingepland",
     "service_createCounterSale": "Baliekoop {ref} aangemaakt",
     "service_createDraft": "Conceptopdracht {ref} aangemaakt",
     "service_createFromInspection": "Werkorder {ref} aangemaakt vanuit een keuring",

+ 34 - 1
messages/nl/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nieuwe werkorder",
     "newWorkOrderAt": "Nieuwe werkorder om {time}",
+    "bookAppointment": "Afspraak inplannen",
+    "bookAppointmentAt": "Afspraak inplannen om {time}",
     "newReminder": "Nieuwe herinnering",
     "newQuote": "Nieuwe offerte",
     "openEvent": "Openen ({count})",
@@ -158,5 +160,36 @@
     "from": "Vanaf {time}",
     "until": "Tot {time}"
   },
-  "timeOffMissing": "Die afwezigheid is intussen gewijzigd of verwijderd."
+  "timeOffMissing": "Die afwezigheid is intussen gewijzigd of verwijderd.",
+  "appointment": {
+    "title": "Afspraak inplannen",
+    "description": "Zet een auto of een klant in de kalender, met wat er moet gebeuren en hoe lang het ongeveer duurt. Nu nog geen werkorder invullen: die wordt geopend zodra de auto binnenkomt.",
+    "descriptionMarine": "Zet een boot of een klant in de kalender, met wat er moet gebeuren en hoe lang het ongeveer duurt. Nu nog geen werkorder invullen: die wordt geopend zodra de boot binnenkomt.",
+    "customer": "Klant",
+    "selectCustomer": "Klant selecteren",
+    "searchCustomer": "Klanten zoeken",
+    "noCustomers": "Geen klanten gevonden.",
+    "vehicle": "Voertuig",
+    "vehicleMarine": "Boot",
+    "selectVehicle": "Voertuig selecteren",
+    "selectVehicleMarine": "Boot selecteren",
+    "searchVehicle": "Zoek op kenteken, merk of model",
+    "searchVehicleMarine": "Zoek op registratienummer, merk of model",
+    "noVehicles": "Geen voertuigen gevonden.",
+    "noVehiclesMarine": "Geen boten gevonden.",
+    "none": "Geen",
+    "whoHint": "Een klant, een auto of allebei. Een auto die nog niet in het systeem staat, kan aan de werkorder worden toegevoegd zodra hij binnenkomt.",
+    "whoHintMarine": "Een klant, een boot of allebei. Een boot die nog niet in het systeem staat, kan aan de werkorder worden toegevoegd zodra hij binnenkomt.",
+    "work": "Benodigd werk",
+    "workPlaceholder": "Voorremmen piepen\nOlie verversen",
+    "workPlaceholderMarine": "Motor wordt te heet bij hoge snelheid\nJaarlijkse onderhoudsbeurt",
+    "workHint": "Eén ding per regel. Elke regel staat op de werkorder onder \"Gevraagd werk\".",
+    "date": "Datum",
+    "time": "Starttijd",
+    "hours": "Geschatte uren",
+    "submit": "Afspraak inplannen",
+    "booked": "Afspraak ingepland",
+    "bookedNobodyFree": "Afspraak ingepland, maar er is op dat moment geen werkplek of monteur vrij. Kijk op het werkbord.",
+    "failed": "Kon de afspraak niet inplannen"
+  }
 }

+ 1 - 0
messages/pl/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Wysłano przed czasem zaplanowany {channel, select, email {e-mail} sms {SMS} telegram {wiadomość Telegram} in_app {powiadomienie w aplikacji} other {{channel}}}",
     "scheduled_message_update": "Zaktualizowano zaplanowany {channel, select, email {e-mail} sms {SMS} telegram {wiadomość Telegram} in_app {powiadomienie w aplikacji} other {{channel}}}",
     "service_create": "Utworzono zlecenie {ref}",
+    "service_bookAppointment": "Umówiono wizytę {ref}",
     "service_createCounterSale": "Utworzono sprzedaż z lady {ref}",
     "service_createDraft": "Utworzono szkic zlecenia {ref}",
     "service_createFromInspection": "Utworzono zlecenie warsztatowe {ref} z przeglądu",

+ 34 - 1
messages/pl/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nowe zlecenie",
     "newWorkOrderAt": "Nowe zlecenie o {time}",
+    "bookAppointment": "Umów wizytę",
+    "bookAppointmentAt": "Umów wizytę na {time}",
     "newReminder": "Nowe przypomnienie",
     "newQuote": "Nowa wycena",
     "openEvent": "Otwórz ({count})",
@@ -158,5 +160,36 @@
     "from": "Od {time}",
     "until": "Do {time}"
   },
-  "timeOffMissing": "Ta nieobecność została w międzyczasie zmieniona lub usunięta."
+  "timeOffMissing": "Ta nieobecność została w międzyczasie zmieniona lub usunięta.",
+  "appointment": {
+    "title": "Umów wizytę",
+    "description": "Wpisz auto lub klienta do kalendarza razem z tym, co trzeba zrobić, i ile ma to potrwać. Teraz nie trzeba wypełniać zlecenia: zostanie otwarte, gdy auto trafi do warsztatu.",
+    "descriptionMarine": "Wpisz łódź lub klienta do kalendarza razem z tym, co trzeba zrobić, i ile ma to potrwać. Teraz nie trzeba wypełniać zlecenia: zostanie otwarte, gdy łódź trafi do warsztatu.",
+    "customer": "Klient",
+    "selectCustomer": "Wybierz klienta",
+    "searchCustomer": "Szukaj klientów",
+    "noCustomers": "Nie znaleziono klientów.",
+    "vehicle": "Pojazd",
+    "vehicleMarine": "Łódź",
+    "selectVehicle": "Wybierz pojazd",
+    "selectVehicleMarine": "Wybierz łódź",
+    "searchVehicle": "Szukaj po tablicy, marce lub modelu",
+    "searchVehicleMarine": "Szukaj po numerze rejestracyjnym, marce lub modelu",
+    "noVehicles": "Nie znaleziono pojazdów.",
+    "noVehiclesMarine": "Nie znaleziono łodzi.",
+    "none": "Brak",
+    "whoHint": "Klient, auto lub jedno i drugie. Auto, którego jeszcze nie ma w bazie, można dodać do zlecenia, gdy trafi do warsztatu.",
+    "whoHintMarine": "Klient, łódź lub jedno i drugie. Łódź, której jeszcze nie ma w bazie, można dodać do zlecenia, gdy trafi do warsztatu.",
+    "work": "Prace do wykonania",
+    "workPlaceholder": "Piszczą przednie hamulce\nWymiana oleju",
+    "workPlaceholderMarine": "Silnik przegrzewa się przy dużej prędkości\nPrzegląd roczny",
+    "workHint": "Jedna rzecz w wierszu. Każdy wiersz trafia do zlecenia w sekcji „Zlecone prace”.",
+    "date": "Data",
+    "time": "Godzina rozpoczęcia",
+    "hours": "Szacowane godziny",
+    "submit": "Umów wizytę",
+    "booked": "Wizyta umówiona",
+    "bookedNobodyFree": "Wizyta umówiona, ale o tej godzinie nie ma wolnego stanowiska ani mechanika. Sprawdź tablicę zleceń.",
+    "failed": "Nie udało się umówić wizyty"
+  }
 }

+ 1 - 0
messages/pt-BR/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "{channel, select, email {E-mail} sms {SMS} telegram {Mensagem do Telegram} in_app {Aviso no aplicativo} other {{channel}}} agendado enviado antes da hora",
     "scheduled_message_update": "{channel, select, email {E-mail} sms {SMS} telegram {Mensagem do Telegram} in_app {Aviso no aplicativo} other {{channel}}} agendado atualizado",
     "service_create": "Serviço {ref} criado",
+    "service_bookAppointment": "Horário {ref} agendado",
     "service_createCounterSale": "Venda no balcão {ref} criada",
     "service_createDraft": "Rascunho de serviço {ref} criado",
     "service_createFromInspection": "Ordem de serviço {ref} criada a partir de uma inspeção",

+ 34 - 1
messages/pt-BR/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nova ordem de serviço",
     "newWorkOrderAt": "Nova ordem de serviço às {time}",
+    "bookAppointment": "Agendar horário",
+    "bookAppointmentAt": "Agendar horário às {time}",
     "newReminder": "Novo lembrete",
     "newQuote": "Novo orçamento",
     "openEvent": "Abrir ({count})",
@@ -158,5 +160,36 @@
     "from": "A partir das {time}",
     "until": "Até às {time}"
   },
-  "timeOffMissing": "Essa ausência foi alterada ou removida nesse meio tempo."
+  "timeOffMissing": "Essa ausência foi alterada ou removida nesse meio tempo.",
+  "appointment": {
+    "title": "Agendar horário",
+    "description": "Coloque um carro ou um cliente no calendário com o que precisa ser feito e quanto tempo deve levar. Não é preciso preencher uma ordem de serviço agora: ela é aberta quando o carro chegar.",
+    "descriptionMarine": "Coloque um barco ou um cliente no calendário com o que precisa ser feito e quanto tempo deve levar. Não é preciso preencher uma ordem de serviço agora: ela é aberta quando o barco chegar.",
+    "customer": "Cliente",
+    "selectCustomer": "Selecionar cliente",
+    "searchCustomer": "Buscar clientes",
+    "noCustomers": "Nenhum cliente encontrado.",
+    "vehicle": "Veículo",
+    "vehicleMarine": "Barco",
+    "selectVehicle": "Selecionar veículo",
+    "selectVehicleMarine": "Selecionar barco",
+    "searchVehicle": "Buscar por placa, marca ou modelo",
+    "searchVehicleMarine": "Buscar por número de registro, marca ou modelo",
+    "noVehicles": "Nenhum veículo encontrado.",
+    "noVehiclesMarine": "Nenhum barco encontrado.",
+    "none": "Nenhum",
+    "whoHint": "Um cliente, um carro ou os dois. Um carro que ainda não está cadastrado pode ser adicionado à ordem quando chegar.",
+    "whoHintMarine": "Um cliente, um barco ou os dois. Um barco que ainda não está cadastrado pode ser adicionado à ordem quando chegar.",
+    "work": "Serviço necessário",
+    "workPlaceholder": "Freios dianteiros chiando\nTroca de óleo",
+    "workPlaceholderMarine": "Motor superaquece em alta velocidade\nRevisão anual",
+    "workHint": "Uma coisa por linha. Cada linha aparece na ordem de serviço em \"Serviço solicitado\".",
+    "date": "Data",
+    "time": "Hora de início",
+    "hours": "Horas estimadas",
+    "submit": "Agendar horário",
+    "booked": "Horário agendado",
+    "bookedNobodyFree": "Horário agendado, mas não há baia nem técnico livre a essa hora. Confira o quadro de trabalho.",
+    "failed": "Não foi possível agendar o horário"
+  }
 }

+ 1 - 0
messages/pt-PT/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Enviou antecipadamente {channel, select, email {um e-mail agendado} sms {um SMS agendado} telegram {uma mensagem do Telegram agendada} in_app {um aviso na aplicação agendado} other {{channel}}}",
     "scheduled_message_update": "Atualizou {channel, select, email {um e-mail agendado} sms {um SMS agendado} telegram {uma mensagem do Telegram agendada} in_app {um aviso na aplicação agendado} other {{channel}}}",
     "service_create": "Registo de serviço {ref} criado",
+    "service_bookAppointment": "Marcação {ref} criada",
     "service_createCounterSale": "Venda ao balcão {ref} criada",
     "service_createDraft": "Rascunho de registo de serviço {ref} criado",
     "service_createFromInspection": "Ordem de serviço {ref} criada a partir de uma inspeção",

+ 34 - 1
messages/pt-PT/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Nova ordem de serviço",
     "newWorkOrderAt": "Nova ordem de serviço às {time}",
+    "bookAppointment": "Fazer marcação",
+    "bookAppointmentAt": "Fazer marcação às {time}",
     "newReminder": "Novo lembrete",
     "newQuote": "Novo orçamento",
     "openEvent": "Abrir ({count})",
@@ -158,5 +160,36 @@
     "from": "A partir das {time}",
     "until": "Até às {time}"
   },
-  "timeOffMissing": "Essa ausência foi alterada ou removida entretanto."
+  "timeOffMissing": "Essa ausência foi alterada ou removida entretanto.",
+  "appointment": {
+    "title": "Fazer marcação",
+    "description": "Coloque uma viatura ou um cliente no calendário com o que é preciso fazer e quanto tempo deve demorar. Não há ordem de serviço para preencher agora: é aberta quando a viatura chegar.",
+    "descriptionMarine": "Coloque um barco ou um cliente no calendário com o que é preciso fazer e quanto tempo deve demorar. Não há ordem de serviço para preencher agora: é aberta quando o barco chegar.",
+    "customer": "Cliente",
+    "selectCustomer": "Selecionar cliente",
+    "searchCustomer": "Pesquisar clientes",
+    "noCustomers": "Nenhum cliente encontrado.",
+    "vehicle": "Viatura",
+    "vehicleMarine": "Barco",
+    "selectVehicle": "Selecionar viatura",
+    "selectVehicleMarine": "Selecionar barco",
+    "searchVehicle": "Pesquisar por matrícula, marca ou modelo",
+    "searchVehicleMarine": "Pesquisar por número de registo, marca ou modelo",
+    "noVehicles": "Nenhuma viatura encontrada.",
+    "noVehiclesMarine": "Nenhum barco encontrado.",
+    "none": "Nenhum",
+    "whoHint": "Um cliente, uma viatura ou ambos. Uma viatura que ainda não esteja registada pode ser adicionada à ordem quando chegar.",
+    "whoHintMarine": "Um cliente, um barco ou ambos. Um barco que ainda não esteja registado pode ser adicionado à ordem quando chegar.",
+    "work": "Trabalho necessário",
+    "workPlaceholder": "Travões da frente a chiar\nMudança de óleo",
+    "workPlaceholderMarine": "Motor sobreaquece a alta velocidade\nRevisão anual",
+    "workHint": "Uma coisa por linha. Cada linha aparece na ordem de serviço em \"Trabalho pedido\".",
+    "date": "Data",
+    "time": "Hora de início",
+    "hours": "Horas estimadas",
+    "submit": "Fazer marcação",
+    "booked": "Marcação feita",
+    "bookedNobodyFree": "Marcação feita, mas não há posto de trabalho nem técnico livre a essa hora. Consulte o quadro de trabalho.",
+    "failed": "Não foi possível fazer a marcação"
+  }
 }

+ 1 - 0
messages/ru/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Запланированное {channel, select, email {письмо} sms {SMS} telegram {сообщение в Telegram} in_app {уведомление в приложении} other {{channel}}} отправлено досрочно",
     "scheduled_message_update": "Обновлено запланированное {channel, select, email {письмо} sms {SMS} telegram {сообщение в Telegram} in_app {уведомление в приложении} other {{channel}}}",
     "service_create": "Создан заказ {ref}",
+    "service_bookAppointment": "Создана запись {ref}",
     "service_createCounterSale": "Создана продажа со стойки {ref}",
     "service_createDraft": "Создан черновик заказа {ref}",
     "service_createFromInspection": "Создан наряд-заказ {ref} по итогам проверки",

+ 34 - 1
messages/ru/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Новый заказ-наряд",
     "newWorkOrderAt": "Новый заказ-наряд в {time}",
+    "bookAppointment": "Создать запись",
+    "bookAppointmentAt": "Создать запись на {time}",
     "newReminder": "Новое напоминание",
     "newQuote": "Новое предложение",
     "openEvent": "Открыть ({count})",
@@ -158,5 +160,36 @@
     "from": "С {time}",
     "until": "До {time}"
   },
-  "timeOffMissing": "Это отсутствие тем временем изменили или удалили."
+  "timeOffMissing": "Это отсутствие тем временем изменили или удалили.",
+  "appointment": {
+    "title": "Создать запись",
+    "description": "Внесите автомобиль или клиента в календарь вместе с тем, что нужно сделать, и примерной продолжительностью. Заполнять заказ-наряд сейчас не нужно: он откроется, когда автомобиль прибудет.",
+    "descriptionMarine": "Внесите лодку или клиента в календарь вместе с тем, что нужно сделать, и примерной продолжительностью. Заполнять заказ-наряд сейчас не нужно: он откроется, когда лодка прибудет.",
+    "customer": "Клиент",
+    "selectCustomer": "Выберите клиента",
+    "searchCustomer": "Поиск клиентов",
+    "noCustomers": "Клиенты не найдены.",
+    "vehicle": "Транспортное средство",
+    "vehicleMarine": "Лодка",
+    "selectVehicle": "Выберите транспортное средство",
+    "selectVehicleMarine": "Выберите лодку",
+    "searchVehicle": "Поиск по госномеру, марке или модели",
+    "searchVehicleMarine": "Поиск по регистрационному номеру, марке или модели",
+    "noVehicles": "Транспортные средства не найдены.",
+    "noVehiclesMarine": "Лодки не найдены.",
+    "none": "Нет",
+    "whoHint": "Клиент, автомобиль или и то, и другое. Автомобиль, которого ещё нет в базе, можно добавить в заказ-наряд, когда он прибудет.",
+    "whoHintMarine": "Клиент, лодка или и то, и другое. Лодку, которой ещё нет в базе, можно добавить в заказ-наряд, когда она прибудет.",
+    "work": "Необходимые работы",
+    "workPlaceholder": "Скрипят передние тормоза\nЗамена масла",
+    "workPlaceholderMarine": "Двигатель перегревается на высокой скорости\nЕжегодное ТО",
+    "workHint": "Одна задача на строку. Каждая строка попадает в заказ-наряд в раздел «Запрошенные работы».",
+    "date": "Дата",
+    "time": "Время начала",
+    "hours": "Ориентировочное время, ч",
+    "submit": "Создать запись",
+    "booked": "Запись создана",
+    "bookedNobodyFree": "Запись создана, но на это время нет ни свободного поста, ни свободного механика. Проверьте доску работ.",
+    "failed": "Не удалось создать запись"
+  }
 }

+ 1 - 0
messages/tr/audit.json

@@ -272,6 +272,7 @@
     "scheduled_message_send_now": "Planlanmış {channel, select, email {e-posta} sms {SMS} telegram {Telegram mesajı} in_app {uygulama içi bildirim} other {{channel}}} erkenden gönderildi",
     "scheduled_message_update": "Planlanmış {channel, select, email {e-posta} sms {SMS} telegram {Telegram mesajı} in_app {uygulama içi bildirim} other {{channel}}} güncellendi",
     "service_create": "{ref} iş kaydı oluşturuldu",
+    "service_bookAppointment": "{ref} randevusu oluşturuldu",
     "service_createCounterSale": "{ref} tezgah satışı oluşturuldu",
     "service_createDraft": "{ref} taslak iş kaydı oluşturuldu",
     "service_createFromInspection": "Bir muayeneden {ref} iş emri oluşturuldu",

+ 34 - 1
messages/tr/calendar.json

@@ -94,6 +94,8 @@
   "contextMenu": {
     "newWorkOrder": "Yeni iş emri",
     "newWorkOrderAt": "{time} için yeni iş emri",
+    "bookAppointment": "Randevu oluştur",
+    "bookAppointmentAt": "{time} için randevu oluştur",
     "newReminder": "Yeni hatırlatıcı",
     "newQuote": "Yeni teklif",
     "openEvent": "Aç ({count})",
@@ -158,5 +160,36 @@
     "from": "{time} itibarıyla",
     "until": "{time} saatine kadar"
   },
-  "timeOffMissing": "Bu izin bu arada değiştirildi veya kaldırıldı."
+  "timeOffMissing": "Bu izin bu arada değiştirildi veya kaldırıldı.",
+  "appointment": {
+    "title": "Randevu oluştur",
+    "description": "Bir aracı veya müşteriyi, yapılacak işle ve ne kadar süreceğiyle birlikte takvime ekleyin. Şimdi doldurulacak bir iş emri yok: iş emri araç geldiğinde açılır.",
+    "descriptionMarine": "Bir tekneyi veya müşteriyi, yapılacak işle ve ne kadar süreceğiyle birlikte takvime ekleyin. Şimdi doldurulacak bir iş emri yok: iş emri tekne geldiğinde açılır.",
+    "customer": "Müşteri",
+    "selectCustomer": "Müşteri seçin",
+    "searchCustomer": "Müşteri ara",
+    "noCustomers": "Müşteri bulunamadı.",
+    "vehicle": "Araç",
+    "vehicleMarine": "Tekne",
+    "selectVehicle": "Araç seçin",
+    "selectVehicleMarine": "Tekne seçin",
+    "searchVehicle": "Plaka, marka veya modele göre ara",
+    "searchVehicleMarine": "Tescil numarası, marka veya modele göre ara",
+    "noVehicles": "Araç bulunamadı.",
+    "noVehiclesMarine": "Tekne bulunamadı.",
+    "none": "Yok",
+    "whoHint": "Bir müşteri, bir araç ya da ikisi birden. Henüz kayıtlı olmayan bir araç, geldiğinde iş emrine eklenebilir.",
+    "whoHintMarine": "Bir müşteri, bir tekne ya da ikisi birden. Henüz kayıtlı olmayan bir tekne, geldiğinde iş emrine eklenebilir.",
+    "work": "Yapılacak iş",
+    "workPlaceholder": "Ön frenler gıcırdıyor\nYağ değişimi",
+    "workPlaceholderMarine": "Motor yüksek hızda aşırı ısınıyor\nYıllık bakım",
+    "workHint": "Her satıra bir iş. Her satır iş emrinde \"İstenen iş\" başlığı altında listelenir.",
+    "date": "Tarih",
+    "time": "Başlangıç saati",
+    "hours": "Tahmini saat",
+    "submit": "Randevu oluştur",
+    "booked": "Randevu oluşturuldu",
+    "bookedNobodyFree": "Randevu oluşturuldu, ancak o saatte boş çalışma alanı veya teknisyen yok. İş panosunu kontrol edin.",
+    "failed": "Randevu oluşturulamadı"
+  }
 }

+ 165 - 0
src/__tests__/features/calendar/book-appointment-dialog.test.tsx

@@ -0,0 +1,165 @@
+/**
+ * Booking a car in from the calendar: pick who, say what needs doing, and
+ * the dialog books it for the day and time the calendar was opened on,
+ * without sending anybody to the work order page.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+
+// Radix measures the popover's anchor; jsdom has no ResizeObserver, and
+// nothing here needs measuring.
+// biome-ignore lint/suspicious/noEmptyBlockStatements: a stub that measures nothing
+const noop = () => {}
+vi.stubGlobal(
+  'ResizeObserver',
+  class {
+    observe = noop
+    unobserve = noop
+    disconnect = noop
+  }
+)
+Element.prototype.scrollIntoView = noop
+
+vi.mock('@/components/date-settings-context', () => ({
+  useDateSettings: () => ({
+    dateFormat: 'yyyy-MM-dd',
+    timeFormat: '24h',
+    timezone: 'UTC',
+    weekStartDay: 1,
+  }),
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } }))
+
+const bookAppointment = vi.hoisted(() => vi.fn())
+vi.mock('@/features/calendar/Actions/bookAppointment', () => ({ bookAppointment }))
+
+const annasCar = {
+  id: 'v-anna',
+  make: 'Volvo',
+  model: 'V70',
+  year: 2015,
+  licensePlate: 'ANNA-1',
+  customerId: 'anna',
+  customer: { id: 'anna', name: 'Anna Berg' },
+}
+const searchVehicles = vi.hoisted(() => vi.fn())
+vi.mock('@/features/vehicles/Actions/vehicleActions', () => ({ searchVehicles }))
+const searchCustomers = vi.hoisted(() => vi.fn())
+vi.mock('@/features/customers/Actions/customerActions', () => ({ searchCustomers }))
+
+const { toast } = await import('sonner')
+const { BookAppointmentDialog } = await import(
+  '@/features/calendar/Components/BookAppointmentDialog'
+)
+
+function renderDialog(props: { defaultTime?: string } = {}) {
+  const onBooked = vi.fn()
+  const onOpenChange = vi.fn()
+  render(
+    <BookAppointmentDialog
+      open
+      onOpenChange={onOpenChange}
+      defaultDate="2026-11-17"
+      defaultTime={props.defaultTime}
+      onBooked={onBooked}
+    />
+  )
+  const [customerField, vehicleField] = screen.getAllByRole('combobox')
+  return { onBooked, onOpenChange, customerField, vehicleField }
+}
+
+const bookButton = () => screen.getByRole('button', { name: 'Book appointment' })
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  searchVehicles.mockResolvedValue({ success: true, data: [annasCar] })
+  searchCustomers.mockResolvedValue({
+    success: true,
+    data: [],
+  })
+  bookAppointment.mockResolvedValue({
+    success: true,
+    data: { id: 'job-1', vehicleId: 'v-anna', invoiceNumber: '2026-1044' },
+  })
+})
+
+describe('BookAppointmentDialog', () => {
+  it("books the car for its owner at the slot's day and time, for the hours given", async () => {
+    const { onBooked, onOpenChange, customerField, vehicleField } = renderDialog({
+      defaultTime: '09:30',
+    })
+
+    fireEvent.click(vehicleField)
+    fireEvent.click(await screen.findByRole('option', { name: /Volvo V70/ }))
+    // The owner comes with the car.
+    expect(customerField).toHaveTextContent('Anna Berg')
+    expect(vehicleField).toHaveTextContent('2015 Volvo V70 (ANNA-1)')
+
+    fireEvent.change(screen.getByLabelText('Work needed'), {
+      target: { value: 'Front brakes squeal\nOil service' },
+    })
+    fireEvent.change(screen.getByLabelText('Estimated hours'), { target: { value: '1.5' } })
+    fireEvent.click(bookButton())
+
+    await waitFor(() => expect(onBooked).toHaveBeenCalled())
+    expect(bookAppointment).toHaveBeenCalledWith({
+      vehicleId: 'v-anna',
+      customerId: 'anna',
+      work: 'Front brakes squeal\nOil service',
+      date: '2026-11-17',
+      time: '09:30',
+      durationMinutes: 90,
+    })
+    expect(toast.success).toHaveBeenCalledWith('Appointment booked')
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+  })
+
+  it('will not book until it knows who for, what for and when', async () => {
+    const { vehicleField } = renderDialog()
+    fireEvent.change(screen.getByLabelText('Work needed'), {
+      target: { value: 'Winter tyres on' },
+    })
+    expect(bookButton()).toBeDisabled()
+
+    fireEvent.click(vehicleField)
+    fireEvent.click(await screen.findByRole('option', { name: /Volvo V70/ }))
+    // Opened from a day rather than a time slot: the time is still missing.
+    expect(bookButton()).toBeDisabled()
+
+    fireEvent.change(screen.getByLabelText('Start time'), { target: { value: '13:00' } })
+    expect(bookButton()).toBeEnabled()
+  })
+
+  it('warns when nobody was free at that time, though it still booked', async () => {
+    bookAppointment.mockResolvedValue({
+      success: true,
+      data: { id: 'job-1', vehicleId: 'v-anna', invoiceNumber: '2026-1044', overbooked: true },
+    })
+    const { onBooked, vehicleField } = renderDialog({ defaultTime: '08:00' })
+    fireEvent.click(vehicleField)
+    fireEvent.click(await screen.findByRole('option', { name: /Volvo V70/ }))
+    fireEvent.change(screen.getByLabelText('Work needed'), { target: { value: 'Service' } })
+    fireEvent.click(bookButton())
+
+    await waitFor(() => expect(onBooked).toHaveBeenCalled())
+    expect(toast.warning).toHaveBeenCalledWith(
+      'Appointment booked, but no bay or technician is free at that time. Check the work board.'
+    )
+    expect(toast.success).not.toHaveBeenCalled()
+  })
+
+  it('keeps the dialog open and says why when the booking is refused', async () => {
+    bookAppointment.mockResolvedValue({ success: false, error: 'Insufficient permissions' })
+    const { onBooked, onOpenChange, vehicleField } = renderDialog({ defaultTime: '08:00' })
+    fireEvent.click(vehicleField)
+    fireEvent.click(await screen.findByRole('option', { name: /Volvo V70/ }))
+    fireEvent.change(screen.getByLabelText('Work needed'), { target: { value: 'Service' } })
+    fireEvent.click(bookButton())
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Insufficient permissions'))
+    expect(onBooked).not.toHaveBeenCalled()
+    expect(onOpenChange).not.toHaveBeenCalled()
+    expect(within(screen.getByRole('dialog')).getByLabelText('Work needed')).toHaveValue('Service')
+  })
+})

+ 283 - 0
src/__tests__/features/calendar/book-appointment.test.ts

@@ -0,0 +1,283 @@
+/**
+ * An appointment booked from the calendar is a scheduled job on the
+ * workshop's own clock, with what the customer asked for as its concerns,
+ * one per line, which is what the printed work order lists as work requested.
+ * It holds a bay, or a technician in a shop without bays, so the online
+ * booking page cannot offer the same hour to a customer.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/workshop-timezone', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/workshop-timezone')>()),
+  workshopTimeZone: async () => 'Europe/Oslo',
+}))
+const loadBookingContext = vi.hoisted(() => vi.fn())
+vi.mock('@/features/workboard/Lib/bookings', () => ({ loadBookingContext }))
+const bookingResources = vi.hoisted(() => vi.fn())
+vi.mock('@/features/inspection-reminders/Lib/booking', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/features/inspection-reminders/Lib/booking')>()),
+  bookingResources,
+}))
+vi.mock('@/lib/db', () => {
+  const db = {
+    // The job number query (src/lib/job-number.ts, once it lands): nothing yet
+    // in the workshop's format, so the number continues from the newest job.
+    $queryRaw: async () => [],
+    user: { findUnique: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    appSetting: { findMany: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    technician: { findFirst: vi.fn() },
+    workBay: { findFirst: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
+    $transaction: vi.fn(),
+  }
+  return { db }
+})
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { bookAppointment } from '@/features/calendar/Actions/bookAppointment'
+import { updateServiceSchema } from '@/features/vehicles/Schema/serviceSchema'
+
+const ORG = 'org-1'
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'office@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false, name: 'Kari' } as never)
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([])
+  vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Test Shop' } as never)
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({ invoiceNumber: '2026-1043' } as never)
+  vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'job-1' } as never)
+  vi.mocked(db.serviceRecord.update).mockResolvedValue({
+    id: 'job-1',
+    vehicleId: 'veh-1',
+    invoiceNumber: '2026-1044',
+  } as never)
+  // The transaction hands the same client in, so its writes are the ones read below.
+  vi.mocked(db.$transaction).mockImplementation((async (fn: (tx: typeof db) => unknown) =>
+    fn(db)) as never)
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+    id: 'veh-1',
+    customer: { taxExempt: false },
+  } as never)
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
+  vi.mocked(db.technician.findFirst).mockResolvedValue({ name: 'Tech' } as never)
+  vi.mocked(db.workBay.findFirst).mockResolvedValue({ id: 'bay' } as never)
+  // A shop with nothing set up to hold, unless a test says otherwise.
+  bookingResources.mockResolvedValue([])
+  loadBookingContext.mockResolvedValue({ bookings: [], hours: {}, closuresEnabled: false })
+})
+
+/** Tuesday 17 November 2026, 09:30 to 12:00 in Oslo: 08:30 to 11:00 UTC. */
+const TUESDAY = {
+  vehicleId: 'veh-1',
+  work: 'Service',
+  date: '2026-11-17',
+  time: '09:30',
+  durationMinutes: 150,
+}
+
+/** Something already holding a bay or a person on Tuesday, 08:00 to 09:00 UTC. */
+function held(resource: { workBayId?: string; technicianId?: string }) {
+  return {
+    id: 'other-job',
+    kind: 'serviceRecord' as const,
+    label: 'Brakes',
+    start: new Date('2026-11-17T08:00:00Z'),
+    end: new Date('2026-11-17T09:00:00Z'),
+    ...resource,
+  }
+}
+
+function created() {
+  return vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as Record<string, unknown>
+}
+function updated() {
+  return vi.mocked(db.serviceRecord.update).mock.calls[0][0] as {
+    where: { id: string }
+    data: { status: string; concerns: { create: { description: string; sortOrder: number }[] } }
+  }
+}
+
+describe('bookAppointment', () => {
+  it("books the car at the workshop's own clock, for as long as was said", async () => {
+    const result = await bookAppointment({
+      vehicleId: 'veh-1',
+      work: 'Front brakes squeal\n\n  Oil service  \n',
+      date: '2026-11-17',
+      time: '09:30',
+      durationMinutes: 150,
+    })
+
+    expect(result.success).toBe(true)
+    // 09:30 in Oslo in November is 08:30 UTC, whatever the server's own zone.
+    expect(created().startDateTime).toEqual(new Date('2026-11-17T08:30:00Z'))
+    expect(created().endDateTime).toEqual(new Date('2026-11-17T11:00:00Z'))
+    expect(created().vehicleId).toBe('veh-1')
+    expect(created().customerId).toBeNull()
+    expect(created().title).toBe('Front brakes squeal')
+    // A job like any other: it takes the next number.
+    expect(created().invoiceNumber).toBe('2026-1044')
+
+    expect(updated().where).toEqual({ id: 'job-1' })
+    expect(updated().data.status).toBe('scheduled')
+    expect(updated().data.concerns.create).toEqual([
+      { description: 'Front brakes squeal', sortOrder: 0 },
+      { description: 'Oil service', sortOrder: 1 },
+    ])
+  })
+
+  it('books a customer whose car is not on file yet as work on a car', async () => {
+    vi.mocked(db.customer.findFirst).mockResolvedValue({ id: 'cust-1', taxExempt: false } as never)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: 'invoice.shopFeeEnabled', value: 'true' },
+      { key: 'invoice.shopFeeMode', value: 'flat' },
+      { key: 'invoice.shopFeeAmount', value: '75' },
+    ] as never)
+
+    const result = await bookAppointment({
+      customerId: 'cust-1',
+      work: 'New customer, timing belt',
+      date: '2026-11-18',
+      time: '12:00',
+      durationMinutes: 240,
+    })
+
+    expect(result.success).toBe(true)
+    expect(db.customer.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'cust-1', organizationId: ORG } })
+    )
+    expect(created().vehicleId).toBeNull()
+    expect(created().customerId).toBe('cust-1')
+    // The shop fee a counter sale leaves off is on the job from the start.
+    expect(created().laborItems).toEqual({
+      create: [expect.objectContaining({ total: 75 })],
+    })
+  })
+
+  it("refuses another workshop's car and creates nothing", async () => {
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
+
+    const result = await bookAppointment({
+      vehicleId: 'someone-elses-car',
+      work: 'Service',
+      date: '2026-11-17',
+      time: '09:00',
+      durationMinutes: 60,
+    })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Vehicle not found')
+    expect(db.vehicle.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'someone-elses-car', organizationId: ORG } })
+    )
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+  })
+
+  it('needs a car or a customer, and something to do', async () => {
+    const nobody = await bookAppointment({
+      work: 'Service',
+      date: '2026-11-17',
+      time: '09:00',
+      durationMinutes: 60,
+    })
+    const nothing = await bookAppointment({
+      vehicleId: 'veh-1',
+      work: '   ',
+      date: '2026-11-17',
+      time: '09:00',
+      durationMinutes: 60,
+    })
+
+    expect(nobody.success).toBe(false)
+    expect(nothing.success).toBe(false)
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('what an appointment holds', () => {
+  it('takes a bay that is free for the whole appointment', async () => {
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }, { workBayId: 'bay-2' }])
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await bookAppointment(TUESDAY)
+
+    expect(result.success).toBe(true)
+    expect(created().workBayId).toBe('bay-2')
+    expect(result.data?.overbooked).toBe(false)
+  })
+
+  it("in a shop without bays, takes the default technician when they're free", async () => {
+    bookingResources.mockResolvedValue([{ technicianId: 'tech-a' }, { technicianId: 'tech-b' }])
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: 'tech-b' } as never)
+
+    await bookAppointment(TUESDAY)
+
+    expect(created().technicianId).toBe('tech-b')
+  })
+
+  it('takes another technician when the default one is busy then', async () => {
+    bookingResources.mockResolvedValue([{ technicianId: 'tech-a' }, { technicianId: 'tech-b' }])
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: 'tech-b' } as never)
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ technicianId: 'tech-b' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    await bookAppointment(TUESDAY)
+
+    expect(created().technicianId).toBe('tech-a')
+  })
+
+  it('still books an hour where everything is taken, and says so', async () => {
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }])
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await bookAppointment(TUESDAY)
+
+    expect(result.success).toBe(true)
+    expect(created().workBayId).toBeUndefined()
+    expect(result.data?.overbooked).toBe(true)
+  })
+
+  it('refuses a day that does not exist rather than booking another one', async () => {
+    const result = await bookAppointment({ ...TUESDAY, date: '2026-02-31' })
+
+    expect(result.success).toBe(false)
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('a booked job on the work order page', () => {
+  it('can be saved while the car has not arrived yet', () => {
+    // The page sends the status back as it found it.
+    const saved = updateServiceSchema.safeParse({ id: 'job-1', status: 'scheduled' })
+    expect(saved.success).toBe(true)
+  })
+})

+ 9 - 0
src/__tests__/features/calendar/time-grid-interactions.test.tsx

@@ -143,6 +143,7 @@ function setup() {
   const onRefresh = vi.fn()
   const actions = {
     onNewWorkOrder: vi.fn(),
+    onBookAppointment: vi.fn(),
     onNewReminder: vi.fn(),
     onNewQuote: vi.fn(),
     onScheduleMessage: vi.fn(),
@@ -209,6 +210,14 @@ describe('TimeGridView', () => {
     expect(actions.onScheduleMessage).toHaveBeenCalledWith('2026-09-02', '15:00')
   })
 
+  it('books an appointment at the clicked slot', async () => {
+    const { columns, actions } = setup()
+    fireEvent.click(columns[2], { clientX: 10, clientY: 540 })
+    fireEvent.click(await screen.findByText('Book appointment at 09:00'))
+    expect(actions.onBookAppointment).toHaveBeenCalledWith('2026-09-02', '09:00')
+    expect(actions.onNewWorkOrder).not.toHaveBeenCalled()
+  })
+
   it('peeks at a block instead of creating anything', async () => {
     const { actions } = setup()
     fireEvent.click(screen.getByText('Brake service'))

+ 150 - 0
src/features/calendar/Actions/bookAppointment.ts

@@ -0,0 +1,150 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { zonedDate } from '@/lib/timezone'
+import { createDraftRecord } from '@/features/vehicles/Lib/createDraftRecord'
+import { loadBookingContext } from '@/features/workboard/Lib/bookings'
+import {
+  type Resource,
+  bookingResources,
+  freeResources,
+} from '@/features/inspection-reminders/Lib/booking'
+import { bookAppointmentSchema, type BookAppointmentInput } from '../Schema/appointmentSchema'
+import { appointmentConcerns, appointmentTitle } from '../Lib/appointment'
+
+/**
+ * Books a car or a customer in from the calendar without going through the
+ * work order page.
+ *
+ * The booking is a job like any other, so the work board, the technician app
+ * and online booking all see the time as taken, and nothing has to be copied
+ * across when the car arrives. It starts out `scheduled`: checking the car in
+ * or clocking on moves it on, as with a customer's own online booking. What
+ * the customer wants done becomes the job's concerns, one per line, which is
+ * what the printed work order lists under work requested.
+ *
+ * It holds a bay, or in a shop without bays a technician, the same way an
+ * online booking does (see `resourceFor`). A job holding neither is invisible
+ * to the online booking page, which would then offer the same hour to a
+ * customer.
+ */
+export async function bookAppointment(input: BookAppointmentInput) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = bookAppointmentSchema.parse(input)
+
+      // Ids from the browser can belong to another workshop.
+      const vehicle = data.vehicleId
+        ? await db.vehicle.findFirst({
+            where: { id: data.vehicleId, organizationId },
+            select: { id: true, customer: { select: { taxExempt: true } } },
+          })
+        : null
+      if (data.vehicleId && !vehicle) throw new Error('Vehicle not found')
+
+      // A job on a car finds its customer through the car, so a customer is
+      // only looked up for a booking that has no car yet.
+      const customer =
+        !vehicle && data.customerId
+          ? await db.customer.findFirst({
+              where: { id: data.customerId, organizationId },
+              select: { id: true, taxExempt: true },
+            })
+          : null
+      if (!vehicle && !customer) throw new Error('Customer not found')
+
+      const timeZone = await workshopTimeZone(organizationId)
+      const [year, month, day] = data.date.split('-').map(Number)
+      const [hour, minute] = data.time.split(':').map(Number)
+      const start = zonedDate(year, month, day, hour, minute, timeZone)
+      const end = new Date(start.getTime() + data.durationMinutes * 60_000)
+      const concerns = appointmentConcerns(data.work)
+      const { resource, full } = await resourceFor(organizationId, start, data.durationMinutes)
+
+      const record = await db.$transaction(async (tx) => {
+        const created = await createDraftRecord(
+          { organizationId, userId },
+          {
+            vehicleId: vehicle?.id ?? null,
+            customerId: vehicle ? null : (customer?.id ?? null),
+            customerExempt: vehicle
+              ? (vehicle.customer?.taxExempt ?? false)
+              : (customer?.taxExempt ?? false),
+            title: appointmentTitle(data.work),
+            startDateTime: start,
+            endDateTime: end,
+            technicianId: resource?.technicianId ?? undefined,
+            workBayId: resource?.workBayId ?? undefined,
+            // Work on a car even before the car is on file.
+            shopWork: true,
+            tx,
+          }
+        )
+        return tx.serviceRecord.update({
+          where: { id: created.id },
+          data: {
+            status: 'scheduled',
+            description: data.work,
+            concerns: {
+              create: concerns.map((description, sortOrder) => ({ description, sortOrder })),
+            },
+          },
+          select: { id: true, vehicleId: true, invoiceNumber: true },
+        })
+      })
+      // Booked all the same: the office may fill an hour the booking page
+      // would refuse. The dialog says so, and the board shows the clash.
+      return { ...record, overbooked: full }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: 'service.create',
+        entity: 'ServiceRecord',
+        entityId: result.id,
+        details: {
+          key: 'service_bookAppointment',
+          params: { ref: result.invoiceNumber || result.id },
+        },
+        metadata: { serviceRecordId: result.id, vehicleId: result.vehicleId },
+      }),
+    }
+  )
+}
+
+/**
+ * What the appointment occupies, by the rule online booking counts with: a
+ * bay when the shop has any, else a technician (`bookingResources`). The
+ * first one free for the whole appointment, preferring the workshop's
+ * default technician among technicians, so the job lands where a new job
+ * would. `full` when the shop has something to hold and all of it is taken
+ * then; the appointment is booked without a resource and the office is told.
+ */
+async function resourceFor(
+  organizationId: string,
+  start: Date,
+  durationMinutes: number
+): Promise<{ resource: Resource | null; full: boolean }> {
+  const resources = await bookingResources(organizationId)
+  if (resources.length === 0) return { resource: null, full: false }
+  // From the day before the start to the day after the end, as loadBookingContext reads.
+  const { bookings } = await loadBookingContext(organizationId, start, {
+    days: Math.ceil(durationMinutes / (24 * 60)) + 1,
+  })
+  const free = freeResources(start, durationMinutes, resources, bookings)
+  if (free.length === 0) return { resource: null, full: true }
+  if (free[0].technicianId) {
+    const preferred = await db.appSetting.findUnique({
+      where: { organizationId_key: { organizationId, key: 'workshop.defaultTechnicianId' } },
+      select: { value: true },
+    })
+    const theirs = free.find((r) => r.technicianId === preferred?.value)
+    if (theirs) return { resource: theirs, full: false }
+  }
+  return { resource: free[0], full: false }
+}

+ 252 - 0
src/features/calendar/Components/BookAppointmentDialog.tsx

@@ -0,0 +1,252 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { DateInput } from '@/components/ui/date-input'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { useServiceType } from '@/components/service-type-context'
+import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
+import { VehicleCombobox } from '@/features/quotes/Components/VehicleCombobox'
+import { bookAppointment } from '../Actions/bookAppointment'
+import { MAX_APPOINTMENT_MINUTES } from '../Schema/appointmentSchema'
+
+/** Hours an appointment is booked for until somebody says otherwise. */
+const DEFAULT_HOURS = '1'
+
+interface BookAppointmentDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  /** YYYY-MM-DD of the day the calendar was on or right-clicked. */
+  defaultDate?: string
+  /** HH:MM of the time slot it was opened from, when there was one. */
+  defaultTime?: string
+  onBooked?: () => void
+}
+
+/**
+ * Books a car or a customer in from the calendar: who, what they want done,
+ * when, and roughly how long. No work order page is opened; the booking is a
+ * scheduled job that the shop opens when the car arrives.
+ */
+export function BookAppointmentDialog({
+  open,
+  onOpenChange,
+  defaultDate,
+  defaultTime,
+  onBooked,
+}: BookAppointmentDialogProps) {
+  const t = useTranslations('calendar.appointment')
+  const isMarine = useServiceType() === 'marine'
+  const [saving, setSaving] = useState(false)
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(next) => {
+        if (!saving) onOpenChange(next)
+      }}
+    >
+      <DialogContent className="max-h-[90vh] sm:max-w-lg" showCloseButton={!saving}>
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>
+            {isMarine ? t('descriptionMarine') : t('description')}
+          </DialogDescription>
+        </DialogHeader>
+        {/* Mounted with the dialog, so what was typed and then cancelled is
+            gone the next time it opens. */}
+        <BookAppointmentForm
+          defaultDate={defaultDate}
+          defaultTime={defaultTime}
+          saving={saving}
+          onSavingChange={setSaving}
+          onCancel={() => onOpenChange(false)}
+          onBooked={() => {
+            onOpenChange(false)
+            onBooked?.()
+          }}
+        />
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function BookAppointmentForm({
+  defaultDate,
+  defaultTime,
+  saving,
+  onSavingChange,
+  onCancel,
+  onBooked,
+}: {
+  defaultDate?: string
+  defaultTime?: string
+  saving: boolean
+  onSavingChange: (saving: boolean) => void
+  onCancel: () => void
+  onBooked: () => void
+}) {
+  const t = useTranslations('calendar.appointment')
+  const tc = useTranslations('common.buttons')
+  const isMarine = useServiceType() === 'marine'
+
+  const [customerId, setCustomerId] = useState('')
+  // The name the customer field shows when picking a car filled it in.
+  const [vehicleOwner, setVehicleOwner] = useState<{
+    id: string
+    name: string
+    company: string | null
+  } | null>(null)
+  const [vehicleId, setVehicleId] = useState('')
+  const [vehicleCustomerId, setVehicleCustomerId] = useState<string | null>(null)
+  const [work, setWork] = useState('')
+  const [date, setDate] = useState(defaultDate ?? '')
+  const [time, setTime] = useState(defaultTime ?? '')
+  const [hours, setHours] = useState(DEFAULT_HOURS)
+
+  const minutes = Math.round(Number(hours.replace(',', '.')) * 60)
+  const minutesValid =
+    Number.isFinite(minutes) && minutes >= 5 && minutes <= MAX_APPOINTMENT_MINUTES
+  const ready = Boolean((vehicleId || customerId) && work.trim() && date && time && minutesValid)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!ready || saving) return
+    onSavingChange(true)
+    try {
+      const result = await bookAppointment({
+        vehicleId: vehicleId || undefined,
+        customerId: customerId || undefined,
+        work,
+        date,
+        time,
+        durationMinutes: minutes,
+      })
+      if (result.success) {
+        // Booked all the same when every bay or technician was taken then.
+        if (result.data?.overbooked) toast.warning(t('bookedNobodyFree'))
+        else toast.success(t('booked'))
+        onBooked()
+      } else {
+        toast.error(result.error || t('failed'))
+      }
+    } catch {
+      toast.error(t('failed'))
+    } finally {
+      onSavingChange(false)
+    }
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
+      <DialogBody className="space-y-4">
+        <div className="space-y-2">
+          <Label>{t('customer')}</Label>
+          <CustomerCombobox
+            value={customerId}
+            initialCustomer={vehicleOwner}
+            placeholder={t('selectCustomer')}
+            searchPlaceholder={t('searchCustomer')}
+            noneLabel={t('none')}
+            emptyLabel={t('noCustomers')}
+            onChange={(id) => {
+              setCustomerId(id)
+              // A car belonging to someone else no longer fits.
+              if (id && vehicleId && vehicleCustomerId !== id) {
+                setVehicleId('')
+                setVehicleCustomerId(null)
+              }
+            }}
+          />
+        </div>
+        <div className="space-y-2">
+          <Label>{isMarine ? t('vehicleMarine') : t('vehicle')}</Label>
+          <VehicleCombobox
+            value={vehicleId}
+            customerId={customerId || undefined}
+            placeholder={isMarine ? t('selectVehicleMarine') : t('selectVehicle')}
+            searchPlaceholder={isMarine ? t('searchVehicleMarine') : t('searchVehicle')}
+            noneLabel={t('none')}
+            emptyLabel={isMarine ? t('noVehiclesMarine') : t('noVehicles')}
+            onChange={(id, vehicle) => {
+              setVehicleId(id)
+              setVehicleCustomerId(vehicle?.customerId ?? null)
+              if (vehicle?.customer) {
+                setCustomerId(vehicle.customer.id)
+                setVehicleOwner({ ...vehicle.customer, company: null })
+              }
+            }}
+          />
+          <p className="text-xs text-muted-foreground">
+            {isMarine ? t('whoHintMarine') : t('whoHint')}
+          </p>
+        </div>
+        <div className="space-y-2">
+          <Label htmlFor="appointment-work">{t('work')}</Label>
+          <Textarea
+            id="appointment-work"
+            value={work}
+            onChange={(e) => setWork(e.target.value)}
+            placeholder={isMarine ? t('workPlaceholderMarine') : t('workPlaceholder')}
+            rows={3}
+            maxLength={4000}
+          />
+          <p className="text-xs text-muted-foreground">{t('workHint')}</p>
+        </div>
+        <div className="grid gap-4 sm:grid-cols-3">
+          <div className="space-y-2">
+            <Label htmlFor="appointment-date">{t('date')}</Label>
+            <DateInput id="appointment-date" value={date} onChange={setDate} />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="appointment-time">{t('time')}</Label>
+            <Input
+              id="appointment-time"
+              type="time"
+              value={time}
+              onChange={(e) => setTime(e.target.value)}
+              required
+            />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="appointment-hours">{t('hours')}</Label>
+            <Input
+              id="appointment-hours"
+              type="number"
+              inputMode="decimal"
+              min={0.25}
+              max={MAX_APPOINTMENT_MINUTES / 60}
+              step={0.25}
+              value={hours}
+              onChange={(e) => setHours(e.target.value)}
+              required
+            />
+          </div>
+        </div>
+      </DialogBody>
+      <DialogFooter>
+        <Button type="button" variant="outline" onClick={onCancel} disabled={saving}>
+          {tc('cancel')}
+        </Button>
+        <Button data-save-shortcut type="submit" disabled={!ready || saving}>
+          {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+          {t('submit')}
+        </Button>
+      </DialogFooter>
+    </form>
+  )
+}

+ 18 - 0
src/features/calendar/Components/CalendarClient.tsx

@@ -22,6 +22,7 @@ import {
 } from '@/features/workshop-calendar/Components/TimeOffDialog'
 import { listAbsences, type AbsenceView } from '@/features/workshop-calendar/Actions/absenceActions'
 import { getCalendarEvents } from '../Actions/calendarActions'
+import { BookAppointmentDialog } from './BookAppointmentDialog'
 import type { CalendarEvent, CalendarEventType } from '../Actions/calendarActions'
 import {
   eachDay,
@@ -137,6 +138,7 @@ export default function CalendarClient({
   const [showPicker, setShowPicker] = useState(false)
   const [showDateChoice, setShowDateChoice] = useState(false)
   const [workOrderQuery, setWorkOrderQuery] = useState<Record<string, string> | undefined>()
+  const [showAppointmentDialog, setShowAppointmentDialog] = useState(false)
   const [showReminderDialog, setShowReminderDialog] = useState(false)
   const [showQuoteDialog, setShowQuoteDialog] = useState(false)
   const [showMessageDialog, setShowMessageDialog] = useState(false)
@@ -346,6 +348,9 @@ export default function CalendarClient({
           if (dateStr !== todayStr) setShowDateChoice(true)
           else openWorkOrderPicker(undefined)
           break
+        case 'appointment':
+          setShowAppointmentDialog(true)
+          break
         case 'reminder':
           setShowReminderDialog(true)
           break
@@ -385,6 +390,11 @@ export default function CalendarClient({
           boardEnd: minutesToTime(Math.min(startMins + SLOT_WORK_ORDER_MINUTES, 24 * 60 - 1)),
         })
       },
+      onBookAppointment: (day, time) => {
+        setMenuDateStr(day)
+        setMenuTime(time)
+        setShowAppointmentDialog(true)
+      },
       onNewReminder: (day, time) => {
         setMenuDateStr(day)
         setMenuTime(time)
@@ -614,6 +624,14 @@ export default function CalendarClient({
         </DialogContent>
       </Dialog>
 
+      <BookAppointmentDialog
+        open={showAppointmentDialog}
+        onOpenChange={setShowAppointmentDialog}
+        defaultDate={menuDateStr}
+        defaultTime={menuTime}
+        onBooked={refresh}
+      />
+
       <ReminderFormDialog
         open={showReminderDialog}
         onOpenChange={setShowReminderDialog}

+ 6 - 1
src/features/calendar/Components/CalendarToolbar.tsx

@@ -3,6 +3,7 @@
 import { useTranslations } from 'next-intl'
 import {
   Bell,
+  CalendarPlus,
   ChevronDown,
   ChevronLeft,
   ChevronRight,
@@ -30,7 +31,7 @@ import { cn } from '@/lib/utils'
 import type { CalendarView } from '../Lib/calendar-range'
 import type { CalendarPreferences } from './useCalendarPreferences'
 
-export type CreateKind = 'workOrder' | 'reminder' | 'quote' | 'message' | 'timeOff'
+export type CreateKind = 'workOrder' | 'appointment' | 'reminder' | 'quote' | 'message' | 'timeOff'
 
 /** The keys the view switcher shows and the keyboard listens for. */
 export const VIEW_SHORTCUTS: Record<CalendarView, string> = {
@@ -185,6 +186,10 @@ export function CalendarToolbar({
               <Wrench className="mr-2 h-4 w-4" />
               {t('contextMenu.newWorkOrder')}
             </DropdownMenuItem>
+            <DropdownMenuItem onClick={() => onCreate('appointment')}>
+              <CalendarPlus className="mr-2 h-4 w-4" />
+              {t('contextMenu.bookAppointment')}
+            </DropdownMenuItem>
             <DropdownMenuItem onClick={() => onCreate('reminder')}>
               <Bell className="mr-2 h-4 w-4" />
               {t('contextMenu.newReminder')}

+ 11 - 1
src/features/calendar/Components/DayContextMenu.tsx

@@ -2,7 +2,7 @@
 
 import type { ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
-import { Bell, ExternalLink, FileText, Send, UserX, Wrench } from 'lucide-react'
+import { Bell, CalendarPlus, ExternalLink, FileText, Send, UserX, Wrench } from 'lucide-react'
 import {
   ContextMenu,
   ContextMenuContent,
@@ -26,6 +26,8 @@ import type { CalendarEvent } from '../Actions/calendarActions'
 export interface DayActions {
   /** `time` is HH:MM when the click landed on a time slot. */
   onNewWorkOrder: (dateStr: string, time?: string) => void
+  /** Book a car or customer in without opening a work order. */
+  onBookAppointment: (dateStr: string, time?: string) => void
   onNewReminder: (dateStr: string, time?: string) => void
   onNewQuote: (dateStr: string, time?: string) => void
   onScheduleMessage: (dateStr: string, time?: string) => void
@@ -85,6 +87,14 @@ export function DayContextMenu({
               })
             : t('contextMenu.newWorkOrder')}
         </ContextMenuItem>
+        <ContextMenuItem onClick={() => actions.onBookAppointment(dateStr, time ?? undefined)}>
+          <CalendarPlus className="mr-2 h-4 w-4" />
+          {time
+            ? t('contextMenu.bookAppointmentAt', {
+                time: formatClock(timeToMinutes(time), timeFormat),
+              })
+            : t('contextMenu.bookAppointment')}
+        </ContextMenuItem>
         <ContextMenuItem onClick={() => actions.onNewReminder(dateStr, time ?? undefined)}>
           <Bell className="mr-2 h-4 w-4" />
           {t('contextMenu.newReminder')}

+ 8 - 1
src/features/calendar/Components/TimeGridView.tsx

@@ -2,7 +2,7 @@
 
 import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
 import { useTranslations } from 'next-intl'
-import { Bell, FileText, Send, Wrench } from 'lucide-react'
+import { Bell, CalendarPlus, FileText, Send, Wrench } from 'lucide-react'
 import { useDayFormatter } from './useDayFormatter'
 import { useDateSettings } from '@/components/date-settings-context'
 import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -307,6 +307,13 @@ export function TimeGridView({
                 })}
                 onClick={() => runSlotAction(actions.onNewWorkOrder)}
               />
+              <SlotMenuItem
+                icon={CalendarPlus}
+                label={t('contextMenu.bookAppointmentAt', {
+                  time: formatClock(timeToMinutes(slot.time), timeFormat),
+                })}
+                onClick={() => runSlotAction(actions.onBookAppointment)}
+              />
               <SlotMenuItem
                 icon={Bell}
                 label={t('contextMenu.newReminder')}

+ 23 - 0
src/features/calendar/Lib/appointment.ts

@@ -0,0 +1,23 @@
+/** How long a booked job's name may be before it is cut, as on a portal request. */
+const TITLE_LENGTH = 60
+
+/**
+ * What the customer asked for, one concern per line and in the order given.
+ * Each prints under "work requested" on the work order and is answered on
+ * its own when the car is in.
+ */
+export function appointmentConcerns(work: string): string[] {
+  return work
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter(Boolean)
+}
+
+/**
+ * The job's name on the calendar and the work board: the first thing asked
+ * for, which is what the person reading the day needs to see.
+ */
+export function appointmentTitle(work: string): string {
+  const first = appointmentConcerns(work)[0] ?? work.trim()
+  return first.length > TITLE_LENGTH ? `${first.slice(0, TITLE_LENGTH - 3)}...` : first
+}

+ 33 - 0
src/features/calendar/Schema/appointmentSchema.ts

@@ -0,0 +1,33 @@
+import { z } from 'zod'
+import { isDateKey, isTime } from '@/features/workshop-calendar/Lib/calendar'
+
+/** The longest an appointment can be booked for: a week of the shop's time. */
+export const MAX_APPOINTMENT_MINUTES = 7 * 24 * 60
+
+/**
+ * An appointment booked from the calendar: who it is for, what they want
+ * done, and when and for how long, read as the workshop's own wall clock.
+ * A car, a customer or both; with only a customer the car is added to the
+ * job when it arrives.
+ */
+export const bookAppointmentSchema = z
+  .object({
+    vehicleId: z.string().min(1).optional(),
+    customerId: z.string().min(1).optional(),
+    /** What needs doing, one thing per line. */
+    work: z.string().trim().min(1).max(4000),
+    /**
+     * YYYY-MM-DD in the workshop's time zone, and a day that exists: the
+     * 31st of February would otherwise roll over into March.
+     */
+    date: z.string().refine(isDateKey, 'Pick a valid date'),
+    /** HH:MM in the workshop's time zone. */
+    time: z.string().refine(isTime, 'Pick a valid time'),
+    durationMinutes: z.number().int().min(5).max(MAX_APPOINTMENT_MINUTES),
+  })
+  .refine((v) => Boolean(v.vehicleId || v.customerId), {
+    message: 'Choose a vehicle or a customer',
+    path: ['vehicleId'],
+  })
+
+export type BookAppointmentInput = z.input<typeof bookAppointmentSchema>

+ 4 - 1
src/features/quotes/Components/VehicleCombobox.tsx

@@ -170,7 +170,10 @@ export function VehicleCombobox({
   }, [hasMore, search, options.length, loadOptions])
 
   return (
-    <Popover open={open} onOpenChange={setOpen}>
+    // modal: the list portals out of any dialog this sits in, and the dialog's
+    // scroll lock swallowed wheel events over it, so a list longer than the
+    // popover could not be scrolled. As in CustomerCombobox.
+    <Popover open={open} onOpenChange={setOpen} modal>
       <PopoverTrigger asChild>
         <Button variant="outline" role="combobox" className="w-full justify-between font-normal">
           <span className="truncate">{selected ? formatVehicle(selected) : placeholder}</span>

+ 8 - 1
src/features/vehicles/Lib/createDraftRecord.ts

@@ -62,6 +62,13 @@ export async function createDraftRecord(
      * midnight.
      */
     bookFrom?: Date
+    /**
+     * Whether this is work on a car, which takes the shop fee and the
+     * workshop's warranty. Follows the vehicle when left out: a job without
+     * one is a sale over the counter. Set for an appointment booked for a
+     * customer whose car is not on file yet; the car is added when it arrives.
+     */
+    shopWork?: boolean
     /**
      * The transaction the record is written in, when the caller has more to
      * write that must land with it or not at all. Reads stay on the pool.
@@ -181,7 +188,7 @@ export async function createDraftRecord(
   // A parts sale over the counter holds no bay and no technician, so there is
   // no slot to find and nothing to search past. Only work on a vehicle is
   // scheduled around what the shop already has booked.
-  const isShopWork = !!opts.vehicleId
+  const isShopWork = opts.shopWork ?? !!opts.vehicleId
   if (opts.startDateTime || !isShopWork) {
     defaultStart =
       opts.startDateTime ??

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

@@ -87,7 +87,12 @@ export const createServiceSchema = z.object({
   title: z.string().min(1, 'Title is required'),
   description: z.string().optional(),
   type: z.enum(['maintenance', 'repair', 'upgrade', 'inspection']).default('maintenance'),
-  status: z.enum(['pending', 'in-progress', 'waiting-parts', 'completed']).default('pending'),
+  // `scheduled` is a booked job whose car has not arrived: an online booking or
+  // one made from the calendar. The work order page sends the status back as
+  // it found it, so without it here such a job could not be saved at all.
+  status: z
+    .enum(['scheduled', 'pending', 'in-progress', 'waiting-parts', 'completed'])
+    .default('pending'),
   cost: z.coerce.number().min(0).default(0),
   mileage: z.coerce.number().nullable().optional(),
   serviceDate: z.string().default(() => new Date().toISOString()),