Przeglądaj źródła

Check the slot before booking, and carry the sheet's typeface into the panels (#282)

* Carry the sheet's typeface into the panels, and link the manual

Choosing a font left the customer and vehicle boxes in the old face while
their headings changed, which reads as the setting half working.

A section's look reported the font the section itself overrode, which is
nothing at all unless somebody had set one. That blank then travelled into
every line that mentions a font, and mentioning it is what overrides what
would otherwise be inherited: the PDF merges a line's own style over the
block's, and an explicit blank wins. Headings never mention a font, so they
followed the document while the values did not.

The look now resolves the font against the sheet, so a line that mentions it
says the same thing the block does, and a section that overrides it still
wins. Covered for the printed sheet and for the shared invoice and quote a
customer opens, which are drawn by the same generator.

Also puts a link to the designer's documentation in its toolbar. The tool is
large enough that the manual is worth one click from inside it rather than
something to go and look for.

* Check the slot before booking a job into it

Setting a technician, a time and a bay booked whatever was typed, whether or
not the shop could take it. Nothing compared the new job against what was
already in the diary, so a lift could be promised twice and nobody found out
until the car arrived.

A technician and a bay are separate resources, set independently on the
board, so two jobs at the same hour only clash when they want the same person
or the same ramp. Inspections count too: they hold a bay and a person exactly
as a job does. The rules live in a pure module because each of them is worth
a test, including the ones that must not fire, like back-to-back bookings and
a job clashing with itself when it is reopened.

The schedule panel now says what a booking would collide with, naming the
job and which resource is taken, and offers the first slot that fits inside
the shop's own working hours. It warns rather than refuses: a shop sometimes
double-books deliberately, and a board that quietly rejects bookings stops
describing the real day.

A new job with no stated time now takes that first free slot as well. It used
to land on today at opening time, which is usually an hour that has already
gone, and every job created that day was given the same one.

* Open the designer in its own tab, and give it a way back

The designer is a full-screen tool that opened over the settings page,
taking whatever was part way through there with it and leaving the browser's
own back button as the only way out.

It now opens in its own tab, from the starting-point cards as well as the
button, since both are the same door into the same tool and one of them
swallowing the page while the other did not would be the odd part. Its
toolbar carries the way back, because a fresh tab has no history to go back
through and the browser's button would land somewhere else entirely.

Leaving asks first when there is unsaved work. A design only reaches the
sheet when Save writes it, so that one click is the one that can throw away
an afternoon with nothing to undo it.
Bernt Christian Egeland 1 miesiąc temu
rodzic
commit
2b4e38ba38
38 zmienionych plików z 1215 dodań i 38 usunięć
  1. 11 1
      messages/de/service.json
  2. 4 1
      messages/de/settings.json
  3. 11 1
      messages/en/service.json
  4. 4 1
      messages/en/settings.json
  5. 11 1
      messages/es/service.json
  6. 4 1
      messages/es/settings.json
  7. 11 1
      messages/fr/service.json
  8. 4 1
      messages/fr/settings.json
  9. 11 1
      messages/it/service.json
  10. 4 1
      messages/it/settings.json
  11. 11 1
      messages/lt/service.json
  12. 4 1
      messages/lt/settings.json
  13. 11 1
      messages/nb/service.json
  14. 4 1
      messages/nb/settings.json
  15. 11 1
      messages/nl/service.json
  16. 4 1
      messages/nl/settings.json
  17. 11 1
      messages/pl/service.json
  18. 4 1
      messages/pl/settings.json
  19. 11 1
      messages/pt-BR/service.json
  20. 4 1
      messages/pt-BR/settings.json
  21. 11 1
      messages/ru/service.json
  22. 4 1
      messages/ru/settings.json
  23. 11 1
      messages/tr/service.json
  24. 4 1
      messages/tr/settings.json
  25. 40 0
      src/__tests__/features/public/invoice-view.test.tsx
  26. 37 0
      src/__tests__/features/public/quote-view.test.tsx
  27. 36 0
      src/__tests__/features/settings/spec-pdf-print.test.tsx
  28. 243 0
      src/__tests__/features/workboard/availability.test.ts
  29. 72 3
      src/__tests__/features/workorders/create-workorder.test.ts
  30. 3 1
      src/app/(authenticated)/settings/templates/template-settings.tsx
  31. 35 0
      src/features/invoice-designer/Components/InvoiceDesigner.tsx
  32. 11 1
      src/features/invoice-designer/Spec/buildSpec.ts
  33. 165 2
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx
  34. 43 7
      src/features/vehicles/Lib/createDraftRecord.ts
  35. 93 0
      src/features/workboard/Actions/boardActions/availability.ts
  36. 1 0
      src/features/workboard/Actions/boardActions/index.ts
  37. 157 0
      src/features/workboard/Lib/availability.ts
  38. 99 0
      src/features/workboard/Lib/bookings.ts

+ 11 - 1
messages/de/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Macht sie zum Techniker",
     "addSomeoneNew": "Jemanden hinzufügen",
     "notOnBoard": "Noch nicht auf der Tafel",
-    "putOnBoard": "Auf die Tafel"
+    "putOnBoard": "Auf die Tafel",
+    "slotTaken": "{count, plural, one {Dieser Termin ist bereits belegt} other {Dieser Termin hat # Überschneidungen}}",
+    "anotherJob": "ein anderer Auftrag",
+    "clashTechnician": "der Techniker ist belegt",
+    "clashBay": "die Box ist belegt",
+    "clashBoth": "beide sind belegt",
+    "bookedAnyway": "Trotzdem gespeichert. Ändern Sie die Zeit oder wählen Sie den nächsten freien Termin.",
+    "pickNextAvailable": "Nächsten freien Termin wählen",
+    "findingSlot": "Termin wird gesucht…",
+    "slotPicked": "Auf den nächsten freien Termin gelegt",
+    "noSlotFound": "In den nächsten Wochen kein freier Termin gefunden"
   },
   "notifications": {
     "title": "Kundenbenachrichtigungen",

+ 4 - 1
messages/de/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Es ist kein Slogan hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "sloganSetHint": "Ihr Slogan stammt aus Ihren Firmendaten.",
     "sloganLink": "Firmeneinstellungen",
-    "placeholder": "Platzhalter"
+    "placeholder": "Platzhalter",
+    "leaveTitle": "Ohne Speichern verlassen?",
+    "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
+    "leaveConfirm": "Verlassen"
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 11 - 1
messages/en/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Makes them a technician",
     "addSomeoneNew": "Add someone new",
     "notOnBoard": "Not on the board yet",
-    "putOnBoard": "Put on board"
+    "putOnBoard": "Put on board",
+    "slotTaken": "{count, plural, one {This slot is already booked} other {This slot has # clashes}}",
+    "anotherJob": "another job",
+    "clashTechnician": "the technician is busy",
+    "clashBay": "the bay is in use",
+    "clashBoth": "both are taken",
+    "bookedAnyway": "Saved anyway. Change the time, or pick the next free slot.",
+    "pickNextAvailable": "Pick next available slot",
+    "findingSlot": "Finding a slot…",
+    "slotPicked": "Booked into the next free slot",
+    "noSlotFound": "No free slot found in the next few weeks"
   },
   "notifications": {
     "title": "Customer Notifications",

+ 4 - 1
messages/en/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "No slogan is set, so the sheet shows a stand-in that will not print.",
     "sloganSetHint": "Your slogan comes from your company details.",
     "sloganLink": "Company settings",
-    "placeholder": "Placeholder"
+    "placeholder": "Placeholder",
+    "leaveTitle": "Leave without saving?",
+    "leaveBody": "Your changes to this design have not been saved. They will be lost.",
+    "leaveConfirm": "Leave"
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 11 - 1
messages/es/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Lo convierte en técnico",
     "addSomeoneNew": "Añadir a alguien nuevo",
     "notOnBoard": "Aún no está en el tablero",
-    "putOnBoard": "Poner en el tablero"
+    "putOnBoard": "Poner en el tablero",
+    "slotTaken": "{count, plural, one {Esta franja ya está reservada} other {Esta franja tiene # conflictos}}",
+    "anotherJob": "otro trabajo",
+    "clashTechnician": "el técnico está ocupado",
+    "clashBay": "la bahía está ocupada",
+    "clashBoth": "ambos están ocupados",
+    "bookedAnyway": "Guardado igualmente. Cambia la hora o elige la siguiente franja libre.",
+    "pickNextAvailable": "Elegir la siguiente franja libre",
+    "findingSlot": "Buscando una franja…",
+    "slotPicked": "Reservado en la siguiente franja libre",
+    "noSlotFound": "No se encontró ninguna franja libre en las próximas semanas"
   },
   "notifications": {
     "title": "Notificaciones al cliente",

+ 4 - 1
messages/es/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "No hay ningún eslogan definido, así que la hoja muestra un texto de ejemplo que no se imprimirá.",
     "sloganSetHint": "Tu eslogan proviene de los datos de tu empresa.",
     "sloganLink": "Ajustes de empresa",
-    "placeholder": "Marcador"
+    "placeholder": "Marcador",
+    "leaveTitle": "¿Salir sin guardar?",
+    "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",
+    "leaveConfirm": "Salir"
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 11 - 1
messages/fr/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "En fait un technicien",
     "addSomeoneNew": "Ajouter quelqu’un",
     "notOnBoard": "Pas encore sur le planning",
-    "putOnBoard": "Mettre au planning"
+    "putOnBoard": "Mettre au planning",
+    "slotTaken": "{count, plural, one {Ce créneau est déjà réservé} other {Ce créneau a # conflits}}",
+    "anotherJob": "un autre travail",
+    "clashTechnician": "le technicien est occupé",
+    "clashBay": "la baie est occupée",
+    "clashBoth": "les deux sont occupés",
+    "bookedAnyway": "Enregistré quand même. Changez l'heure ou choisissez le prochain créneau libre.",
+    "pickNextAvailable": "Choisir le prochain créneau libre",
+    "findingSlot": "Recherche d'un créneau…",
+    "slotPicked": "Placé sur le prochain créneau libre",
+    "noSlotFound": "Aucun créneau libre trouvé dans les prochaines semaines"
   },
   "notifications": {
     "title": "Notifications client",

+ 4 - 1
messages/fr/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Aucun slogan n'est défini, la feuille affiche donc un texte d'exemple qui ne sera pas imprimé.",
     "sloganSetHint": "Votre slogan provient des informations de votre entreprise.",
     "sloganLink": "Paramètres de l'entreprise",
-    "placeholder": "Exemple"
+    "placeholder": "Exemple",
+    "leaveTitle": "Quitter sans enregistrer ?",
+    "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",
+    "leaveConfirm": "Quitter"
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 11 - 1
messages/it/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Lo rende un tecnico",
     "addSomeoneNew": "Aggiungi qualcuno",
     "notOnBoard": "Non ancora sulla lavagna",
-    "putOnBoard": "Metti sulla lavagna"
+    "putOnBoard": "Metti sulla lavagna",
+    "slotTaken": "{count, plural, one {Questa fascia è già occupata} other {Questa fascia ha # conflitti}}",
+    "anotherJob": "un altro lavoro",
+    "clashTechnician": "il tecnico è occupato",
+    "clashBay": "la postazione è occupata",
+    "clashBoth": "entrambi sono occupati",
+    "bookedAnyway": "Salvato comunque. Cambia l'orario o scegli la prossima fascia libera.",
+    "pickNextAvailable": "Scegli la prossima fascia libera",
+    "findingSlot": "Ricerca di una fascia…",
+    "slotPicked": "Spostato sulla prossima fascia libera",
+    "noSlotFound": "Nessuna fascia libera trovata nelle prossime settimane"
   },
   "notifications": {
     "title": "Notifiche al cliente",

+ 4 - 1
messages/it/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Nessuno slogan impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
     "sloganSetHint": "Lo slogan proviene dai dati della tua azienda.",
     "sloganLink": "Impostazioni azienda",
-    "placeholder": "Segnaposto"
+    "placeholder": "Segnaposto",
+    "leaveTitle": "Uscire senza salvare?",
+    "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",
+    "leaveConfirm": "Esci"
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 11 - 1
messages/lt/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Padarys jį techniku",
     "addSomeoneNew": "Pridėti naują žmogų",
     "notOnBoard": "Dar nėra lentoje",
-    "putOnBoard": "Įtraukti į lentą"
+    "putOnBoard": "Įtraukti į lentą",
+    "slotTaken": "{count, plural, one {Šis laikas jau užimtas} other {Šiam laikui yra # sutapimų}}",
+    "anotherJob": "kitas darbas",
+    "clashTechnician": "technikas užimtas",
+    "clashBay": "postas užimtas",
+    "clashBoth": "abu užimti",
+    "bookedAnyway": "Vis tiek išsaugota. Pakeiskite laiką arba pasirinkite artimiausią laisvą.",
+    "pickNextAvailable": "Rinktis artimiausią laisvą laiką",
+    "findingSlot": "Ieškoma laiko…",
+    "slotPicked": "Priskirta artimiausiam laisvam laikui",
+    "noSlotFound": "Artimiausiomis savaitėmis laisvo laiko nerasta"
   },
   "notifications": {
     "title": "Kliento pranešimai",

+ 4 - 1
messages/lt/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Šūkis nenustatytas, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
     "sloganSetHint": "Jūsų šūkis imamas iš įmonės duomenų.",
     "sloganLink": "Įmonės nustatymai",
-    "placeholder": "Vietaženklis"
+    "placeholder": "Vietaženklis",
+    "leaveTitle": "Išeiti neišsaugojus?",
+    "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",
+    "leaveConfirm": "Išeiti"
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 11 - 1
messages/nb/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Gjør dem til tekniker",
     "addSomeoneNew": "Legg til noen ny",
     "notOnBoard": "Ikke på tavlen ennå",
-    "putOnBoard": "Sett på tavlen"
+    "putOnBoard": "Sett på tavlen",
+    "slotTaken": "{count, plural, one {Denne tiden er allerede booket} other {Denne tiden har # kollisjoner}}",
+    "anotherJob": "en annen jobb",
+    "clashTechnician": "teknikeren er opptatt",
+    "clashBay": "plassen er i bruk",
+    "clashBoth": "begge er opptatt",
+    "bookedAnyway": "Lagret likevel. Endre tiden, eller velg neste ledige tid.",
+    "pickNextAvailable": "Velg neste ledige tid",
+    "findingSlot": "Finner en tid…",
+    "slotPicked": "Booket inn på neste ledige tid",
+    "noSlotFound": "Fant ingen ledig tid de neste ukene"
   },
   "notifications": {
     "title": "Kundevarsler",

+ 4 - 1
messages/nb/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Det er ikke satt noe slagord, så arket viser en plassholder som ikke skrives ut.",
     "sloganSetHint": "Slagordet hentes fra firmaopplysningene dine.",
     "sloganLink": "Firmainnstillinger",
-    "placeholder": "Plassholder"
+    "placeholder": "Plassholder",
+    "leaveTitle": "Forlate uten å lagre?",
+    "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",
+    "leaveConfirm": "Forlat"
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 11 - 1
messages/nl/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Maakt hen monteur",
     "addSomeoneNew": "Iemand nieuw toevoegen",
     "notOnBoard": "Nog niet op het bord",
-    "putOnBoard": "Op het bord zetten"
+    "putOnBoard": "Op het bord zetten",
+    "slotTaken": "{count, plural, one {Dit tijdslot is al bezet} other {Dit tijdslot heeft # conflicten}}",
+    "anotherJob": "een andere klus",
+    "clashTechnician": "de monteur is bezet",
+    "clashBay": "de brug is bezet",
+    "clashBoth": "beide zijn bezet",
+    "bookedAnyway": "Toch opgeslagen. Wijzig de tijd of kies het eerstvolgende vrije slot.",
+    "pickNextAvailable": "Kies eerstvolgende vrije slot",
+    "findingSlot": "Slot zoeken…",
+    "slotPicked": "Ingepland op het eerstvolgende vrije slot",
+    "noSlotFound": "Geen vrij slot gevonden in de komende weken"
   },
   "notifications": {
     "title": "Klantmeldingen",

+ 4 - 1
messages/nl/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Er is geen slogan ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
     "sloganSetHint": "Je slogan komt uit je bedrijfsgegevens.",
     "sloganLink": "Bedrijfsinstellingen",
-    "placeholder": "Voorbeeld"
+    "placeholder": "Voorbeeld",
+    "leaveTitle": "Weggaan zonder opslaan?",
+    "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",
+    "leaveConfirm": "Weggaan"
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 11 - 1
messages/pl/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Uczyni tę osobę technikiem",
     "addSomeoneNew": "Dodaj nową osobę",
     "notOnBoard": "Jeszcze nie na tablicy",
-    "putOnBoard": "Dodaj do tablicy"
+    "putOnBoard": "Dodaj do tablicy",
+    "slotTaken": "{count, plural, one {Ten termin jest już zajęty} other {Ten termin ma # kolizji}}",
+    "anotherJob": "inne zlecenie",
+    "clashTechnician": "technik jest zajęty",
+    "clashBay": "stanowisko jest zajęte",
+    "clashBoth": "oba są zajęte",
+    "bookedAnyway": "Zapisano mimo to. Zmień godzinę lub wybierz najbliższy wolny termin.",
+    "pickNextAvailable": "Wybierz najbliższy wolny termin",
+    "findingSlot": "Szukanie terminu…",
+    "slotPicked": "Zaplanowano na najbliższy wolny termin",
+    "noSlotFound": "Nie znaleziono wolnego terminu w najbliższych tygodniach"
   },
   "notifications": {
     "title": "Powiadomienia klienta",

+ 4 - 1
messages/pl/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Nie ustawiono sloganu, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
     "sloganSetHint": "Twój slogan pochodzi z danych firmy.",
     "sloganLink": "Ustawienia firmy",
-    "placeholder": "Symbol zastępczy"
+    "placeholder": "Symbol zastępczy",
+    "leaveTitle": "Wyjść bez zapisywania?",
+    "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",
+    "leaveConfirm": "Wyjdź"
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 11 - 1
messages/pt-BR/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Torna essa pessoa técnica",
     "addSomeoneNew": "Adicionar alguém novo",
     "notOnBoard": "Ainda não está no quadro",
-    "putOnBoard": "Colocar no quadro"
+    "putOnBoard": "Colocar no quadro",
+    "slotTaken": "{count, plural, one {Este horário já está reservado} other {Este horário tem # conflitos}}",
+    "anotherJob": "outro serviço",
+    "clashTechnician": "o técnico está ocupado",
+    "clashBay": "o box está ocupado",
+    "clashBoth": "ambos estão ocupados",
+    "bookedAnyway": "Salvo mesmo assim. Altere o horário ou escolha o próximo horário livre.",
+    "pickNextAvailable": "Escolher o próximo horário livre",
+    "findingSlot": "Procurando um horário…",
+    "slotPicked": "Agendado no próximo horário livre",
+    "noSlotFound": "Nenhum horário livre encontrado nas próximas semanas"
   },
   "notifications": {
     "title": "Notificações ao cliente",

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

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Nenhum slogan definido, então a folha mostra um texto de exemplo que não será impresso.",
     "sloganSetHint": "Seu slogan vem dos dados da sua empresa.",
     "sloganLink": "Configurações da empresa",
-    "placeholder": "Exemplo"
+    "placeholder": "Exemplo",
+    "leaveTitle": "Sair sem salvar?",
+    "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",
+    "leaveConfirm": "Sair"
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 11 - 1
messages/ru/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Сделает механиком",
     "addSomeoneNew": "Добавить человека",
     "notOnBoard": "Ещё не на доске",
-    "putOnBoard": "Добавить на доску"
+    "putOnBoard": "Добавить на доску",
+    "slotTaken": "{count, plural, one {Это время уже занято} other {На это время приходится # пересечений}}",
+    "anotherJob": "другая работа",
+    "clashTechnician": "техник занят",
+    "clashBay": "пост занят",
+    "clashBoth": "заняты оба",
+    "bookedAnyway": "Всё равно сохранено. Измените время или выберите ближайшее свободное.",
+    "pickNextAvailable": "Выбрать ближайшее свободное время",
+    "findingSlot": "Поиск времени…",
+    "slotPicked": "Назначено на ближайшее свободное время",
+    "noSlotFound": "В ближайшие недели свободного времени не найдено"
   },
   "notifications": {
     "title": "Уведомления клиента",

+ 4 - 1
messages/ru/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Слоган не задан, поэтому на листе показан образец, который не будет напечатан.",
     "sloganSetHint": "Слоган берётся из данных вашей компании.",
     "sloganLink": "Настройки компании",
-    "placeholder": "Образец"
+    "placeholder": "Образец",
+    "leaveTitle": "Выйти без сохранения?",
+    "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",
+    "leaveConfirm": "Выйти"
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 11 - 1
messages/tr/service.json

@@ -350,7 +350,17 @@
     "makeTechnician": "Onu teknisyen yapar",
     "addSomeoneNew": "Yeni birini ekle",
     "notOnBoard": "Henüz panoda değil",
-    "putOnBoard": "Panoya ekle"
+    "putOnBoard": "Panoya ekle",
+    "slotTaken": "{count, plural, one {Bu saat zaten dolu} other {Bu saatte # çakışma var}}",
+    "anotherJob": "başka bir iş",
+    "clashTechnician": "teknisyen meşgul",
+    "clashBay": "servis alanı dolu",
+    "clashBoth": "ikisi de dolu",
+    "bookedAnyway": "Yine de kaydedildi. Saati değiştirin veya sıradaki boş saati seçin.",
+    "pickNextAvailable": "Sıradaki boş saati seç",
+    "findingSlot": "Saat aranıyor…",
+    "slotPicked": "Sıradaki boş saate alındı",
+    "noSlotFound": "Önümüzdeki haftalarda boş saat bulunamadı"
   },
   "notifications": {
     "title": "Müşteri bildirimleri",

+ 4 - 1
messages/tr/settings.json

@@ -1881,7 +1881,10 @@
     "sloganPlaceholderHint": "Slogan tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
     "sloganSetHint": "Sloganınız şirket bilgilerinizden gelir.",
     "sloganLink": "Şirket ayarları",
-    "placeholder": "Örnek"
+    "placeholder": "Örnek",
+    "leaveTitle": "Kaydetmeden çıkılsın mı?",
+    "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",
+    "leaveConfirm": "Çık"
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

+ 40 - 0
src/__tests__/features/public/invoice-view.test.tsx

@@ -119,6 +119,46 @@ function renderView(props: any) {
   return render(<InvoiceView spec={spec} {...props} />)
 }
 
+/**
+ * The sheet the customer opens has to wear the same typeface as the designer
+ * promised. It is built by the same generator, so the trap is the same one:
+ * a line that mentions a font at all overrides what it inherits, and a blank
+ * one silently reset the panels to the default face.
+ */
+describe('the typeface a customer sees', () => {
+  it('carries the chosen font into the panels, not just the headings', () => {
+    const spec = buildInvoicePrintSpec({
+      data: DEFAULT_PROPS.record,
+      workshop: WORKSHOP,
+      invoiceSettings: { currencyCode: 'EUR' },
+      template: { fontFamily: 'Montserrat' },
+    })
+    expect(spec.page.fontFamily).toBe('Montserrat')
+    for (const id of ['customer', 'vehicle']) {
+      const block = spec.blocks.find((b) => b.id === id)
+      if (!block) continue
+      expect(block.text?.fontFamily, `${id} lost the sheet's font`).toBe('Montserrat')
+      const fonts = JSON.stringify(block.content).match(/"fontFamily":"[^"]*"/g) ?? []
+      expect(new Set(fonts), `${id} has a line in another face`).toEqual(
+        new Set(fonts.length ? ['"fontFamily":"Montserrat"'] : [])
+      )
+    }
+  })
+
+  it('renders that font onto the page the customer opens', () => {
+    const spec = buildInvoicePrintSpec({
+      data: DEFAULT_PROPS.record,
+      workshop: WORKSHOP,
+      invoiceSettings: { currencyCode: 'EUR' },
+      template: { fontFamily: 'Montserrat' },
+    })
+    const { container } = render(<InvoiceView spec={spec} {...DEFAULT_PROPS} />)
+    // The sheet sets the family as a real CSS stack, so a customer without
+    // the face installed still falls back sensibly rather than to nothing.
+    expect(container.innerHTML).toContain('Montserrat')
+  })
+})
+
 let mockFetch: ReturnType<typeof vi.fn>
 
 beforeAll(() => {

+ 37 - 0
src/__tests__/features/public/quote-view.test.tsx

@@ -67,6 +67,43 @@ function renderView(props: any) {
   return render(<QuoteView spec={spec} {...props} />)
 }
 
+/**
+ * A quote is drawn by the same generator as an invoice, so it inherits the
+ * same trap: a line that mentions a font at all overrides what it inherits,
+ * and a blank one reset the panels to the default face.
+ */
+describe('the typeface a customer sees', () => {
+  it('carries the chosen font into the panels of a shared quote', () => {
+    const spec = buildQuotePrintSpec({
+      data: DEFAULT_PROPS.quote,
+      workshop: WORKSHOP,
+      currencyCode: 'EUR',
+      template: { fontFamily: 'Lato' },
+    })
+    expect(spec.page.fontFamily).toBe('Lato')
+    for (const id of ['customer', 'vehicle']) {
+      const block = spec.blocks.find((b) => b.id === id)
+      if (!block) continue
+      expect(block.text?.fontFamily, `${id} lost the sheet's font`).toBe('Lato')
+      const fonts = JSON.stringify(block.content).match(/"fontFamily":"[^"]*"/g) ?? []
+      expect(new Set(fonts), `${id} has a line in another face`).toEqual(
+        new Set(fonts.length ? ['"fontFamily":"Lato"'] : [])
+      )
+    }
+  })
+
+  it('renders that font onto the page the customer opens', () => {
+    const spec = buildQuotePrintSpec({
+      data: DEFAULT_PROPS.quote,
+      workshop: WORKSHOP,
+      currencyCode: 'EUR',
+      template: { fontFamily: 'Lato' },
+    })
+    const { container } = render(<QuoteView spec={spec} {...DEFAULT_PROPS} />)
+    expect(container.innerHTML).toContain('Lato')
+  })
+})
+
 let mockFetch: ReturnType<typeof vi.fn>
 
 beforeAll(() => {

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

@@ -306,6 +306,42 @@ describe('the printed invoice follows the designed layout', () => {
     expect(withFooterLogo(true)).toContain('footer.logo')
   })
 
+  it("carries the sheet's typeface into every line of a panel", () => {
+    // A node that mentions a font at all overrides what it inherits, because
+    // the PDF merges the two and an explicit undefined wins. That reset a
+    // panel's values to the default face while its heading, which never
+    // mentions one, followed the document: the boxes kept the old typeface.
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: { fontFamily: 'Montserrat' },
+    })
+    const customer = spec.blocks.find((b) => b.id === 'customer')
+    expect(customer?.text?.fontFamily).toBe('Montserrat')
+    const fonts = JSON.stringify(customer?.content).match(/"fontFamily":"[^"]*"/g) ?? []
+    expect(fonts.length).toBeGreaterThan(0)
+    expect(new Set(fonts)).toEqual(new Set(['"fontFamily":"Montserrat"']))
+  })
+
+  it('still lets one section keep a typeface of its own', () => {
+    const layout = getDefaultInvoiceLayout()
+    layout.sections = layout.sections.map((s) =>
+      s.id === 'customer' ? { ...s, style: { fontFamily: 'Courier' } } : s
+    )
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop,
+      invoiceSettings: settings,
+      template: {
+        fontFamily: 'Montserrat',
+        layoutConfig: { ...layout, version: DESIGNER_LAYOUT_VERSION },
+      },
+    })
+    expect(spec.blocks.find((b) => b.id === 'customer')?.text?.fontFamily).toBe('Courier')
+    expect(spec.blocks.find((b) => b.id === 'vehicle')?.text?.fontFamily).toBe('Montserrat')
+  })
+
   it('borrows the title block once a designer layout is saved', () => {
     // The default designer layout hides the title section; the sheet borrows
     // it and sets it under the header.

+ 243 - 0
src/__tests__/features/workboard/availability.test.ts

@@ -0,0 +1,243 @@
+/**
+ * Whether the shop can actually take a job at a given moment.
+ *
+ * The rule that carries this is what counts as a clash. A technician and a
+ * bay are separate resources, set independently on the board, so two jobs at
+ * the same hour are only a problem when they want the same person or the same
+ * ramp. Getting that wrong in the generous direction double-books a lift; in
+ * the strict direction it refuses bookings a shop can genuinely take.
+ */
+
+import { describe, expect, it } from 'vitest'
+import {
+  findConflicts,
+  nextAvailableSlot,
+  overlaps,
+  withinWorkingHours,
+  type Booking,
+} from '@/features/workboard/Lib/availability'
+
+const at = (day: number, hh: number, mm = 0) => new Date(2026, 8, day, hh, mm, 0, 0)
+
+const HOURS = { start: '07:00', end: '15:00' }
+
+const booking = (over: Partial<Booking> & { start: Date; end: Date }): Booking => ({
+  id: 'b1',
+  kind: 'serviceRecord',
+  label: 'Volvo V60',
+  technicianId: null,
+  workBayId: null,
+  ...over,
+})
+
+// 1 Sep 2026 is a Tuesday; the 5th and 6th are the weekend.
+describe('overlapping in time', () => {
+  it('counts an overlap when each starts before the other ends', () => {
+    expect(overlaps(at(1, 9), at(1, 11), at(1, 10), at(1, 12))).toBe(true)
+  })
+
+  it('lets one start exactly where the other ends', () => {
+    // Back to back is the normal way a bay is used, not a clash.
+    expect(overlaps(at(1, 9), at(1, 11), at(1, 11), at(1, 13))).toBe(false)
+  })
+})
+
+describe('what counts as a clash', () => {
+  const existing = [
+    booking({ id: 'job-1', start: at(1, 9), end: at(1, 11), technicianId: 't1', workBayId: 'bay1' }),
+  ]
+
+  it('clashes when the same technician is already busy', () => {
+    const c = findConflicts({ start: at(1, 10), end: at(1, 12), technicianId: 't1' }, existing)
+    expect(c.map((b) => b.id)).toEqual(['job-1'])
+  })
+
+  it('clashes when the same bay is already occupied', () => {
+    const c = findConflicts({ start: at(1, 10), end: at(1, 12), workBayId: 'bay1' }, existing)
+    expect(c.map((b) => b.id)).toEqual(['job-1'])
+  })
+
+  it('allows another person in another bay at the same hour', () => {
+    // Two jobs at once is the whole point of having two ramps.
+    expect(
+      findConflicts({ start: at(1, 10), end: at(1, 12), technicianId: 't2', workBayId: 'bay2' }, existing)
+    ).toEqual([])
+  })
+
+  it('ignores a job that holds neither a person nor a bay', () => {
+    // Unassigned work is on the list, not in the shop, so nothing collides.
+    const loose = [booking({ id: 'job-2', start: at(1, 9), end: at(1, 11) })]
+    expect(
+      findConflicts({ start: at(1, 10), end: at(1, 12), technicianId: 't1', workBayId: 'bay1' }, loose)
+    ).toEqual([])
+  })
+
+  it('never reports the job being edited against itself', () => {
+    // Reopening a booking and pressing save must not clash with itself.
+    const c = findConflicts(
+      { start: at(1, 9), end: at(1, 11), technicianId: 't1' },
+      existing,
+      'job-1'
+    )
+    expect(c).toEqual([])
+  })
+
+  it('catches an inspection holding the bay, not only a job', () => {
+    const inspections = [
+      booking({ id: 'insp-1', kind: 'inspection', start: at(1, 9), end: at(1, 10), workBayId: 'bay1' }),
+    ]
+    const c = findConflicts({ start: at(1, 9, 30), end: at(1, 11), workBayId: 'bay1' }, inspections)
+    expect(c.map((b) => b.kind)).toEqual(['inspection'])
+  })
+})
+
+describe('staying inside the working day', () => {
+  it('accepts a slot within opening hours', () => {
+    expect(withinWorkingHours({ start: at(1, 9), end: at(1, 11) }, HOURS)).toBe(true)
+  })
+
+  it('rejects a slot running past closing time', () => {
+    expect(withinWorkingHours({ start: at(1, 14), end: at(1, 16) }, HOURS)).toBe(false)
+  })
+
+  it('rejects a weekend unless the shop works them', () => {
+    expect(withinWorkingHours({ start: at(5, 9), end: at(5, 11) }, HOURS)).toBe(false)
+    expect(
+      withinWorkingHours({ start: at(5, 9), end: at(5, 11) }, { ...HOURS, includeWeekends: true })
+    ).toBe(true)
+  })
+})
+
+describe('finding the next free slot', () => {
+  it('offers the opening time when nothing is booked', () => {
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 60,
+      bookings: [],
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(1, 7))
+    expect(slot?.end).toEqual(at(1, 8))
+  })
+
+  it('starts from now rather than this morning', () => {
+    // Searching at half past two must not offer nine o'clock.
+    const slot = nextAvailableSlot({
+      from: at(1, 9, 30),
+      durationMinutes: 60,
+      bookings: [],
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(1, 9, 30))
+  })
+
+  it('lands on the moment the bay frees up', () => {
+    const bookings = [booking({ start: at(1, 7), end: at(1, 9, 30), workBayId: 'bay1' })]
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 60,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(1, 9, 30))
+  })
+
+  it('steps over a run of back-to-back bookings in one go', () => {
+    const bookings = [
+      booking({ id: 'a', start: at(1, 7), end: at(1, 9), workBayId: 'bay1' }),
+      booking({ id: 'b', start: at(1, 9), end: at(1, 11), workBayId: 'bay1' }),
+      booking({ id: 'c', start: at(1, 11), end: at(1, 12), workBayId: 'bay1' }),
+    ]
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 60,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(1, 12))
+  })
+
+  it('rolls to the next day when today cannot fit the job', () => {
+    const bookings = [booking({ start: at(1, 7), end: at(1, 15), workBayId: 'bay1' })]
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 120,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(2, 7))
+  })
+
+  it('never offers a slot that would run past closing time', () => {
+    const bookings = [booking({ start: at(1, 7), end: at(1, 14), workBayId: 'bay1' })]
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 120,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    // 14:00 leaves only an hour, so the two-hour job goes to the next morning.
+    expect(slot?.start).toEqual(at(2, 7))
+  })
+
+  it('skips the weekend for a shop that does not work it', () => {
+    // The 4th is a Friday, fully booked; the next working day is Monday the 7th.
+    const bookings = [booking({ start: at(4, 7), end: at(4, 15), workBayId: 'bay1' })]
+    const slot = nextAvailableSlot({
+      from: at(4, 6),
+      durationMinutes: 60,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(7, 7))
+  })
+
+  it('uses the weekend when the shop works it', () => {
+    const bookings = [booking({ start: at(4, 7), end: at(4, 15), workBayId: 'bay1' })]
+    const slot = nextAvailableSlot({
+      from: at(4, 6),
+      durationMinutes: 60,
+      bookings,
+      hours: { ...HOURS, includeWeekends: true },
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(5, 7))
+  })
+
+  it('ignores work that holds a different resource', () => {
+    const bookings = [booking({ start: at(1, 7), end: at(1, 12), workBayId: 'bay2' })]
+    const slot = nextAvailableSlot({
+      from: at(1, 6),
+      durationMinutes: 60,
+      bookings,
+      hours: HOURS,
+      workBayId: 'bay1',
+    })
+    expect(slot?.start).toEqual(at(1, 7))
+  })
+
+  it('gives up rather than offering a slot months away', () => {
+    // A fortnight of solid booking should say "nothing free", not hand back
+    // a date nobody would accept.
+    const bookings = Array.from({ length: 40 }, (_, i) =>
+      booking({ id: `b${i}`, start: at(1 + i, 7), end: at(1 + i, 15), workBayId: 'bay1' })
+    )
+    expect(
+      nextAvailableSlot({
+        from: at(1, 6),
+        durationMinutes: 60,
+        bookings,
+        hours: HOURS,
+        workBayId: 'bay1',
+        searchDays: 3,
+      })
+    ).toBeNull()
+  })
+})

+ 72 - 3
src/__tests__/features/workorders/create-workorder.test.ts

@@ -39,7 +39,10 @@ vi.mock('@/lib/db', () => ({
     vehicle: { findFirst: vi.fn(), update: vi.fn() },
     appSetting: { findMany: vi.fn(), updateMany: vi.fn() },
     organization: { findUnique: vi.fn() },
-    serviceRecord: { findFirst: vi.fn(), create: vi.fn() },
+    // findMany: a draft with no stated time is placed in the first free slot,
+    // which means reading what is already booked.
+    serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
+    inspection: { findMany: vi.fn() },
     technician: { findFirst: vi.fn() },
     $transaction: vi.fn(),
   },
@@ -81,6 +84,10 @@ function setupCommonMocks() {
 
 beforeEach(() => {
   vi.resetAllMocks()
+  // An empty shop unless a test says otherwise, so slot-finding has something
+  // to read and every other test keeps asserting what it always did.
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
+  vi.mocked(db.inspection.findMany).mockResolvedValue([])
 })
 
 // ---------------------------------------------------------------------------
@@ -651,7 +658,16 @@ describe('createDraftServiceRecord — scheduling', () => {
     )
   })
 
-  it('uses workDayStart setting when no startDateTime provided', async () => {
+  /**
+   * A draft with no stated time used to land on today at opening time, which
+   * is usually an hour that has already gone, and every job created that day
+   * got the same one. It now takes the first slot the shop can actually take
+   * it, which is opening time only when opening time is still ahead.
+   */
+  it("books a new job into the shop's opening hour when the day has not started", async () => {
+    vi.useFakeTimers()
+    // A Tuesday, before the shop opens at 08:30.
+    vi.setSystemTime(new Date(2026, 8, 1, 6, 0, 0))
     setupAuth()
     vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
     vi.mocked(db.appSetting.findMany).mockResolvedValue([
@@ -659,7 +675,6 @@ describe('createDraftServiceRecord — scheduling', () => {
     ])
     vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
-
     vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-daystart' } as any)
 
     await createDraftServiceRecord(VEHICLE_ID)
@@ -668,6 +683,60 @@ describe('createDraftServiceRecord — scheduling', () => {
     const startDT: Date = createCall.data.startDateTime
     expect(startDT.getHours()).toBe(8)
     expect(startDT.getMinutes()).toBe(30)
+    vi.useRealTimers()
+  })
+
+  it('does not offer an hour that has already passed', async () => {
+    vi.useFakeTimers()
+    // Same Tuesday, but the shop has been open for hours.
+    vi.setSystemTime(new Date(2026, 8, 1, 11, 0, 0))
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: 'workboard.workDayStart', value: '08:30' } as any,
+    ])
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-now' } as any)
+
+    await createDraftServiceRecord(VEHICLE_ID)
+
+    const createCall = vi.mocked(db.serviceRecord.create).mock.calls[0][0] as any
+    const startDT: Date = createCall.data.startDateTime
+    expect(startDT.getHours()).toBe(11)
+    vi.useRealTimers()
+  })
+
+  it('steps past a booking the technician already has', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(new Date(2026, 8, 1, 6, 0, 0))
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: 'workboard.workDayStart', value: '08:30' } as any,
+    ])
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        id: 'busy',
+        title: 'Existing',
+        startDateTime: new Date(2026, 8, 1, 8, 30),
+        endDateTime: new Date(2026, 8, 1, 10, 0),
+        technicianId: TECH_ID,
+        workBayId: null,
+        vehicle: null,
+      } as any,
+    ])
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-after' } as any)
+
+    await createDraftServiceRecord(VEHICLE_ID, undefined, undefined, TECH_ID)
+
+    const createCall = vi.mocked(db.serviceRecord.create).mock.calls[0][0] as any
+    const startDT: Date = createCall.data.startDateTime
+    expect(startDT.getHours()).toBe(10)
+    expect(startDT.getMinutes()).toBe(0)
+    vi.useRealTimers()
   })
 })
 

+ 3 - 1
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -142,6 +142,8 @@ function TemplateTab({ documentType }: { documentType: 'invoice' | 'quote' }) {
           <Link
             key={preset.id}
             href={`/invoice-designer?doc=${documentType}&preset=${preset.id}`}
+            target="_blank"
+            rel="noopener"
             className="rounded-lg border p-3 text-left transition-colors hover:bg-muted"
           >
             <div className="overflow-hidden rounded border bg-background">
@@ -165,7 +167,7 @@ function TemplateTab({ documentType }: { documentType: 'invoice' | 'quote' }) {
       <div className="flex items-center justify-between gap-4 border-t pt-4">
         <p className="text-xs text-muted-foreground">{t('templates.designerHint')}</p>
         <Button asChild variant="outline">
-          <Link href={`/invoice-designer?doc=${documentType}`}>
+          <Link href={`/invoice-designer?doc=${documentType}`} target="_blank" rel="noopener">
             {t('templates.openDesigner')} →
           </Link>
         </Button>

+ 35 - 0
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -6,6 +6,7 @@ import { useMessages, useTranslations } from 'next-intl'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { useConfirm } from '@/components/confirm-dialog'
+import { DocsLink } from '@/components/docs-link'
 import { Button } from '@/components/ui/button'
 import {
   Dialog,
@@ -595,6 +596,25 @@ export function InvoiceDesigner({
     [layout, setLayout]
   )
 
+  /**
+   * Leave for the settings page, asking first when there is unsaved work.
+   *
+   * A design is only on the sheet until Save writes it, and this tab was
+   * opened fresh from settings, so walking away is the one click that can
+   * throw away an afternoon with nothing to undo it.
+   */
+  const leaveForSettings = async () => {
+    if (dirty) {
+      const go = await confirm({
+        title: t('leaveTitle'),
+        description: t('leaveBody'),
+        confirmLabel: t('leaveConfirm'),
+      })
+      if (!go) return
+    }
+    router.push('/settings/templates')
+  }
+
   const save = async () => {
     setSaving(true)
     try {
@@ -838,6 +858,21 @@ export function InvoiceDesigner({
           </button>
         </div>
 
+        {/* The way back out. The designer opens in its own tab, so there is no
+            history to go back through, and the browser's own button would
+            land somewhere else entirely. */}
+        <button
+          type="button"
+          onClick={leaveForSettings}
+          className="rounded-[7px] border border-[#e3e5e9] px-3 py-1.5 text-[13px] font-medium hover:bg-[#f4f5f7]"
+        >
+          ◂ {t('backToSettings')}
+        </button>
+
+        {/* The tool is large enough that the manual is worth one click from
+            inside it, rather than being something to go and look for. */}
+        <DocsLink href="/docs/configuration/invoice-designer" variant="header" />
+
         <button
           type="button"
           onClick={() => {

+ 11 - 1
src/features/invoice-designer/Spec/buildSpec.ts

@@ -147,7 +147,17 @@ function lookOf(section: InvoiceSection, theme: DocumentTheme) {
     /** Banding behind alternate rows; unset follows the sheet's setting. */
     stripes: s?.stripes,
     fontSize: s?.fontSize,
-    fontFamily: s?.fontFamily,
+    /**
+     * Resolved against the sheet, not left blank when the section sets none.
+     *
+     * A node that carries the key at all overrides what it inherits, because
+     * the PDF walker merges the two and an explicit `undefined` wins. Handing
+     * back a bare `s?.fontFamily` therefore reset every line that mentioned a
+     * font back to the default face, which is why a panel's values kept the
+     * old typeface while its heading, which never mentions one, followed the
+     * document.
+     */
+    fontFamily: s?.fontFamily ?? theme.fontFamily,
   }
 }
 

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

@@ -22,11 +22,22 @@ import {
   CommandItem,
   CommandList,
 } from '@/components/ui/command'
-import { Check, ChevronsUpDown, Clock, Plus, Settings, UserPlus } from 'lucide-react'
+import {
+  AlertTriangle,
+  Check,
+  ChevronsUpDown,
+  Clock,
+  Plus,
+  Settings,
+  UserPlus,
+  Wand2,
+} from 'lucide-react'
 import { toast } from 'sonner'
 import { DateTimePicker } from '@/components/ui/datetime-picker'
 import {
   assignTechnician,
+  checkSlotAvailability,
+  findNextSlot,
   scheduleJob,
   updateServiceTimes,
 } from '@/features/workboard/Actions/boardActions'
@@ -93,6 +104,28 @@ export function ScheduleTimesSection({
   useEffect(() => {
     setMounted(true)
   }, [])
+
+  // Opening a job that is already double-booked should say so, not wait for
+  // somebody to change something first.
+  useEffect(() => {
+    if (!initialStartDateTime || !initialEndDateTime) return
+    if (!initialTechnicianId && !initialWorkBayId) return
+    void checkSlotAvailability({
+      start: new Date(initialStartDateTime).toISOString(),
+      end: new Date(initialEndDateTime).toISOString(),
+      technicianId: initialTechnicianId || null,
+      workBayId: initialWorkBayId || null,
+      excludeId: serviceRecordId,
+    }).then((res) => {
+      if (res.success && res.data) setConflicts(res.data.conflicts)
+    })
+  }, [
+    initialStartDateTime,
+    initialEndDateTime,
+    initialTechnicianId,
+    initialWorkBayId,
+    serviceRecordId,
+  ])
   const [newTechName, setNewTechName] = useState('')
 
   const [startDateTime, setStartDateTime] = useState<Date | undefined>(
@@ -102,7 +135,75 @@ export function ScheduleTimesSection({
     initialEndDateTime ? new Date(initialEndDateTime) : new Date(Date.now() + 3600000)
   )
 
-  const saveTimes = async (start: Date, end: Date) => {
+  /** What the current selection would double-book, if anything. */
+  const [conflicts, setConflicts] = useState<
+    {
+      id: string
+      label: string
+      start: string
+      end: string
+      onTechnician: boolean
+      onBay: boolean
+    }[]
+  >([])
+  const [checking, setChecking] = useState(false)
+  const [finding, setFinding] = useState(false)
+
+  const bayId = selectedBayId === NO_BAY ? null : selectedBayId
+
+  /**
+   * Ask whether a slot is free, for the resources currently chosen.
+   *
+   * Advisory rather than blocking: a shop sometimes double-books a person on
+   * purpose, and refusing the booking outright would make the board lie about
+   * what the day actually looks like. Saying so plainly is the useful part.
+   */
+  const checkSlot = async (start: Date, end: Date, techId: string, bay: string | null) => {
+    if ((!techId && !bay) || end <= start) {
+      setConflicts([])
+      return
+    }
+    setChecking(true)
+    const res = await checkSlotAvailability({
+      start: start.toISOString(),
+      end: end.toISOString(),
+      technicianId: techId || null,
+      workBayId: bay,
+      excludeId: serviceRecordId,
+    })
+    setChecking(false)
+    setConflicts(res.success && res.data ? res.data.conflicts : [])
+  }
+
+  /** Move the booking to the first slot that fits, inside working hours. */
+  const pickNextAvailable = async () => {
+    const minutes =
+      startDateTime && endDateTime
+        ? Math.max(15, Math.round((endDateTime.getTime() - startDateTime.getTime()) / 60000))
+        : 60
+    setFinding(true)
+    const res = await findNextSlot({
+      durationMinutes: minutes,
+      technicianId: selectedTechId || null,
+      workBayId: bayId,
+      excludeId: serviceRecordId,
+      from: new Date().toISOString(),
+    })
+    setFinding(false)
+    if (!res.success || !res.data?.start || !res.data?.end) {
+      toast.error(t('noSlotFound'))
+      return
+    }
+    const start = new Date(res.data.start)
+    const end = new Date(res.data.end)
+    setStartDateTime(start)
+    setEndDateTime(end)
+    setConflicts([])
+    await saveTimes(start, end, { skipCheck: true })
+    toast.success(t('slotPicked'))
+  }
+
+  const saveTimes = async (start: Date, end: Date, opts?: { skipCheck?: boolean }) => {
     if (end <= start) {
       toast.error(t('endBeforeStart'))
       return
@@ -114,6 +215,9 @@ export function ScheduleTimesSection({
     })
     if (res.success) {
       onSaved?.()
+      // Checked after the write rather than before it: the times save as they
+      // always did, and the clash is reported against what is now booked.
+      if (!opts?.skipCheck) void checkSlot(start, end, selectedTechId, bayId)
     } else {
       toast.error(res.error || t('failedUpdate'))
     }
@@ -136,6 +240,7 @@ export function ScheduleTimesSection({
     })
     if (res.success) {
       onSaved?.()
+      if (startDateTime && endDateTime) void checkSlot(startDateTime, endDateTime, techId, bayId)
     } else {
       toast.error(t('failedAssign'))
       setSelectedTechId(initialTechnicianId || '')
@@ -188,6 +293,9 @@ export function ScheduleTimesSection({
     })
     if (res.success) {
       onSaved?.()
+      const nextBay = bayId === NO_BAY ? null : bayId
+      if (startDateTime && endDateTime)
+        void checkSlot(startDateTime, endDateTime, selectedTechId, nextBay)
     } else {
       toast.error(res.error || t('failedUpdate'))
       setSelectedBayId(previous)
@@ -414,6 +522,61 @@ export function ScheduleTimesSection({
         )}
       </div>
 
+      {conflicts.length > 0 && (
+        <div className="rounded-md border border-amber-300 bg-amber-50 p-2.5 dark:border-amber-900/60 dark:bg-amber-950/40">
+          <div className="flex items-start gap-2">
+            <AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-500" />
+            <div className="min-w-0 flex-1 space-y-1">
+              <p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
+                {t('slotTaken', { count: conflicts.length })}
+              </p>
+              <ul className="space-y-0.5">
+                {conflicts.slice(0, 3).map((c) => (
+                  <li
+                    key={c.id}
+                    className="text-[11px] leading-snug text-amber-800 dark:text-amber-300"
+                  >
+                    {c.label || t('anotherJob')} ·{' '}
+                    {new Date(c.start).toLocaleString(undefined, {
+                      day: 'numeric',
+                      month: 'short',
+                      hour: '2-digit',
+                      minute: '2-digit',
+                    })}
+                    {' – '}
+                    {new Date(c.end).toLocaleTimeString(undefined, {
+                      hour: '2-digit',
+                      minute: '2-digit',
+                    })}
+                    {' · '}
+                    {c.onTechnician && c.onBay
+                      ? t('clashBoth')
+                      : c.onTechnician
+                        ? t('clashTechnician')
+                        : t('clashBay')}
+                  </li>
+                ))}
+              </ul>
+              {/* Advisory, not a block: a shop that means to double-book still
+                  can, and the board keeps telling the truth about the day. */}
+              <p className="text-[11px] text-amber-700 dark:text-amber-400">{t('bookedAnyway')}</p>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <Button
+        type="button"
+        variant={conflicts.length > 0 ? 'default' : 'outline'}
+        size="sm"
+        className="w-full"
+        disabled={finding || checking}
+        onClick={pickNextAvailable}
+      >
+        <Wand2 className="mr-1.5 h-3.5 w-3.5" />
+        {finding ? t('findingSlot') : t('pickNextAvailable')}
+      </Button>
+
       <div className="space-y-1">
         <span className="text-xs font-medium text-muted-foreground">Duration presets</span>
         <div className="flex flex-wrap gap-1">

+ 43 - 7
src/features/vehicles/Lib/createDraftRecord.ts

@@ -1,10 +1,15 @@
 import { db } from '@/lib/db'
+import { nextAvailableSlot } from '@/features/workboard/Lib/availability'
+import { loadBookingContext } from '@/features/workboard/Lib/bookings'
 
 /**
  * Shared draft-record creation for both work orders (with a vehicle) and
  * counter sales (no vehicle, direct customer link). Resolves invoice number,
  * default technician, tax settings and schedule times identically.
  */
+/** How long a job is assumed to take until somebody says otherwise. */
+const DEFAULT_JOB_MINUTES = 60
+
 export async function createDraftRecord(
   { organizationId, userId }: { organizationId: string; userId: string },
   opts: {
@@ -124,14 +129,45 @@ export async function createDraftRecord(
     taxEnabled && !opts.customerExempt ? Number(settingsMap['workshop.defaultTaxRate']) || 0 : 0
   const taxInclusive = settingsMap['workshop.taxInclusive'] === 'true'
 
-  // Default to today's date at work day start time (from settings, fallback 07:00)
+  /**
+   * When a job with no stated time gets booked in.
+   *
+   * The first slot the shop can actually take it, rather than this morning at
+   * opening time: that hour is usually already gone by the time anybody books
+   * anything, and it was handed out again to every job created that day, so
+   * a busy Tuesday quietly stacked its whole intake on one moment. Respects
+   * whoever or whatever the job is assigned to, so booking against a
+   * technician does not land on top of their morning.
+   */
   let defaultStart: Date
-  if (!opts.startDateTime) {
-    const workDayStart = settingsMap['workboard.workDayStart'] || '07:00'
-    const [h, m] = workDayStart.split(':').map(Number)
-    defaultStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0)
+  let defaultEnd = opts.endDateTime
+  // 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
+  if (opts.startDateTime || !isShopWork) {
+    const [h, m] = (settingsMap['workboard.workDayStart'] || '07:00').split(':').map(Number)
+    defaultStart =
+      opts.startDateTime ?? new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0)
   } else {
-    defaultStart = opts.startDateTime
+    const { bookings, hours } = await loadBookingContext(organizationId, now)
+    const slot = nextAvailableSlot({
+      from: now,
+      durationMinutes: DEFAULT_JOB_MINUTES,
+      bookings,
+      hours,
+      technicianId: opts.technicianId,
+      workBayId: opts.workBayId,
+    })
+    if (slot) {
+      defaultStart = slot.start
+      if (!defaultEnd) defaultEnd = slot.end
+    } else {
+      // Booked solid for weeks. Falling back to opening time keeps the job on
+      // the board rather than refusing to create it over a scheduling detail.
+      const [h, m] = (hours.start || '07:00').split(':').map(Number)
+      defaultStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0)
+    }
   }
   // serviceDate should be date-only (start of day)
   const serviceDate = new Date(
@@ -158,7 +194,7 @@ export async function createDraftRecord(
       serviceDate,
       invoiceDate: serviceDate,
       startDateTime: defaultStart,
-      endDateTime: opts.endDateTime ?? new Date(defaultStart.getTime() + 3600000),
+      endDateTime: defaultEnd ?? new Date(defaultStart.getTime() + 3600000),
     },
   })
 }

+ 93 - 0
src/features/workboard/Actions/boardActions/availability.ts

@@ -0,0 +1,93 @@
+'use server'
+
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { findConflicts, nextAvailableSlot } from '../../Lib/availability'
+import { loadBookingContext } from '../../Lib/bookings'
+
+/**
+ * Is this slot free, and if not, where is the next one.
+ *
+ * The board lets a technician and a bay be set independently, so a booking
+ * can clash on either. Both questions read the same window of work, and both
+ * count inspections, which hold a bay and a person exactly as a job does.
+ */
+
+/** What a proposed booking would collide with. Empty means the slot is free. */
+export async function checkSlotAvailability(input: {
+  start: string
+  end: string
+  technicianId?: string | null
+  workBayId?: string | null
+  /** The job being edited, so it never clashes with itself. */
+  excludeId?: string
+}) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = new Date(input.start)
+      const end = new Date(input.end)
+      if (!(start < end)) return { conflicts: [] }
+      // Nothing is held, so nothing can be clashed with.
+      if (!input.technicianId && !input.workBayId) return { conflicts: [] }
+
+      const { bookings } = await loadBookingContext(organizationId, start)
+      const conflicts = findConflicts(
+        { start, end, technicianId: input.technicianId, workBayId: input.workBayId },
+        bookings,
+        input.excludeId
+      )
+      return {
+        conflicts: conflicts.map((c) => ({
+          id: c.id,
+          kind: c.kind,
+          label: c.label,
+          start: c.start.toISOString(),
+          end: c.end.toISOString(),
+          // Which resource actually clashed, so the warning can say so rather
+          // than leaving somebody to work it out from two times.
+          onTechnician: !!input.technicianId && c.technicianId === input.technicianId,
+          onBay: !!input.workBayId && c.workBayId === input.workBayId,
+        })),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_ORDERS },
+      ],
+    }
+  )
+}
+
+/** The earliest slot that fits, within the shop's own working hours. */
+export async function findNextSlot(input: {
+  durationMinutes: number
+  technicianId?: string | null
+  workBayId?: string | null
+  excludeId?: string
+  /** Where to search from. Defaults to now. */
+  from?: string
+}) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const from = input.from ? new Date(input.from) : new Date()
+      const { bookings, hours } = await loadBookingContext(organizationId, from)
+      const slot = nextAvailableSlot({
+        from,
+        durationMinutes: input.durationMinutes,
+        bookings,
+        hours,
+        technicianId: input.technicianId,
+        workBayId: input.workBayId,
+        excludeId: input.excludeId,
+      })
+      return slot
+        ? { start: slot.start.toISOString(), end: slot.end.toISOString() }
+        : { start: null, end: null }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_ORDERS },
+      ],
+    }
+  )
+}

+ 1 - 0
src/features/workboard/Actions/boardActions/index.ts

@@ -9,3 +9,4 @@ export {
 } from './assignments'
 export { scheduleJob, updateServiceTimes, updateInspectionTimes } from './scheduling'
 export { getServiceRecordTechnician } from './queries'
+export { checkSlotAvailability, findNextSlot } from './availability'

+ 157 - 0
src/features/workboard/Lib/availability.ts

@@ -0,0 +1,157 @@
+/**
+ * Whether a slot in the shop is free, and where the next free one is.
+ *
+ * A technician and a bay are each a resource that can hold one job at a time,
+ * and the board lets both be set independently, so a booking can clash on
+ * either without clashing on the other. Everything here is pure: the rules
+ * are worth testing on their own, and the caller brings the bookings.
+ *
+ * Inspections occupy a bay and a person exactly as a job does, so they arrive
+ * in the same list rather than being a second kind of thing to remember.
+ */
+
+export interface Booking {
+  id: string
+  /** What it is, so a clash can name it. */
+  kind: 'serviceRecord' | 'inspection'
+  label: string
+  start: Date
+  end: Date
+  technicianId?: string | null
+  workBayId?: string | null
+}
+
+export interface Slot {
+  start: Date
+  end: Date
+  technicianId?: string | null
+  workBayId?: string | null
+}
+
+/** The shop's bookable hours, as "HH:mm" in the shop's own reckoning. */
+export interface WorkingHours {
+  start: string
+  end: string
+  /**
+   * Whether Saturday and Sunday can be booked. Off by default: a shop that
+   * works weekends says so, and one that does not should never be handed a
+   * Sunday morning as its next free slot.
+   */
+  includeWeekends?: boolean
+}
+
+/** Two half-open intervals overlap when each starts before the other ends. */
+export function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
+  return aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime()
+}
+
+/**
+ * The bookings a slot would collide with.
+ *
+ * A booking only counts when it shares a resource. Two jobs at the same hour
+ * in different bays with different people are not a clash, and a job with
+ * neither set is not holding anything, so it cannot be clashed with.
+ */
+export function findConflicts(slot: Slot, bookings: Booking[], excludeId?: string): Booking[] {
+  return bookings.filter((booking) => {
+    if (booking.id === excludeId) return false
+    if (!overlaps(slot.start, slot.end, booking.start, booking.end)) return false
+    const sameTech = !!slot.technicianId && booking.technicianId === slot.technicianId
+    const sameBay = !!slot.workBayId && booking.workBayId === slot.workBayId
+    return sameTech || sameBay
+  })
+}
+
+function minutesOf(hhmm: string): number {
+  const [h, m] = hhmm.split(':').map(Number)
+  return (Number.isFinite(h) ? h : 0) * 60 + (Number.isFinite(m) ? m : 0)
+}
+
+const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6
+
+/** The opening moment of the working day `date` falls in. */
+function dayStart(date: Date, hours: WorkingHours): Date {
+  const d = new Date(date)
+  d.setHours(0, minutesOf(hours.start), 0, 0)
+  return d
+}
+
+function dayEnd(date: Date, hours: WorkingHours): Date {
+  const d = new Date(date)
+  d.setHours(0, minutesOf(hours.end), 0, 0)
+  return d
+}
+
+/** Whether the whole slot sits inside one bookable day. */
+export function withinWorkingHours(slot: Slot, hours: WorkingHours): boolean {
+  if (!hours.includeWeekends && isWeekend(slot.start)) return false
+  const open = dayStart(slot.start, hours)
+  const close = dayEnd(slot.start, hours)
+  return slot.start >= open && slot.end <= close
+}
+
+/**
+ * The earliest free slot of `durationMinutes`, at or after `from`.
+ *
+ * Walks the working days forward, and inside each one steps from the opening
+ * time to the end of whatever is in the way, which lands exactly on the
+ * moment a resource frees up rather than on an arbitrary grid. A shop booked
+ * to the minute for a fortnight gets null instead of a slot in the far
+ * future that nobody would want offered.
+ */
+export function nextAvailableSlot({
+  from,
+  durationMinutes,
+  bookings,
+  hours,
+  technicianId,
+  workBayId,
+  excludeId,
+  searchDays = 30,
+}: {
+  from: Date
+  durationMinutes: number
+  bookings: Booking[]
+  hours: WorkingHours
+  technicianId?: string | null
+  workBayId?: string | null
+  excludeId?: string
+  searchDays?: number
+}): Slot | null {
+  const durationMs = Math.max(1, durationMinutes) * 60_000
+  const day = new Date(from)
+  day.setHours(0, 0, 0, 0)
+
+  for (let i = 0; i < searchDays; i++) {
+    const cursorDay = new Date(day)
+    cursorDay.setDate(day.getDate() + i)
+    if (!hours.includeWeekends && isWeekend(cursorDay)) continue
+
+    const close = dayEnd(cursorDay, hours)
+    // Today starts from now rather than from opening time, so a search at
+    // half past two is not offered nine in the morning.
+    let cursor = dayStart(cursorDay, hours)
+    if (from > cursor) cursor = new Date(from)
+    // Whole minutes: a slot starting at 09:07:23 is noise on a booking sheet.
+    cursor.setSeconds(0, 0)
+    if (cursor.getTime() % 60_000 !== 0)
+      cursor = new Date(Math.ceil(cursor.getTime() / 60_000) * 60_000)
+
+    while (cursor.getTime() + durationMs <= close.getTime()) {
+      const candidate: Slot = {
+        start: new Date(cursor),
+        end: new Date(cursor.getTime() + durationMs),
+        technicianId,
+        workBayId,
+      }
+      const clashes = findConflicts(candidate, bookings, excludeId)
+      if (clashes.length === 0) return candidate
+      // Jump to the moment the last thing in the way finishes; stepping by a
+      // fixed interval would try the same occupied minutes over and over.
+      const freeAt = Math.max(...clashes.map((c) => c.end.getTime()))
+      cursor = new Date(Math.max(freeAt, cursor.getTime() + 60_000))
+    }
+  }
+
+  return null
+}

+ 99 - 0
src/features/workboard/Lib/bookings.ts

@@ -0,0 +1,99 @@
+import { db } from '@/lib/db'
+import type { Booking, WorkingHours } from './availability'
+
+/**
+ * Everything currently holding a person or a bay, and the hours the shop works.
+ *
+ * A plain module rather than part of the server action beside it: a
+ * 'use server' file exposes each export as an endpoint, and this one takes an
+ * organization id, which must never be something a caller can choose.
+ */
+
+/** How far ahead to read. Long enough to answer "next free slot" honestly. */
+const WINDOW_DAYS = 45
+
+export async function loadBookingContext(
+  organizationId: string,
+  from: Date
+): Promise<{ bookings: Booking[]; hours: WorkingHours }> {
+  const windowStart = new Date(from)
+  windowStart.setDate(windowStart.getDate() - 1)
+  const windowEnd = new Date(from)
+  windowEnd.setDate(windowEnd.getDate() + WINDOW_DAYS)
+
+  // Only work that actually holds a resource can be clashed with; anything
+  // unassigned is on the list rather than in the shop.
+  const booked = {
+    startDateTime: { not: null },
+    endDateTime: { not: null },
+    OR: [{ technicianId: { not: null } }, { workBayId: { not: null } }],
+    AND: [{ startDateTime: { lt: windowEnd } }, { endDateTime: { gt: windowStart } }],
+  }
+
+  const [records, inspections, settings] = await Promise.all([
+    db.serviceRecord.findMany({
+      where: { organizationId, ...booked },
+      select: {
+        id: true,
+        title: true,
+        startDateTime: true,
+        endDateTime: true,
+        technicianId: true,
+        workBayId: true,
+        vehicle: { select: { make: true, model: true, licensePlate: true } },
+      },
+    }),
+    db.inspection.findMany({
+      where: { organizationId, ...booked },
+      select: {
+        id: true,
+        startDateTime: true,
+        endDateTime: true,
+        technicianId: true,
+        workBayId: true,
+        vehicle: { select: { make: true, model: true, licensePlate: true } },
+      },
+    }),
+    db.appSetting.findMany({
+      where: {
+        organizationId,
+        key: { in: ['workboard.workDayStart', 'workboard.workDayEnd', 'workboard.showWeekends'] },
+      },
+      select: { key: true, value: true },
+    }),
+  ])
+
+  const describe = (v?: { make: string; model: string; licensePlate: string | null } | null) =>
+    v ? [v.make, v.model, v.licensePlate].filter(Boolean).join(' ') : ''
+
+  const bookings: Booking[] = [
+    ...records.map((r) => ({
+      id: r.id,
+      kind: 'serviceRecord' as const,
+      label: describe(r.vehicle) || r.title || '',
+      start: r.startDateTime as Date,
+      end: r.endDateTime as Date,
+      technicianId: r.technicianId,
+      workBayId: r.workBayId,
+    })),
+    ...inspections.map((i) => ({
+      id: i.id,
+      kind: 'inspection' as const,
+      label: describe(i.vehicle),
+      start: i.startDateTime as Date,
+      end: i.endDateTime as Date,
+      technicianId: i.technicianId,
+      workBayId: i.workBayId,
+    })),
+  ]
+
+  const map = Object.fromEntries(settings.map((s) => [s.key, s.value]))
+  return {
+    bookings,
+    hours: {
+      start: map['workboard.workDayStart'] || '07:00',
+      end: map['workboard.workDayEnd'] || '15:00',
+      includeWeekends: map['workboard.showWeekends'] === 'true',
+    },
+  }
+}