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

Book appointments with notifications, edit bookings, and see the week ahead on the dashboard (#579)

* Stop dialogs without a description from warning in the console

Radix warns when a dialog, sheet or drawer opens with no description.
The ones that have none on purpose now say so with
aria-describedby={undefined}.

* Show the week ahead on the dashboard in a Calendar Events card

The card lists what the calendar holds for today and the next six days,
booked appointments included, drawn and opened the way the calendar's
schedule view does it, and books an appointment without leaving the
dashboard. A saved dashboard gets it above Upcoming Reminders.

Calendar events follow the role's own read of each kind of record:
jobs to whoever opens the calendar, reminders with vehicles, quotes
with quotes, scheduled messages with customers.

* Make room for the calendar card among saved custom cards too

A custom card was put back only after the calendar card had made room,
so one on the reminders' row stayed there and the two overlapped.

* Book appointments that are not work orders, edit bookings, and notify before them

The booking dialog is rebuilt the way a calendar app's is: a title, an
Appointment or Work order switch, when, who, the work needed, a rich text
description, a video call where one is connected, and notifications in
the app, by email, or to the customer by SMS or email, an hour before by
default or days before at a time of day.

An appointment is only on the calendar: no job number, no bay. It can be
edited, deleted, or made a work order when the car comes in. A booked job
can be edited from the calendar while its car has not arrived; it keeps
its bay when moved if that is still free.

Notifications go out from a job that runs every minute and reads the
start afresh, so moving a booking here or on the work board moves them.
Appointments are in the backup. On a phone the schedule list puts the
time above the title, and the peek opens below a full-width row.

* Line up the week view's day columns above the hour grid

The hour grid scrolls and its scrollbar took width the day headers and
the all-day row did not lose, so each column's line sat a little further
left in the grid than above it. Every row now keeps that space.

* Give the booking dialog one size, a concern list and a title that leads

Switching between an appointment and a work order no longer resizes the
dialog: it has one height, and the fields only a work order has sit
under the notifications where they move nothing. Work needed is a list,
one concern per row, Enter for the next, a pasted list split into rows.
The title is large, the kind switch has icons, When is one line with the
duration beside it and a second date only for a booking into another
day, and customer and vehicle say Optional on an appointment.

* Keep overlapping jobs apart in the week view

Blocks of one colour that overlapped merged into one shape. Each block now
has an edge in the page's colour, and a job that starts later is drawn
over one already running, the way Google's calendar does it, so no title
is hidden under an earlier block. The line under the time names the
customer, since the title already names the car.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
4c2c134d33
100 измененных файлов с 6183 добавлено и 779 удалено
  1. 7 0
      messages/de/audit.json
  2. 88 8
      messages/de/calendar.json
  3. 6 0
      messages/de/dashboard.json
  4. 1 1
      messages/de/settings.json
  5. 7 0
      messages/en/audit.json
  6. 88 8
      messages/en/calendar.json
  7. 6 0
      messages/en/dashboard.json
  8. 1 1
      messages/en/settings.json
  9. 7 0
      messages/es/audit.json
  10. 88 8
      messages/es/calendar.json
  11. 6 0
      messages/es/dashboard.json
  12. 1 1
      messages/es/settings.json
  13. 7 0
      messages/fr/audit.json
  14. 88 8
      messages/fr/calendar.json
  15. 6 0
      messages/fr/dashboard.json
  16. 1 1
      messages/fr/settings.json
  17. 7 0
      messages/it/audit.json
  18. 88 8
      messages/it/calendar.json
  19. 6 0
      messages/it/dashboard.json
  20. 1 1
      messages/it/settings.json
  21. 7 0
      messages/lt/audit.json
  22. 88 8
      messages/lt/calendar.json
  23. 6 0
      messages/lt/dashboard.json
  24. 1 1
      messages/lt/settings.json
  25. 7 0
      messages/nb/audit.json
  26. 88 8
      messages/nb/calendar.json
  27. 6 0
      messages/nb/dashboard.json
  28. 1 1
      messages/nb/settings.json
  29. 7 0
      messages/nl/audit.json
  30. 88 8
      messages/nl/calendar.json
  31. 6 0
      messages/nl/dashboard.json
  32. 1 1
      messages/nl/settings.json
  33. 7 0
      messages/pl/audit.json
  34. 88 8
      messages/pl/calendar.json
  35. 6 0
      messages/pl/dashboard.json
  36. 1 1
      messages/pl/settings.json
  37. 7 0
      messages/pt-BR/audit.json
  38. 88 8
      messages/pt-BR/calendar.json
  39. 6 0
      messages/pt-BR/dashboard.json
  40. 1 1
      messages/pt-BR/settings.json
  41. 7 0
      messages/pt-PT/audit.json
  42. 88 8
      messages/pt-PT/calendar.json
  43. 6 0
      messages/pt-PT/dashboard.json
  44. 1 1
      messages/pt-PT/settings.json
  45. 7 0
      messages/ru/audit.json
  46. 88 8
      messages/ru/calendar.json
  47. 6 0
      messages/ru/dashboard.json
  48. 1 1
      messages/ru/settings.json
  49. 7 0
      messages/tr/audit.json
  50. 88 8
      messages/tr/calendar.json
  51. 6 0
      messages/tr/dashboard.json
  52. 1 1
      messages/tr/settings.json
  53. 76 0
      prisma/migrations/20261113154240_appointments/migration.sql
  54. 73 0
      prisma/schema/appointments.prisma
  55. 31 29
      prisma/schema/auth.prisma
  56. 1 0
      prisma/schema/customers.prisma
  57. 2 0
      prisma/schema/organization.prisma
  58. 12 10
      prisma/schema/service-records.prisma
  59. 1 0
      prisma/schema/vehicles.prisma
  60. 0 165
      src/__tests__/features/calendar/book-appointment-dialog.test.tsx
  61. 697 0
      src/__tests__/features/calendar/booking-actions.test.ts
  62. 562 0
      src/__tests__/features/calendar/booking-dialog.test.tsx
  63. 225 0
      src/__tests__/features/calendar/calendar-events-access.test.ts
  64. 134 0
      src/__tests__/features/calendar/calendar-events-card.test.tsx
  65. 71 0
      src/__tests__/features/calendar/concern-list-editor.test.tsx
  66. 99 0
      src/__tests__/features/calendar/notification-timing.test.ts
  67. 59 0
      src/__tests__/features/calendar/time-grid-interactions.test.tsx
  68. 114 1
      src/__tests__/features/dashboard-layout.test.ts
  69. 22 1
      src/__tests__/lib/backup/build-data.test.ts
  70. 125 0
      src/__tests__/lib/backup/import-tenancy.test.ts
  71. 319 0
      src/__tests__/lib/cron/appointment-notifications.test.ts
  72. 18 0
      src/app/(authenticated)/dashboard-client.tsx
  73. 24 0
      src/app/(authenticated)/page.tsx
  74. 1 1
      src/app/(authenticated)/settings/company/company-settings.tsx
  75. 2 2
      src/app/(authenticated)/settings/data/data-settings.tsx
  76. 1 1
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  77. 1 1
      src/app/(authenticated)/tire-hotel/storage/storage-client.tsx
  78. 1 1
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  79. 1 1
      src/components/app-sidebar.tsx
  80. 6 1
      src/components/ui/rich-text-editor.tsx
  81. 1 0
      src/cronTasks.ts
  82. 1 1
      src/features/billing/Components/RecurringInvoicesClient.tsx
  83. 1 38
      src/features/calendar/Actions/bookAppointment.ts
  84. 191 115
      src/features/calendar/Actions/calendarActions.ts
  85. 167 0
      src/features/calendar/Actions/createBooking.ts
  86. 36 0
      src/features/calendar/Actions/deleteAppointment.ts
  87. 174 0
      src/features/calendar/Actions/getBooking.ts
  88. 49 0
      src/features/calendar/Actions/getBookingOptions.ts
  89. 229 0
      src/features/calendar/Actions/updateBooking.ts
  90. 0 252
      src/features/calendar/Components/BookAppointmentDialog.tsx
  91. 829 0
      src/features/calendar/Components/BookingDialog.tsx
  92. 24 7
      src/features/calendar/Components/CalendarClient.tsx
  93. 139 0
      src/features/calendar/Components/CalendarEventsCard.tsx
  94. 2 0
      src/features/calendar/Components/CalendarSidebar.tsx
  95. 143 0
      src/features/calendar/Components/ConcernListEditor.tsx
  96. 16 2
      src/features/calendar/Components/EventChip.tsx
  97. 39 7
      src/features/calendar/Components/EventContextMenu.tsx
  98. 101 13
      src/features/calendar/Components/EventPeek.tsx
  99. 16 6
      src/features/calendar/Components/ScheduleView.tsx
  100. 21 7
      src/features/calendar/Components/TimeGridView.tsx

+ 7 - 0
messages/de/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Notiz gelöscht",
     "reminder_create": "Erinnerung erstellt",
     "reminder_delete": "Erinnerung gelöscht",
+    "appointment_create": "Termin erstellt",
+    "appointment_update": "Termin aktualisiert",
+    "appointment_delete": "Termin gelöscht",
     "reminder_import": "Erinnerungen importiert",
     "organization_create": "Organisation erstellt",
     "subscription_cancel": "Abonnement gekündigt",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Termin {ref} geändert",
+    "appointment_create": "Termin {title} hinzugefügt",
+    "appointment_update": "Termin {title} geändert",
+    "appointment_delete": "Termin {title} gelöscht",
     "service_createCounterSale": "Thekenverkauf {ref} angelegt",
     "service_createDraft": "Auftragsentwurf {ref} angelegt",
     "service_createFromInspection": "Arbeitsauftrag {ref} aus einer Prüfung angelegt",

+ 88 - 8
messages/de/calendar.json

@@ -70,8 +70,11 @@
       "message": "Nachrichten öffnen",
       "external": "Öffnen",
       "closure": "Öffnungszeiten öffnen",
-      "absence": "Auftragsboard öffnen"
-    }
+      "absence": "Auftragsboard öffnen",
+      "appointment": "Kalender öffnen"
+    },
+    "makeWorkOrder": "Arbeitsauftrag erstellen",
+    "notifications": "{count, plural, one {# Benachrichtigung} other {# Benachrichtigungen}}"
   },
   "filters": {
     "services": "Arbeitsaufträge",
@@ -80,7 +83,8 @@
     "messages": "Nachrichten",
     "external": "Verbundene Kalender",
     "closures": "Schließtage",
-    "absences": "Abwesenheiten"
+    "absences": "Abwesenheiten",
+    "appointments": "Termine"
   },
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Geplante Nachricht \"{title}\" löschen? Sie wird nicht gesendet.",
     "deleted": "Gelöscht",
     "updated": "Aktualisiert",
-    "failed": "Das hat nicht geklappt. Bitte erneut versuchen."
+    "failed": "Das hat nicht geklappt. Bitte erneut versuchen.",
+    "edit": "Bearbeiten",
+    "makeWorkOrder": "In Arbeitsauftrag umwandeln",
+    "deleteAppointment": "Termin löschen",
+    "deleteAppointmentConfirm": "Termin \"{title}\" löschen? Die zugehörigen Benachrichtigungen werden nicht gesendet."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "In Warteschlange",
       "busy": "Belegt",
       "closed": "Geschlossen",
-      "away": "Abwesend"
+      "away": "Abwesend",
+      "booked": "Gebucht"
     },
     "type": {
       "service": "Arbeitsauftrag",
@@ -140,7 +149,8 @@
       "message": "Nachricht",
       "external": "Verbundener Kalender",
       "closure": "Geschlossen",
-      "absence": "Abwesenheit"
+      "absence": "Abwesenheit",
+      "appointment": "Termin"
     },
     "noEvents": "Keine Ereignisse",
     "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Beschreibung",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Keine Fahrzeuge gefunden.",
     "noVehiclesMarine": "Keine Boote gefunden.",
     "none": "Keine",
+    "optional": "Optional",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Termin konnte nicht gebucht werden",
+    "editTitle": "Termin bearbeiten",
+    "convertTitle": "In Arbeitsauftrag umwandeln",
+    "dialogDescription": "Tragen Sie einen Termin oder einen Arbeitsauftrag in den Kalender ein, mit wem er stattfindet und welche Benachrichtigungen vorher kommen.",
+    "titleLabel": "Titel",
+    "titlePlaceholder": "Titel hinzufügen",
+    "kind": {
+      "appointment": "Termin",
+      "workOrder": "Arbeitsauftrag",
+      "appointmentHint": "Nur im Kalender. Machen Sie einen Arbeitsauftrag daraus, wenn das Fahrzeug kommt.",
+      "appointmentHintMarine": "Nur im Kalender. Machen Sie einen Arbeitsauftrag daraus, wenn das Boot kommt.",
+      "workOrderHint": "Belegt einen Arbeitsplatz oder einen Mechaniker, erhält eine Auftragsnummer und erscheint auf dem Auftragsboard."
+    },
+    "when": "Wann",
+    "start": "Beginn",
+    "end": "Ende",
+    "durationHours": "{count, plural, one {# Stunde} other {# Stunden}}",
+    "durationMinutes": "{count, plural, one {# Minute} other {# Minuten}}",
+    "endAfterStart": "Das Ende muss nach dem Beginn liegen, höchstens eine Woche später.",
+    "appointmentWhoHint": "Wählen Sie einen Kunden, um ihn vor dem Termin benachrichtigen zu können.",
+    "appointmentWhoHintMarine": "Wählen Sie einen Kunden, um ihn vor dem Termin benachrichtigen zu können.",
+    "whoLocked": "Ändern Sie Fahrzeug oder Kunde im Arbeitsauftrag.",
+    "whoLockedMarine": "Ändern Sie Boot oder Kunde im Arbeitsauftrag.",
+    "workHintTitle": "Eine Sache pro Zeile. Jede Zeile wird ein Anliegen im Auftrag. Bleibt das Feld leer, wird der Titel verwendet.",
+    "workLocked": "Auf diesen Anliegen ist bereits Arbeit erfasst. Ändern Sie sie im Arbeitsauftrag.",
+    "concernLabel": "Anliegen {number}",
+    "removeConcern": "Anliegen entfernen",
+    "addConcern": "Anliegen eingeben und Enter drücken",
+    "addConcernMarine": "Anliegen eingeben und Enter drücken",
+    "workEmptyHint": "Leer gelassen wird der Titel zum Anliegen des Auftrags.",
+    "endsAnotherDay": "Endet an einem anderen Tag",
+    "video": "Videoanruf",
+    "addMeeting": "{name}-Meeting hinzufügen",
+    "meetingAdded": "Beim Speichern wird ein {name}-Meeting hinzugefügt.",
+    "removeMeeting": "Kein Meeting hinzufügen",
+    "joinMeeting": "Videoanruf beitreten",
+    "meetingFailed": "Der Termin ist gespeichert, aber das Meeting konnte nicht hinzugefügt werden. {error}",
+    "notifications": "Benachrichtigungen",
+    "addNotification": "Benachrichtigung hinzufügen",
+    "removeNotification": "Benachrichtigung entfernen",
+    "channelLabel": "Wie",
+    "amountLabel": "Wie viele",
+    "unitLabel": "Minuten, Stunden, Tage oder Wochen vorher",
+    "at": "um",
+    "atLabel": "Uhrzeit",
+    "channel": {
+      "toWorkshop": "An die Werkstatt",
+      "toCustomer": "An den Kunden",
+      "inApp": "In der App",
+      "email": "E-Mail an mich",
+      "customerSms": "SMS an den Kunden",
+      "customerEmail": "E-Mail an den Kunden"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {Minute vorher} other {Minuten vorher}}",
+      "hours": "{count, plural, one {Stunde vorher} other {Stunden vorher}}",
+      "days": "{count, plural, one {Tag vorher} other {Tage vorher}}",
+      "weeks": "{count, plural, one {Woche vorher} other {Wochen vorher}}"
+    },
+    "customerChannelNeedsCustomer": "Wählen Sie einen Kunden, um ihn zu benachrichtigen.",
+    "descriptionPlaceholder": "Beschreibung hinzufügen",
+    "convertSubmit": "Arbeitsauftrag erstellen",
+    "saved": "Termin gespeichert"
+  },
+  "notify": {
+    "inAppTitle": "Demnächst: {title}",
+    "emailSubject": "Demnächst: {title}, {when}",
+    "emailFooter": "Sie haben diese Benachrichtigung im Werkstattkalender in Torqvoice eingerichtet.",
+    "customerSubject": "Ihr Termin bei {workshop}",
+    "customerBody": "Hallo {name}, wir möchten Sie an Ihren Termin bei {workshop} am {when} erinnern. Wir freuen uns auf Sie!"
   }
 }

+ 6 - 0
messages/de/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Karten anzeigen",
   "cards": {
     "maintenance": "Voraussichtliche Wartung",
+    "calendarEvents": "Kalendereinträge",
     "reminders": "Anstehende Erinnerungen",
     "inspections": "Inspektionen",
     "serviceRequests": "Serviceanfragen",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Ausblenden",
     "dismissError": "Fahrzeug konnte nicht ausgeblendet werden. Bitte versuchen Sie es erneut."
   },
+  "calendarEvents": {
+    "title": "Kalendereinträge",
+    "description": "Nächste {days} Tage",
+    "noData": "In den nächsten {days} Tagen steht nichts im Kalender."
+  },
   "reminders": {
     "title": "Anstehende Erinnerungen",
     "overdue": "Überfällig",

+ 1 - 1
messages/de/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Kunden",
     "optCustomersDesc": "Kundendaten und Kontaktinformationen",
     "optVehicles": "Fahrzeuge & Servicedaten",
-    "optVehiclesDesc": "Fahrzeuge, Notizen, Kraftstoffprotokolle, Erinnerungen, Servicedaten",
+    "optVehiclesDesc": "Fahrzeuge, Notizen, Kraftstoffprotokolle, Erinnerungen, Servicedaten, Termine",
     "optQuotes": "Angebote",
     "optQuotesDesc": "Angebote mit Teilen und Arbeitszeit",
     "optInventory": "Inventar",

+ 7 - 0
messages/en/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Deleted Note",
     "reminder_create": "Created Reminder",
     "reminder_delete": "Deleted Reminder",
+    "appointment_create": "Created Appointment",
+    "appointment_update": "Updated Appointment",
+    "appointment_delete": "Deleted Appointment",
     "reminder_import": "Imported Reminders",
     "organization_create": "Created Organization",
     "subscription_cancel": "Cancelled Subscription",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Changed booking {ref}",
+    "appointment_create": "Added appointment {title}",
+    "appointment_update": "Changed appointment {title}",
+    "appointment_delete": "Deleted appointment {title}",
     "service_createCounterSale": "Created counter sale {ref}",
     "service_createDraft": "Created draft service record {ref}",
     "service_createFromInspection": "Created work order {ref} from an inspection",

+ 88 - 8
messages/en/calendar.json

@@ -70,8 +70,11 @@
       "message": "Open messages",
       "external": "Open",
       "closure": "Open opening hours",
-      "absence": "Open the work board"
-    }
+      "absence": "Open the work board",
+      "appointment": "Open calendar"
+    },
+    "makeWorkOrder": "Make work order",
+    "notifications": "{count, plural, one {# notification} other {# notifications}}"
   },
   "filters": {
     "services": "Work orders",
@@ -80,7 +83,8 @@
     "messages": "Messages",
     "external": "Connected calendars",
     "closures": "Closed days",
-    "absences": "Leave"
+    "absences": "Leave",
+    "appointments": "Appointments"
   },
   "selectVehicle": "Select Vehicle for Work Order",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Delete the scheduled message \"{title}\"? It will not be sent.",
     "deleted": "Deleted",
     "updated": "Updated",
-    "failed": "That did not work. Try again."
+    "failed": "That did not work. Try again.",
+    "edit": "Edit",
+    "makeWorkOrder": "Make it a work order",
+    "deleteAppointment": "Delete appointment",
+    "deleteAppointmentConfirm": "Delete the appointment \"{title}\"? Its notifications will not be sent."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "Queued",
       "busy": "Busy",
       "closed": "Closed",
-      "away": "Away"
+      "away": "Away",
+      "booked": "Booked"
     },
     "type": {
       "service": "Work order",
@@ -140,7 +149,8 @@
       "message": "Message",
       "external": "Connected calendar",
       "closure": "Closed",
-      "absence": "Leave"
+      "absence": "Leave",
+      "appointment": "Appointment"
     },
     "noEvents": "No events",
     "selectDay": "Select a day with events or create a new work order.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Description",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "No vehicles found.",
     "noVehiclesMarine": "No boats found.",
     "none": "None",
+    "optional": "Optional",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Could not book the appointment",
+    "editTitle": "Edit booking",
+    "convertTitle": "Make it a work order",
+    "dialogDescription": "Put an appointment or a work order on the calendar, with who it is with and the notifications before it.",
+    "titleLabel": "Title",
+    "titlePlaceholder": "Add title",
+    "kind": {
+      "appointment": "Appointment",
+      "workOrder": "Work order",
+      "appointmentHint": "Only on the calendar. Make it a work order when the car comes in.",
+      "appointmentHintMarine": "Only on the calendar. Make it a work order when the boat comes in.",
+      "workOrderHint": "Holds a bay or a technician, gets a job number and shows on the work board."
+    },
+    "when": "When",
+    "start": "Start",
+    "end": "End",
+    "durationHours": "{count, plural, one {# hour} other {# hours}}",
+    "durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
+    "endAfterStart": "The end has to be after the start, and at most a week later.",
+    "appointmentWhoHint": "Choose a customer to be able to notify them before the appointment.",
+    "appointmentWhoHintMarine": "Choose a customer to be able to notify them before the appointment.",
+    "whoLocked": "Change the car or the customer on the work order.",
+    "whoLockedMarine": "Change the boat or the customer on the work order.",
+    "workHintTitle": "One thing per line. Each line is a concern on the job. Left empty, the title is.",
+    "workLocked": "Work is recorded on these concerns. Change them on the work order.",
+    "concernLabel": "Concern {number}",
+    "removeConcern": "Remove concern",
+    "addConcern": "Add a concern and press Enter",
+    "addConcernMarine": "Add a concern and press Enter",
+    "workEmptyHint": "Left empty, the title is the job's concern.",
+    "endsAnotherDay": "Ends another day",
+    "video": "Video call",
+    "addMeeting": "Add {name} meeting",
+    "meetingAdded": "A {name} meeting is added when you save.",
+    "removeMeeting": "Don't add the meeting",
+    "joinMeeting": "Join the video call",
+    "meetingFailed": "The booking is saved, but the meeting could not be added. {error}",
+    "notifications": "Notifications",
+    "addNotification": "Add notification",
+    "removeNotification": "Remove notification",
+    "channelLabel": "How",
+    "amountLabel": "How many",
+    "unitLabel": "Minutes, hours, days or weeks before",
+    "at": "at",
+    "atLabel": "Time of day",
+    "channel": {
+      "toWorkshop": "To the workshop",
+      "toCustomer": "To the customer",
+      "inApp": "In the app",
+      "email": "Email to me",
+      "customerSms": "SMS to customer",
+      "customerEmail": "Email to customer"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minute before} other {minutes before}}",
+      "hours": "{count, plural, one {hour before} other {hours before}}",
+      "days": "{count, plural, one {day before} other {days before}}",
+      "weeks": "{count, plural, one {week before} other {weeks before}}"
+    },
+    "customerChannelNeedsCustomer": "Choose a customer to notify them.",
+    "descriptionPlaceholder": "Add description",
+    "convertSubmit": "Make work order",
+    "saved": "Booking saved"
+  },
+  "notify": {
+    "inAppTitle": "Coming up: {title}",
+    "emailSubject": "Coming up: {title}, {when}",
+    "emailFooter": "You set this notification on the workshop calendar in Torqvoice.",
+    "customerSubject": "Your appointment at {workshop}",
+    "customerBody": "Hi {name}, this is a reminder of your appointment at {workshop} on {when}. See you then."
   }
 }

+ 6 - 0
messages/en/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Show cards",
   "cards": {
     "maintenance": "Predicted Maintenance",
+    "calendarEvents": "Calendar Events",
     "reminders": "Upcoming Reminders",
     "inspections": "Inspections",
     "serviceRequests": "Service Requests",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Dismiss",
     "dismissError": "Couldn't dismiss the vehicle. Try again."
   },
+  "calendarEvents": {
+    "title": "Calendar Events",
+    "description": "Next {days} days",
+    "noData": "Nothing on the calendar in the next {days} days."
+  },
   "reminders": {
     "title": "Upcoming Reminders",
     "overdue": "Overdue",

+ 1 - 1
messages/en/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Customers",
     "optCustomersDesc": "Customer records and contact info",
     "optVehicles": "Vehicles & Service Records",
-    "optVehiclesDesc": "Vehicles, notes, fuel logs, reminders, service records",
+    "optVehiclesDesc": "Vehicles, notes, fuel logs, reminders, service records, appointments",
     "optQuotes": "Quotes",
     "optQuotesDesc": "Quotes with parts and labor",
     "optInventory": "Inventory",

+ 7 - 0
messages/es/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Nota eliminada",
     "reminder_create": "Recordatorio creado",
     "reminder_delete": "Recordatorio eliminado",
+    "appointment_create": "Cita creada",
+    "appointment_update": "Cita actualizada",
+    "appointment_delete": "Cita eliminada",
     "reminder_import": "Recordatorios importados",
     "organization_create": "Organización creada",
     "subscription_cancel": "Suscripción cancelada",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Reserva {ref} modificada",
+    "appointment_create": "Cita {title} añadida",
+    "appointment_update": "Cita {title} modificada",
+    "appointment_delete": "Cita {title} eliminada",
     "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",

+ 88 - 8
messages/es/calendar.json

@@ -70,8 +70,11 @@
       "message": "Abrir mensajes",
       "external": "Abrir",
       "closure": "Abrir horario de apertura",
-      "absence": "Abrir el tablero de trabajo"
-    }
+      "absence": "Abrir el tablero de trabajo",
+      "appointment": "Abrir calendario"
+    },
+    "makeWorkOrder": "Crear orden de trabajo",
+    "notifications": "{count, plural, one {# notificación} other {# notificaciones}}"
   },
   "filters": {
     "services": "Órdenes de trabajo",
@@ -80,7 +83,8 @@
     "messages": "Mensajes",
     "external": "Calendarios conectados",
     "closures": "Días de cierre",
-    "absences": "Ausencias"
+    "absences": "Ausencias",
+    "appointments": "Citas"
   },
   "selectVehicle": "Seleccionar vehículo para orden de trabajo",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "¿Eliminar el mensaje programado \"{title}\"? No se enviará.",
     "deleted": "Eliminado",
     "updated": "Actualizado",
-    "failed": "No se pudo completar. Inténtalo de nuevo."
+    "failed": "No se pudo completar. Inténtalo de nuevo.",
+    "edit": "Editar",
+    "makeWorkOrder": "Convertir en orden de trabajo",
+    "deleteAppointment": "Eliminar cita",
+    "deleteAppointmentConfirm": "¿Eliminar la cita \"{title}\"? Sus notificaciones no se enviarán."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "En cola",
       "busy": "Ocupado",
       "closed": "Cerrado",
-      "away": "Ausente"
+      "away": "Ausente",
+      "booked": "Reservado"
     },
     "type": {
       "service": "Orden de trabajo",
@@ -140,7 +149,8 @@
       "message": "Mensaje",
       "external": "Calendario conectado",
       "closure": "Cerrado",
-      "absence": "Ausencia"
+      "absence": "Ausencia",
+      "appointment": "Cita"
     },
     "noEvents": "Sin eventos",
     "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Descripción",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "No se encontraron vehículos.",
     "noVehiclesMarine": "No se encontraron embarcaciones.",
     "none": "Ninguno",
+    "optional": "Opcional",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "No se pudo reservar la cita",
+    "editTitle": "Editar reserva",
+    "convertTitle": "Convertir en orden de trabajo",
+    "dialogDescription": "Pon una cita o una orden de trabajo en el calendario, con quién es y las notificaciones previas.",
+    "titleLabel": "Título",
+    "titlePlaceholder": "Añadir título",
+    "kind": {
+      "appointment": "Cita",
+      "workOrder": "Orden de trabajo",
+      "appointmentHint": "Solo en el calendario. Conviértela en orden de trabajo cuando llegue el vehículo.",
+      "appointmentHintMarine": "Solo en el calendario. Conviértela en orden de trabajo cuando llegue la embarcación.",
+      "workOrderHint": "Ocupa un puesto o un técnico, recibe un número de orden y aparece en el tablero de trabajo."
+    },
+    "when": "Cuándo",
+    "start": "Inicio",
+    "end": "Fin",
+    "durationHours": "{count, plural, one {# hora} other {# horas}}",
+    "durationMinutes": "{count, plural, one {# minuto} other {# minutos}}",
+    "endAfterStart": "El fin tiene que ser posterior al inicio, y como mucho una semana después.",
+    "appointmentWhoHint": "Elige un cliente para poder avisarle antes de la cita.",
+    "appointmentWhoHintMarine": "Elige un cliente para poder avisarle antes de la cita.",
+    "whoLocked": "Cambia el vehículo o el cliente en la orden de trabajo.",
+    "whoLockedMarine": "Cambia la embarcación o el cliente en la orden de trabajo.",
+    "workHintTitle": "Una cosa por línea. Cada línea es un motivo en la orden. Si lo dejas vacío, se usa el título.",
+    "workLocked": "Hay trabajo registrado en estos motivos. Cámbialos en la orden de trabajo.",
+    "concernLabel": "Motivo {number}",
+    "removeConcern": "Quitar motivo",
+    "addConcern": "Escribe un motivo y pulsa Intro",
+    "addConcernMarine": "Escribe un motivo y pulsa Intro",
+    "workEmptyHint": "Si lo dejas vacío, el título será el motivo del trabajo.",
+    "endsAnotherDay": "Termina otro día",
+    "video": "Videollamada",
+    "addMeeting": "Añadir reunión de {name}",
+    "meetingAdded": "Se añadirá una reunión de {name} al guardar.",
+    "removeMeeting": "No añadir la reunión",
+    "joinMeeting": "Unirse a la videollamada",
+    "meetingFailed": "La reserva se ha guardado, pero no se pudo añadir la reunión. {error}",
+    "notifications": "Notificaciones",
+    "addNotification": "Añadir notificación",
+    "removeNotification": "Quitar notificación",
+    "channelLabel": "Cómo",
+    "amountLabel": "Cuántos",
+    "unitLabel": "Minutos, horas, días o semanas antes",
+    "at": "a las",
+    "atLabel": "Hora del día",
+    "channel": {
+      "toWorkshop": "Al taller",
+      "toCustomer": "Al cliente",
+      "inApp": "En la app",
+      "email": "Correo para mí",
+      "customerSms": "SMS al cliente",
+      "customerEmail": "Correo al cliente"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuto antes} other {minutos antes}}",
+      "hours": "{count, plural, one {hora antes} other {horas antes}}",
+      "days": "{count, plural, one {día antes} other {días antes}}",
+      "weeks": "{count, plural, one {semana antes} other {semanas antes}}"
+    },
+    "customerChannelNeedsCustomer": "Elige un cliente para avisarle.",
+    "descriptionPlaceholder": "Añadir descripción",
+    "convertSubmit": "Crear orden de trabajo",
+    "saved": "Reserva guardada"
+  },
+  "notify": {
+    "inAppTitle": "Próximamente: {title}",
+    "emailSubject": "Próximamente: {title}, {when}",
+    "emailFooter": "Configuraste esta notificación en el calendario del taller en Torqvoice.",
+    "customerSubject": "Tu cita en {workshop}",
+    "customerBody": "Hola {name}, te recordamos tu cita en {workshop} el {when}. ¡Te esperamos!"
   }
 }

+ 6 - 0
messages/es/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Mostrar tarjetas",
   "cards": {
     "maintenance": "Mantenimiento previsto",
+    "calendarEvents": "Eventos del calendario",
     "reminders": "Recordatorios pendientes",
     "inspections": "Inspecciones",
     "serviceRequests": "Solicitudes de servicio",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Descartar",
     "dismissError": "No se pudo descartar el vehículo. Inténtelo de nuevo."
   },
+  "calendarEvents": {
+    "title": "Eventos del calendario",
+    "description": "Próximos {days} días",
+    "noData": "No hay nada en el calendario en los próximos {days} días."
+  },
   "reminders": {
     "title": "Recordatorios pendientes",
     "overdue": "Vencidos",

+ 1 - 1
messages/es/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Clientes",
     "optCustomersDesc": "Registros de clientes e información de contacto",
     "optVehicles": "Vehículos y registros de servicio",
-    "optVehiclesDesc": "Vehículos, notas, registros de combustible, recordatorios, registros de servicio",
+    "optVehiclesDesc": "Vehículos, notas, registros de combustible, recordatorios, registros de servicio, citas",
     "optQuotes": "Cotizaciones",
     "optQuotesDesc": "Cotizaciones con piezas y mano de obra",
     "optInventory": "Inventario",

+ 7 - 0
messages/fr/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Note supprimée",
     "reminder_create": "Rappel créé",
     "reminder_delete": "Rappel supprimé",
+    "appointment_create": "Rendez-vous créé",
+    "appointment_update": "Rendez-vous mis à jour",
+    "appointment_delete": "Rendez-vous supprimé",
     "reminder_import": "Rappels importés",
     "organization_create": "Organisation créée",
     "subscription_cancel": "Abonnement annulé",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Réservation {ref} modifiée",
+    "appointment_create": "Rendez-vous {title} ajouté",
+    "appointment_update": "Rendez-vous {title} modifié",
+    "appointment_delete": "Rendez-vous {title} supprimé",
     "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",

+ 88 - 8
messages/fr/calendar.json

@@ -70,8 +70,11 @@
       "message": "Ouvrir les messages",
       "external": "Ouvrir",
       "closure": "Ouvrir les horaires d'ouverture",
-      "absence": "Ouvrir le tableau de travail"
-    }
+      "absence": "Ouvrir le tableau de travail",
+      "appointment": "Ouvrir le calendrier"
+    },
+    "makeWorkOrder": "Créer l'ordre de travail",
+    "notifications": "{count, plural, one {# notification} other {# notifications}}"
   },
   "filters": {
     "services": "Ordres de travail",
@@ -80,7 +83,8 @@
     "messages": "Messages",
     "external": "Calendriers connectés",
     "closures": "Jours de fermeture",
-    "absences": "Absences"
+    "absences": "Absences",
+    "appointments": "Rendez-vous"
   },
   "selectVehicle": "Sélectionner un véhicule pour l’ordre de travail",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Supprimer le message programmé \"{title}\" ? Il ne sera pas envoyé.",
     "deleted": "Supprimé",
     "updated": "Mis à jour",
-    "failed": "Cela n'a pas fonctionné. Réessayez."
+    "failed": "Cela n'a pas fonctionné. Réessayez.",
+    "edit": "Modifier",
+    "makeWorkOrder": "En faire un ordre de travail",
+    "deleteAppointment": "Supprimer le rendez-vous",
+    "deleteAppointmentConfirm": "Supprimer le rendez-vous \"{title}\" ? Ses notifications ne seront pas envoyées."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "En file",
       "busy": "Occupé",
       "closed": "Fermé",
-      "away": "Absent"
+      "away": "Absent",
+      "booked": "Réservé"
     },
     "type": {
       "service": "Ordre de travail",
@@ -140,7 +149,8 @@
       "message": "Message",
       "external": "Calendrier connecté",
       "closure": "Fermé",
-      "absence": "Absence"
+      "absence": "Absence",
+      "appointment": "Rendez-vous"
     },
     "noEvents": "Aucun événement",
     "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Description",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Aucun véhicule trouvé.",
     "noVehiclesMarine": "Aucun bateau trouvé.",
     "none": "Aucun",
+    "optional": "Facultatif",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Impossible d’enregistrer le rendez-vous",
+    "editTitle": "Modifier la réservation",
+    "convertTitle": "En faire un ordre de travail",
+    "dialogDescription": "Placez un rendez-vous ou un ordre de travail dans le calendrier, avec la personne concernée et les notifications qui le précèdent.",
+    "titleLabel": "Titre",
+    "titlePlaceholder": "Ajouter un titre",
+    "kind": {
+      "appointment": "Rendez-vous",
+      "workOrder": "Ordre de travail",
+      "appointmentHint": "Uniquement dans le calendrier. Faites-en un ordre de travail à l’arrivée du véhicule.",
+      "appointmentHintMarine": "Uniquement dans le calendrier. Faites-en un ordre de travail à l’arrivée du bateau.",
+      "workOrderHint": "Occupe un poste ou un technicien, reçoit un numéro d’ordre et apparaît sur le tableau de travail."
+    },
+    "when": "Quand",
+    "start": "Début",
+    "end": "Fin",
+    "durationHours": "{count, plural, one {# heure} other {# heures}}",
+    "durationMinutes": "{count, plural, one {# minute} other {# minutes}}",
+    "endAfterStart": "La fin doit être après le début, et au plus une semaine plus tard.",
+    "appointmentWhoHint": "Choisissez un client pour pouvoir le prévenir avant le rendez-vous.",
+    "appointmentWhoHintMarine": "Choisissez un client pour pouvoir le prévenir avant le rendez-vous.",
+    "whoLocked": "Changez le véhicule ou le client sur l’ordre de travail.",
+    "whoLockedMarine": "Changez le bateau ou le client sur l’ordre de travail.",
+    "workHintTitle": "Une chose par ligne. Chaque ligne devient un point de l’intervention. Si le champ reste vide, le titre est utilisé.",
+    "workLocked": "Du travail est enregistré sur ces points. Modifiez-les sur l’ordre de travail.",
+    "concernLabel": "Point {number}",
+    "removeConcern": "Retirer le point",
+    "addConcern": "Saisissez un point puis Entrée",
+    "addConcernMarine": "Saisissez un point puis Entrée",
+    "workEmptyHint": "Laissé vide, le titre devient le point du travail.",
+    "endsAnotherDay": "Se termine un autre jour",
+    "video": "Appel vidéo",
+    "addMeeting": "Ajouter une réunion {name}",
+    "meetingAdded": "Une réunion {name} sera ajoutée à l’enregistrement.",
+    "removeMeeting": "Ne pas ajouter la réunion",
+    "joinMeeting": "Rejoindre l’appel vidéo",
+    "meetingFailed": "La réservation est enregistrée, mais la réunion n’a pas pu être ajoutée. {error}",
+    "notifications": "Notifications",
+    "addNotification": "Ajouter une notification",
+    "removeNotification": "Retirer la notification",
+    "channelLabel": "Comment",
+    "amountLabel": "Combien",
+    "unitLabel": "Minutes, heures, jours ou semaines avant",
+    "at": "à",
+    "atLabel": "Heure",
+    "channel": {
+      "toWorkshop": "À l’atelier",
+      "toCustomer": "Au client",
+      "inApp": "Dans l’app",
+      "email": "E-mail pour moi",
+      "customerSms": "SMS au client",
+      "customerEmail": "E-mail au client"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minute avant} other {minutes avant}}",
+      "hours": "{count, plural, one {heure avant} other {heures avant}}",
+      "days": "{count, plural, one {jour avant} other {jours avant}}",
+      "weeks": "{count, plural, one {semaine avant} other {semaines avant}}"
+    },
+    "customerChannelNeedsCustomer": "Choisissez un client pour le prévenir.",
+    "descriptionPlaceholder": "Ajouter une description",
+    "convertSubmit": "Créer l’ordre de travail",
+    "saved": "Réservation enregistrée"
+  },
+  "notify": {
+    "inAppTitle": "Bientôt : {title}",
+    "emailSubject": "Bientôt : {title}, {when}",
+    "emailFooter": "Vous avez programmé cette notification dans le calendrier de l'atelier sur Torqvoice.",
+    "customerSubject": "Votre rendez-vous chez {workshop}",
+    "customerBody": "Bonjour {name}, nous vous rappelons votre rendez-vous chez {workshop} le {when}. À bientôt !"
   }
 }

+ 6 - 0
messages/fr/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Afficher les cartes",
   "cards": {
     "maintenance": "Maintenance prévue",
+    "calendarEvents": "Événements du calendrier",
     "reminders": "Rappels à venir",
     "inspections": "Inspections",
     "serviceRequests": "Demandes de service",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Ignorer",
     "dismissError": "Impossible d'ignorer le véhicule. Veuillez réessayer."
   },
+  "calendarEvents": {
+    "title": "Événements du calendrier",
+    "description": "{days} prochains jours",
+    "noData": "Rien au calendrier pour les {days} prochains jours."
+  },
   "reminders": {
     "title": "Rappels à venir",
     "overdue": "En retard",

+ 1 - 1
messages/fr/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Clients",
     "optCustomersDesc": "Dossiers clients et informations de contact",
     "optVehicles": "Véhicules et dossiers de service",
-    "optVehiclesDesc": "Véhicules, notes, journaux de carburant, rappels, dossiers de service",
+    "optVehiclesDesc": "Véhicules, notes, journaux de carburant, rappels, dossiers de service, rendez-vous",
     "optQuotes": "Devis",
     "optQuotesDesc": "Devis avec pièces et main-d'oeuvre",
     "optInventory": "Inventaire",

+ 7 - 0
messages/it/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Nota eliminata",
     "reminder_create": "Promemoria creato",
     "reminder_delete": "Promemoria eliminato",
+    "appointment_create": "Appuntamento creato",
+    "appointment_update": "Appuntamento aggiornato",
+    "appointment_delete": "Appuntamento eliminato",
     "reminder_import": "Promemoria importati",
     "organization_create": "Organizzazione creata",
     "subscription_cancel": "Abbonamento annullato",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Prenotazione {ref} modificata",
+    "appointment_create": "Appuntamento {title} aggiunto",
+    "appointment_update": "Appuntamento {title} modificato",
+    "appointment_delete": "Appuntamento {title} eliminato",
     "service_createCounterSale": "Vendita al banco {ref} creata",
     "service_createDraft": "Bozza di intervento {ref} creata",
     "service_createFromInspection": "Ordine di lavoro {ref} creato da un controllo",

+ 88 - 8
messages/it/calendar.json

@@ -70,8 +70,11 @@
       "message": "Apri messaggi",
       "external": "Apri",
       "closure": "Apri orari di apertura",
-      "absence": "Apri la bacheca lavori"
-    }
+      "absence": "Apri la bacheca lavori",
+      "appointment": "Apri calendario"
+    },
+    "makeWorkOrder": "Crea ordine di lavoro",
+    "notifications": "{count, plural, one {# notifica} other {# notifiche}}"
   },
   "filters": {
     "services": "Ordini di lavoro",
@@ -80,7 +83,8 @@
     "messages": "Messaggi",
     "external": "Calendari collegati",
     "closures": "Giorni di chiusura",
-    "absences": "Assenze"
+    "absences": "Assenze",
+    "appointments": "Appuntamenti"
   },
   "selectVehicle": "Seleziona veicolo per ordine di lavoro",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Eliminare il messaggio pianificato \"{title}\"? Non verrà inviato.",
     "deleted": "Eliminato",
     "updated": "Aggiornato",
-    "failed": "Operazione non riuscita. Riprova."
+    "failed": "Operazione non riuscita. Riprova.",
+    "edit": "Modifica",
+    "makeWorkOrder": "Trasforma in ordine di lavoro",
+    "deleteAppointment": "Elimina appuntamento",
+    "deleteAppointmentConfirm": "Eliminare l'appuntamento \"{title}\"? Le sue notifiche non verranno inviate."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "In coda",
       "busy": "Occupato",
       "closed": "Chiuso",
-      "away": "Assente"
+      "away": "Assente",
+      "booked": "Prenotato"
     },
     "type": {
       "service": "Ordine di lavoro",
@@ -140,7 +149,8 @@
       "message": "Messaggio",
       "external": "Calendario collegato",
       "closure": "Chiuso",
-      "absence": "Assenza"
+      "absence": "Assenza",
+      "appointment": "Appuntamento"
     },
     "noEvents": "Nessun evento",
     "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Descrizione",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Nessun veicolo trovato.",
     "noVehiclesMarine": "Nessuna imbarcazione trovata.",
     "none": "Nessuno",
+    "optional": "Facoltativo",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Impossibile prenotare l'appuntamento",
+    "editTitle": "Modifica prenotazione",
+    "convertTitle": "Trasforma in ordine di lavoro",
+    "dialogDescription": "Metti in calendario un appuntamento o un ordine di lavoro, con chi riguarda e le notifiche che lo precedono.",
+    "titleLabel": "Titolo",
+    "titlePlaceholder": "Aggiungi titolo",
+    "kind": {
+      "appointment": "Appuntamento",
+      "workOrder": "Ordine di lavoro",
+      "appointmentHint": "Solo in calendario. Trasformalo in ordine di lavoro quando arriva l'auto.",
+      "appointmentHintMarine": "Solo in calendario. Trasformalo in ordine di lavoro quando arriva l'imbarcazione.",
+      "workOrderHint": "Occupa una postazione o un tecnico, riceve un numero di lavoro e compare nella bacheca lavori."
+    },
+    "when": "Quando",
+    "start": "Inizio",
+    "end": "Fine",
+    "durationHours": "{count, plural, one {# ora} other {# ore}}",
+    "durationMinutes": "{count, plural, one {# minuto} other {# minuti}}",
+    "endAfterStart": "La fine deve essere dopo l'inizio, al massimo una settimana dopo.",
+    "appointmentWhoHint": "Scegli un cliente per poterlo avvisare prima dell'appuntamento.",
+    "appointmentWhoHintMarine": "Scegli un cliente per poterlo avvisare prima dell'appuntamento.",
+    "whoLocked": "Cambia l'auto o il cliente nell'ordine di lavoro.",
+    "whoLockedMarine": "Cambia l'imbarcazione o il cliente nell'ordine di lavoro.",
+    "workHintTitle": "Una cosa per riga. Ogni riga diventa una segnalazione nel lavoro. Se resta vuoto, si usa il titolo.",
+    "workLocked": "Su queste segnalazioni è già registrato del lavoro. Modificale nell'ordine di lavoro.",
+    "concernLabel": "Segnalazione {number}",
+    "removeConcern": "Rimuovi segnalazione",
+    "addConcern": "Scrivi una segnalazione e premi Invio",
+    "addConcernMarine": "Scrivi una segnalazione e premi Invio",
+    "workEmptyHint": "Se vuoto, il titolo diventa la segnalazione del lavoro.",
+    "endsAnotherDay": "Termina un altro giorno",
+    "video": "Videochiamata",
+    "addMeeting": "Aggiungi riunione {name}",
+    "meetingAdded": "Al salvataggio verrà aggiunta una riunione {name}.",
+    "removeMeeting": "Non aggiungere la riunione",
+    "joinMeeting": "Partecipa alla videochiamata",
+    "meetingFailed": "La prenotazione è salvata, ma non è stato possibile aggiungere la riunione. {error}",
+    "notifications": "Notifiche",
+    "addNotification": "Aggiungi notifica",
+    "removeNotification": "Rimuovi notifica",
+    "channelLabel": "Come",
+    "amountLabel": "Quanti",
+    "unitLabel": "Minuti, ore, giorni o settimane prima",
+    "at": "alle",
+    "atLabel": "Ora del giorno",
+    "channel": {
+      "toWorkshop": "All'officina",
+      "toCustomer": "Al cliente",
+      "inApp": "Nell'app",
+      "email": "E-mail a me",
+      "customerSms": "SMS al cliente",
+      "customerEmail": "E-mail al cliente"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuto prima} other {minuti prima}}",
+      "hours": "{count, plural, one {ora prima} other {ore prima}}",
+      "days": "{count, plural, one {giorno prima} other {giorni prima}}",
+      "weeks": "{count, plural, one {settimana prima} other {settimane prima}}"
+    },
+    "customerChannelNeedsCustomer": "Scegli un cliente per avvisarlo.",
+    "descriptionPlaceholder": "Aggiungi descrizione",
+    "convertSubmit": "Crea ordine di lavoro",
+    "saved": "Prenotazione salvata"
+  },
+  "notify": {
+    "inAppTitle": "In arrivo: {title}",
+    "emailSubject": "In arrivo: {title}, {when}",
+    "emailFooter": "Hai impostato questa notifica nel calendario dell'officina su Torqvoice.",
+    "customerSubject": "Il tuo appuntamento presso {workshop}",
+    "customerBody": "Ciao {name}, ti ricordiamo il tuo appuntamento presso {workshop} per {when}. A presto!"
   }
 }

+ 6 - 0
messages/it/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Mostra schede",
   "cards": {
     "maintenance": "Manutenzione prevista",
+    "calendarEvents": "Eventi del calendario",
     "reminders": "Promemoria in scadenza",
     "inspections": "Ispezioni",
     "serviceRequests": "Richieste di servizio",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Ignora",
     "dismissError": "Impossibile ignorare il veicolo. Riprova."
   },
+  "calendarEvents": {
+    "title": "Eventi del calendario",
+    "description": "Prossimi {days} giorni",
+    "noData": "Nulla in calendario nei prossimi {days} giorni."
+  },
   "reminders": {
     "title": "Promemoria in scadenza",
     "overdue": "Scaduto",

+ 1 - 1
messages/it/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Clienti",
     "optCustomersDesc": "Registri clienti e informazioni di contatto",
     "optVehicles": "Veicoli e registri di servizio",
-    "optVehiclesDesc": "Veicoli, note, registri carburante, promemoria, registri di servizio",
+    "optVehiclesDesc": "Veicoli, note, registri carburante, promemoria, registri di servizio, appuntamenti",
     "optQuotes": "Preventivi",
     "optQuotesDesc": "Preventivi con ricambi e manodopera",
     "optInventory": "Inventario",

+ 7 - 0
messages/lt/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Ištrinta pastaba",
     "reminder_create": "Sukurtas priminimas",
     "reminder_delete": "Ištrintas priminimas",
+    "appointment_create": "Sukurtas vizitas",
+    "appointment_update": "Atnaujintas vizitas",
+    "appointment_delete": "Ištrintas vizitas",
     "reminder_import": "Importuoti priminimai",
     "organization_create": "Sukurta organizacija",
     "subscription_cancel": "Atšaukta prenumerata",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Pakeista registracija {ref}",
+    "appointment_create": "Pridėtas vizitas {title}",
+    "appointment_update": "Pakeistas vizitas {title}",
+    "appointment_delete": "Ištrintas vizitas {title}",
     "service_createCounterSale": "Sukurtas pardavimas prie prekystalio {ref}",
     "service_createDraft": "Sukurtas darbo juodraštis {ref}",
     "service_createFromInspection": "Iš patikros sukurtas darbo užsakymas {ref}",

+ 88 - 8
messages/lt/calendar.json

@@ -70,8 +70,11 @@
       "message": "Atidaryti žinutes",
       "external": "Atidaryti",
       "closure": "Atidaryti darbo laiką",
-      "absence": "Atidaryti darbų lentą"
-    }
+      "absence": "Atidaryti darbų lentą",
+      "appointment": "Atidaryti kalendorių"
+    },
+    "makeWorkOrder": "Sukurti darbo užsakymą",
+    "notifications": "{count, plural, one {# pranešimas} few {# pranešimai} many {# pranešimo} other {# pranešimų}}"
   },
   "filters": {
     "services": "Darbo užsakymai",
@@ -80,7 +83,8 @@
     "messages": "Žinutės",
     "external": "Prijungti kalendoriai",
     "closures": "Nedarbo dienos",
-    "absences": "Nebuvimas darbe"
+    "absences": "Nebuvimas darbe",
+    "appointments": "Vizitai"
   },
   "selectVehicle": "Pasirinkite transporto priemonę darbo užsakymui",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Ištrinti suplanuotą žinutę „{title}“? Ji nebus išsiųsta.",
     "deleted": "Ištrinta",
     "updated": "Atnaujinta",
-    "failed": "Nepavyko. Bandykite dar kartą."
+    "failed": "Nepavyko. Bandykite dar kartą.",
+    "edit": "Redaguoti",
+    "makeWorkOrder": "Paversti darbo užsakymu",
+    "deleteAppointment": "Ištrinti vizitą",
+    "deleteAppointmentConfirm": "Ištrinti vizitą „{title}“? Jo pranešimai nebus išsiųsti."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "Eilėje",
       "busy": "Užimta",
       "closed": "Uždaryta",
-      "away": "Nėra"
+      "away": "Nėra",
+      "booked": "Užregistruota"
     },
     "type": {
       "service": "Darbo užsakymas",
@@ -140,7 +149,8 @@
       "message": "Žinutė",
       "external": "Prijungtas kalendorius",
       "closure": "Uždaryta",
-      "absence": "Nebuvimas darbe"
+      "absence": "Nebuvimas darbe",
+      "appointment": "Vizitas"
     },
     "noEvents": "Įvykių nėra",
     "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Aprašymas",
     "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ą",
@@ -178,6 +188,7 @@
     "noVehicles": "Transporto priemonių nerasta.",
     "noVehiclesMarine": "Valčių nerasta.",
     "none": "Nėra",
+    "optional": "Neprivaloma",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Nepavyko užregistruoti vizito",
+    "editTitle": "Redaguoti registraciją",
+    "convertTitle": "Paversti darbo užsakymu",
+    "dialogDescription": "Įrašykite vizitą arba darbo užsakymą į kalendorių, nurodykite, su kuo jis, ir pranešimus prieš jį.",
+    "titleLabel": "Pavadinimas",
+    "titlePlaceholder": "Pridėti pavadinimą",
+    "kind": {
+      "appointment": "Vizitas",
+      "workOrder": "Darbo užsakymas",
+      "appointmentHint": "Tik kalendoriuje. Paverskite jį darbo užsakymu, kai automobilis atvyks.",
+      "appointmentHintMarine": "Tik kalendoriuje. Paverskite jį darbo užsakymu, kai valtis bus pristatyta.",
+      "workOrderHint": "Užima darbo vietą arba meistrą, gauna darbo numerį ir rodomas darbų lentoje."
+    },
+    "when": "Kada",
+    "start": "Pradžia",
+    "end": "Pabaiga",
+    "durationHours": "{count, plural, one {# valanda} few {# valandos} many {# valandos} other {# valandų}}",
+    "durationMinutes": "{count, plural, one {# minutė} few {# minutės} many {# minutės} other {# minučių}}",
+    "endAfterStart": "Pabaiga turi būti po pradžios ir ne vėliau kaip po savaitės.",
+    "appointmentWhoHint": "Pasirinkite klientą, kad galėtumėte jam priminti prieš vizitą.",
+    "appointmentWhoHintMarine": "Pasirinkite klientą, kad galėtumėte jam priminti prieš vizitą.",
+    "whoLocked": "Automobilį ar klientą keiskite darbo užsakyme.",
+    "whoLockedMarine": "Valtį ar klientą keiskite darbo užsakyme.",
+    "workHintTitle": "Po vieną dalyką eilutėje. Kiekviena eilutė tampa darbo punktu. Palikus tuščią, naudojamas pavadinimas.",
+    "workLocked": "Šiems punktams jau užregistruotas darbas. Keiskite juos darbo užsakyme.",
+    "concernLabel": "Punktas {number}",
+    "removeConcern": "Pašalinti punktą",
+    "addConcern": "Įrašykite punktą ir paspauskite Enter",
+    "addConcernMarine": "Įrašykite punktą ir paspauskite Enter",
+    "workEmptyHint": "Palikus tuščią, darbo punktu tampa pavadinimas.",
+    "endsAnotherDay": "Baigiasi kitą dieną",
+    "video": "Vaizdo skambutis",
+    "addMeeting": "Pridėti {name} susitikimą",
+    "meetingAdded": "Išsaugojus bus pridėtas {name} susitikimas.",
+    "removeMeeting": "Nepridėti susitikimo",
+    "joinMeeting": "Prisijungti prie vaizdo skambučio",
+    "meetingFailed": "Registracija išsaugota, bet susitikimo pridėti nepavyko. {error}",
+    "notifications": "Pranešimai",
+    "addNotification": "Pridėti pranešimą",
+    "removeNotification": "Pašalinti pranešimą",
+    "channelLabel": "Kaip",
+    "amountLabel": "Kiek",
+    "unitLabel": "Minutės, valandos, dienos ar savaitės prieš",
+    "at": "laikas",
+    "atLabel": "Paros laikas",
+    "channel": {
+      "toWorkshop": "Dirbtuvėms",
+      "toCustomer": "Klientui",
+      "inApp": "Programoje",
+      "email": "El. laiškas man",
+      "customerSms": "SMS klientui",
+      "customerEmail": "El. laiškas klientui"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minutė prieš} few {minutės prieš} many {minutės prieš} other {minučių prieš}}",
+      "hours": "{count, plural, one {valanda prieš} few {valandos prieš} many {valandos prieš} other {valandų prieš}}",
+      "days": "{count, plural, one {diena prieš} few {dienos prieš} many {dienos prieš} other {dienų prieš}}",
+      "weeks": "{count, plural, one {savaitė prieš} few {savaitės prieš} many {savaitės prieš} other {savaičių prieš}}"
+    },
+    "customerChannelNeedsCustomer": "Pasirinkite klientą, kad jam būtų pranešta.",
+    "descriptionPlaceholder": "Pridėti aprašymą",
+    "convertSubmit": "Sukurti darbo užsakymą",
+    "saved": "Registracija išsaugota"
+  },
+  "notify": {
+    "inAppTitle": "Artėja: {title}",
+    "emailSubject": "Artėja: {title}, {when}",
+    "emailFooter": "Šį pranešimą nustatėte dirbtuvių kalendoriuje Torqvoice sistemoje.",
+    "customerSubject": "Jūsų vizitas: {workshop}",
+    "customerBody": "Sveiki, {name}! Primename apie jūsų vizitą: {workshop}, {when}. Lauksime jūsų!"
   }
 }

+ 6 - 0
messages/lt/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Rodyti korteles",
   "cards": {
     "maintenance": "Numatoma priežiūra",
+    "calendarEvents": "Kalendoriaus įvykiai",
     "reminders": "Artėjantys priminimai",
     "inspections": "Apžiūros",
     "serviceRequests": "Paslaugų užklausos",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Atmesti",
     "dismissError": "Nepavyko atmesti transporto priemonės. Bandykite dar kartą."
   },
+  "calendarEvents": {
+    "title": "Kalendoriaus įvykiai",
+    "description": "{days, plural, one {# artimiausia diena} few {# artimiausios dienos} many {# artimiausios dienos} other {# artimiausių dienų}}",
+    "noData": "Per {days, plural, one {# artimiausią dieną} few {# artimiausias dienas} many {# artimiausios dienos} other {# artimiausių dienų}} kalendoriuje nieko nėra."
+  },
   "reminders": {
     "title": "Artėjantys priminimai",
     "overdue": "Vėluoja",

+ 1 - 1
messages/lt/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Klientai",
     "optCustomersDesc": "Klientų įrašai ir kontaktinė informacija",
     "optVehicles": "Transporto priemonės ir paslaugų įrašai",
-    "optVehiclesDesc": "Transporto priemonės, pastabos, degalų žurnalai, priminimai, paslaugų įrašai",
+    "optVehiclesDesc": "Transporto priemonės, pastabos, degalų žurnalai, priminimai, paslaugų įrašai, vizitai",
     "optQuotes": "Pasiūlymai",
     "optQuotesDesc": "Pasiūlymai su dalimis ir darbais",
     "optInventory": "Inventorius",

+ 7 - 0
messages/nb/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Slettet notat",
     "reminder_create": "Opprettet påminnelse",
     "reminder_delete": "Slettet påminnelse",
+    "appointment_create": "Opprettet timeavtale",
+    "appointment_update": "Oppdaterte timeavtale",
+    "appointment_delete": "Slettet timeavtale",
     "reminder_import": "Importerte påminnelser",
     "organization_create": "Opprettet organisasjon",
     "subscription_cancel": "Kansellerte abonnement",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Endret booking {ref}",
+    "appointment_create": "La til timeavtale {title}",
+    "appointment_update": "Endret timeavtale {title}",
+    "appointment_delete": "Slettet timeavtale {title}",
     "service_createCounterSale": "Opprettet disksalg {ref}",
     "service_createDraft": "Opprettet utkast til jobb {ref}",
     "service_createFromInspection": "Opprettet arbeidsordre {ref} fra en kontroll",

+ 88 - 8
messages/nb/calendar.json

@@ -70,8 +70,11 @@
       "message": "Åpne meldinger",
       "external": "Åpne",
       "closure": "Åpne åpningstider",
-      "absence": "Åpne arbeidstavlen"
-    }
+      "absence": "Åpne arbeidstavlen",
+      "appointment": "Åpne kalenderen"
+    },
+    "makeWorkOrder": "Lag arbeidsordre",
+    "notifications": "{count, plural, one {# varsel} other {# varsler}}"
   },
   "filters": {
     "services": "Arbeidsordrer",
@@ -80,7 +83,8 @@
     "messages": "Meldinger",
     "external": "Tilkoblede kalendere",
     "closures": "Stengte dager",
-    "absences": "Fravær"
+    "absences": "Fravær",
+    "appointments": "Timeavtaler"
   },
   "selectVehicle": "Velg kjøretøy for arbeidsordre",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Slette den planlagte meldingen «{title}»? Den blir ikke sendt.",
     "deleted": "Slettet",
     "updated": "Oppdatert",
-    "failed": "Det gikk ikke. Prøv igjen."
+    "failed": "Det gikk ikke. Prøv igjen.",
+    "edit": "Rediger",
+    "makeWorkOrder": "Gjør om til arbeidsordre",
+    "deleteAppointment": "Slett timeavtale",
+    "deleteAppointmentConfirm": "Slette timeavtalen «{title}»? Varslene for den blir ikke sendt."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "I kø",
       "busy": "Opptatt",
       "closed": "Stengt",
-      "away": "Borte"
+      "away": "Borte",
+      "booked": "Booket"
     },
     "type": {
       "service": "Arbeidsordre",
@@ -140,7 +149,8 @@
       "message": "Melding",
       "external": "Tilkoblet kalender",
       "closure": "Stengt",
-      "absence": "Fravær"
+      "absence": "Fravær",
+      "appointment": "Timeavtale"
     },
     "noEvents": "Ingen hendelser",
     "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Beskrivelse",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Ingen kjøretøy funnet.",
     "noVehiclesMarine": "Ingen båter funnet.",
     "none": "Ingen",
+    "optional": "Valgfritt",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Kunne ikke booke timen",
+    "editTitle": "Rediger booking",
+    "convertTitle": "Gjør om til arbeidsordre",
+    "dialogDescription": "Sett en timeavtale eller en arbeidsordre i kalenderen, med hvem den gjelder og varslene før den.",
+    "titleLabel": "Tittel",
+    "titlePlaceholder": "Legg til tittel",
+    "kind": {
+      "appointment": "Timeavtale",
+      "workOrder": "Arbeidsordre",
+      "appointmentHint": "Bare i kalenderen. Gjør den om til en arbeidsordre når bilen kommer.",
+      "appointmentHintMarine": "Bare i kalenderen. Gjør den om til en arbeidsordre når båten kommer.",
+      "workOrderHint": "Holder av en arbeidsplass eller en mekaniker, får et jobbnummer og vises på arbeidstavlen."
+    },
+    "when": "Når",
+    "start": "Start",
+    "end": "Slutt",
+    "durationHours": "{count, plural, one {# time} other {# timer}}",
+    "durationMinutes": "{count, plural, one {# minutt} other {# minutter}}",
+    "endAfterStart": "Slutt må være etter start, og høyst en uke senere.",
+    "appointmentWhoHint": "Velg en kunde for å kunne varsle kunden før timeavtalen.",
+    "appointmentWhoHintMarine": "Velg en kunde for å kunne varsle kunden før timeavtalen.",
+    "whoLocked": "Endre bilen eller kunden på arbeidsordren.",
+    "whoLockedMarine": "Endre båten eller kunden på arbeidsordren.",
+    "workHintTitle": "Én ting per linje. Hver linje blir et punkt på jobben. Står feltet tomt, brukes tittelen.",
+    "workLocked": "Det er registrert arbeid på disse punktene. Endre dem på arbeidsordren.",
+    "concernLabel": "Punkt {number}",
+    "removeConcern": "Fjern punkt",
+    "addConcern": "Skriv et punkt og trykk Enter",
+    "addConcernMarine": "Skriv et punkt og trykk Enter",
+    "workEmptyHint": "Står det tomt, blir tittelen jobbens punkt.",
+    "endsAnotherDay": "Slutter en annen dag",
+    "video": "Videosamtale",
+    "addMeeting": "Legg til {name}-møte",
+    "meetingAdded": "Et {name}-møte legges til når du lagrer.",
+    "removeMeeting": "Ikke legg til møtet",
+    "joinMeeting": "Bli med i videosamtalen",
+    "meetingFailed": "Bookingen er lagret, men møtet kunne ikke legges til. {error}",
+    "notifications": "Varsler",
+    "addNotification": "Legg til varsel",
+    "removeNotification": "Fjern varsel",
+    "channelLabel": "Hvordan",
+    "amountLabel": "Hvor mange",
+    "unitLabel": "Minutter, timer, dager eller uker før",
+    "at": "kl.",
+    "atLabel": "Klokkeslett",
+    "channel": {
+      "toWorkshop": "Til verkstedet",
+      "toCustomer": "Til kunden",
+      "inApp": "I appen",
+      "email": "E-post til meg",
+      "customerSms": "SMS til kunden",
+      "customerEmail": "E-post til kunden"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minutt før} other {minutter før}}",
+      "hours": "{count, plural, one {time før} other {timer før}}",
+      "days": "{count, plural, one {dag før} other {dager før}}",
+      "weeks": "{count, plural, one {uke før} other {uker før}}"
+    },
+    "customerChannelNeedsCustomer": "Velg en kunde for å varsle kunden.",
+    "descriptionPlaceholder": "Legg til beskrivelse",
+    "convertSubmit": "Lag arbeidsordre",
+    "saved": "Booking lagret"
+  },
+  "notify": {
+    "inAppTitle": "Snart: {title}",
+    "emailSubject": "Snart: {title}, {when}",
+    "emailFooter": "Du satte opp dette varselet i verkstedkalenderen i Torqvoice.",
+    "customerSubject": "Timen din hos {workshop}",
+    "customerBody": "Hei {name}! Vi minner om timen din hos {workshop} {when}. Vel møtt!"
   }
 }

+ 6 - 0
messages/nb/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Vis kort",
   "cards": {
     "maintenance": "Forventet vedlikehold",
+    "calendarEvents": "Kalenderhendelser",
     "reminders": "Kommende påminnelser",
     "inspections": "Inspeksjoner",
     "serviceRequests": "Serviceforespørsler",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Avvis",
     "dismissError": "Kunne ikke avvise kjøretøyet. Prøv igjen."
   },
+  "calendarEvents": {
+    "title": "Kalenderhendelser",
+    "description": "Neste {days} dager",
+    "noData": "Ingenting i kalenderen de neste {days} dagene."
+  },
   "reminders": {
     "title": "Kommende påminnelser",
     "overdue": "Forfalt",

+ 1 - 1
messages/nb/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Kunder",
     "optCustomersDesc": "Kundeposter og kontaktinformasjon",
     "optVehicles": "Kjøretøy og servicedata",
-    "optVehiclesDesc": "Kjøretøy, notater, drivstofflogger, påminnelser, servicedata",
+    "optVehiclesDesc": "Kjøretøy, notater, drivstofflogger, påminnelser, servicedata, timeavtaler",
     "optQuotes": "Tilbud",
     "optQuotesDesc": "Tilbud med deler og arbeid",
     "optInventory": "Inventar",

+ 7 - 0
messages/nl/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Notitie verwijderd",
     "reminder_create": "Herinnering aangemaakt",
     "reminder_delete": "Herinnering verwijderd",
+    "appointment_create": "Afspraak aangemaakt",
+    "appointment_update": "Afspraak bijgewerkt",
+    "appointment_delete": "Afspraak verwijderd",
     "reminder_import": "Herinneringen geïmporteerd",
     "organization_create": "Organisatie aangemaakt",
     "subscription_cancel": "Abonnement opgezegd",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Boeking {ref} gewijzigd",
+    "appointment_create": "Afspraak {title} toegevoegd",
+    "appointment_update": "Afspraak {title} gewijzigd",
+    "appointment_delete": "Afspraak {title} verwijderd",
     "service_createCounterSale": "Baliekoop {ref} aangemaakt",
     "service_createDraft": "Conceptopdracht {ref} aangemaakt",
     "service_createFromInspection": "Werkorder {ref} aangemaakt vanuit een keuring",

+ 88 - 8
messages/nl/calendar.json

@@ -70,8 +70,11 @@
       "message": "Berichten openen",
       "external": "Openen",
       "closure": "Openingstijden openen",
-      "absence": "Werkbord openen"
-    }
+      "absence": "Werkbord openen",
+      "appointment": "Kalender openen"
+    },
+    "makeWorkOrder": "Werkorder maken",
+    "notifications": "{count, plural, one {# melding} other {# meldingen}}"
   },
   "filters": {
     "services": "Werkorders",
@@ -80,7 +83,8 @@
     "messages": "Berichten",
     "external": "Gekoppelde agenda's",
     "closures": "Sluitingsdagen",
-    "absences": "Verlof"
+    "absences": "Verlof",
+    "appointments": "Afspraken"
   },
   "selectVehicle": "Selecteer voertuig voor werkorder",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Gepland bericht \"{title}\" verwijderen? Het wordt niet verzonden.",
     "deleted": "Verwijderd",
     "updated": "Bijgewerkt",
-    "failed": "Dat is niet gelukt. Probeer het opnieuw."
+    "failed": "Dat is niet gelukt. Probeer het opnieuw.",
+    "edit": "Bewerken",
+    "makeWorkOrder": "Omzetten naar werkorder",
+    "deleteAppointment": "Afspraak verwijderen",
+    "deleteAppointmentConfirm": "Afspraak \"{title}\" verwijderen? De meldingen ervan worden niet verzonden."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "In wachtrij",
       "busy": "Bezet",
       "closed": "Gesloten",
-      "away": "Afwezig"
+      "away": "Afwezig",
+      "booked": "Geboekt"
     },
     "type": {
       "service": "Werkorder",
@@ -140,7 +149,8 @@
       "message": "Bericht",
       "external": "Gekoppelde agenda",
       "closure": "Gesloten",
-      "absence": "Verlof"
+      "absence": "Verlof",
+      "appointment": "Afspraak"
     },
     "noEvents": "Geen evenementen",
     "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Beschrijving",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Geen voertuigen gevonden.",
     "noVehiclesMarine": "Geen boten gevonden.",
     "none": "Geen",
+    "optional": "Optioneel",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Kon de afspraak niet inplannen",
+    "editTitle": "Boeking bewerken",
+    "convertTitle": "Omzetten naar werkorder",
+    "dialogDescription": "Zet een afspraak of een werkorder in de kalender, met wie het is en de meldingen vooraf.",
+    "titleLabel": "Titel",
+    "titlePlaceholder": "Titel toevoegen",
+    "kind": {
+      "appointment": "Afspraak",
+      "workOrder": "Werkorder",
+      "appointmentHint": "Alleen in de kalender. Maak er een werkorder van zodra de auto binnenkomt.",
+      "appointmentHintMarine": "Alleen in de kalender. Maak er een werkorder van zodra de boot binnenkomt.",
+      "workOrderHint": "Houdt een werkplek of een monteur bezet, krijgt een ordernummer en staat op het werkbord."
+    },
+    "when": "Wanneer",
+    "start": "Begin",
+    "end": "Einde",
+    "durationHours": "{count, plural, one {# uur} other {# uur}}",
+    "durationMinutes": "{count, plural, one {# minuut} other {# minuten}}",
+    "endAfterStart": "Het einde moet na het begin liggen, en hoogstens een week later.",
+    "appointmentWhoHint": "Kies een klant om die vóór de afspraak te kunnen herinneren.",
+    "appointmentWhoHintMarine": "Kies een klant om die vóór de afspraak te kunnen herinneren.",
+    "whoLocked": "Wijzig de auto of de klant op de werkorder.",
+    "whoLockedMarine": "Wijzig de boot of de klant op de werkorder.",
+    "workHintTitle": "Eén ding per regel. Elke regel wordt een punt op de werkorder. Blijft het leeg, dan wordt de titel gebruikt.",
+    "workLocked": "Op deze punten is al werk geregistreerd. Wijzig ze op de werkorder.",
+    "concernLabel": "Punt {number}",
+    "removeConcern": "Punt verwijderen",
+    "addConcern": "Typ een punt en druk op Enter",
+    "addConcernMarine": "Typ een punt en druk op Enter",
+    "workEmptyHint": "Leeg gelaten wordt de titel het punt van de opdracht.",
+    "endsAnotherDay": "Eindigt op een andere dag",
+    "video": "Videogesprek",
+    "addMeeting": "{name}-vergadering toevoegen",
+    "meetingAdded": "Bij het opslaan wordt een {name}-vergadering toegevoegd.",
+    "removeMeeting": "Vergadering niet toevoegen",
+    "joinMeeting": "Deelnemen aan het videogesprek",
+    "meetingFailed": "De boeking is opgeslagen, maar de vergadering kon niet worden toegevoegd. {error}",
+    "notifications": "Meldingen",
+    "addNotification": "Melding toevoegen",
+    "removeNotification": "Melding verwijderen",
+    "channelLabel": "Hoe",
+    "amountLabel": "Hoeveel",
+    "unitLabel": "Minuten, uren, dagen of weken vooraf",
+    "at": "om",
+    "atLabel": "Tijdstip",
+    "channel": {
+      "toWorkshop": "Naar de werkplaats",
+      "toCustomer": "Naar de klant",
+      "inApp": "In de app",
+      "email": "E-mail naar mij",
+      "customerSms": "Sms naar de klant",
+      "customerEmail": "E-mail naar de klant"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuut vooraf} other {minuten vooraf}}",
+      "hours": "{count, plural, one {uur vooraf} other {uur vooraf}}",
+      "days": "{count, plural, one {dag vooraf} other {dagen vooraf}}",
+      "weeks": "{count, plural, one {week vooraf} other {weken vooraf}}"
+    },
+    "customerChannelNeedsCustomer": "Kies een klant om die op de hoogte te brengen.",
+    "descriptionPlaceholder": "Beschrijving toevoegen",
+    "convertSubmit": "Werkorder maken",
+    "saved": "Boeking opgeslagen"
+  },
+  "notify": {
+    "inAppTitle": "Binnenkort: {title}",
+    "emailSubject": "Binnenkort: {title}, {when}",
+    "emailFooter": "Je hebt deze melding ingesteld in de werkplaatskalender in Torqvoice.",
+    "customerSubject": "Uw afspraak bij {workshop}",
+    "customerBody": "Hallo {name}, graag herinneren we u aan uw afspraak bij {workshop} op {when}. Tot dan!"
   }
 }

+ 6 - 0
messages/nl/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Kaarten tonen",
   "cards": {
     "maintenance": "Verwacht onderhoud",
+    "calendarEvents": "Kalenderitems",
     "reminders": "Aankomende herinneringen",
     "inspections": "Inspecties",
     "serviceRequests": "Serviceaanvragen",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Negeren",
     "dismissError": "Kon het voertuig niet negeren. Probeer het opnieuw."
   },
+  "calendarEvents": {
+    "title": "Kalenderitems",
+    "description": "Komende {days} dagen",
+    "noData": "De komende {days} dagen staat er niets in de kalender."
+  },
   "reminders": {
     "title": "Aankomende herinneringen",
     "overdue": "Achterstallig",

+ 1 - 1
messages/nl/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Klanten",
     "optCustomersDesc": "Klantenrecords en contactgegevens",
     "optVehicles": "Voertuigen en servicerecords",
-    "optVehiclesDesc": "Voertuigen, notities, brandstoflogboeken, herinneringen, servicerecords",
+    "optVehiclesDesc": "Voertuigen, notities, brandstoflogboeken, herinneringen, servicerecords, afspraken",
     "optQuotes": "Offertes",
     "optQuotesDesc": "Offertes met onderdelen en arbeid",
     "optInventory": "Inventaris",

+ 7 - 0
messages/pl/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Usunięto notatkę",
     "reminder_create": "Utworzono przypomnienie",
     "reminder_delete": "Usunięto przypomnienie",
+    "appointment_create": "Utworzono wizytę",
+    "appointment_update": "Zaktualizowano wizytę",
+    "appointment_delete": "Usunięto wizytę",
     "reminder_import": "Zaimportowano przypomnienia",
     "organization_create": "Utworzono organizację",
     "subscription_cancel": "Anulowano subskrypcję",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Zmieniono rezerwację {ref}",
+    "appointment_create": "Dodano wizytę {title}",
+    "appointment_update": "Zmieniono wizytę {title}",
+    "appointment_delete": "Usunięto wizytę {title}",
     "service_createCounterSale": "Utworzono sprzedaż z lady {ref}",
     "service_createDraft": "Utworzono szkic zlecenia {ref}",
     "service_createFromInspection": "Utworzono zlecenie warsztatowe {ref} z przeglądu",

+ 88 - 8
messages/pl/calendar.json

@@ -70,8 +70,11 @@
       "message": "Otwórz wiadomości",
       "external": "Otwórz",
       "closure": "Otwórz godziny otwarcia",
-      "absence": "Otwórz tablicę zleceń"
-    }
+      "absence": "Otwórz tablicę zleceń",
+      "appointment": "Otwórz kalendarz"
+    },
+    "makeWorkOrder": "Utwórz zlecenie",
+    "notifications": "{count, plural, one {# powiadomienie} few {# powiadomienia} many {# powiadomień} other {# powiadomienia}}"
   },
   "filters": {
     "services": "Zlecenia",
@@ -80,7 +83,8 @@
     "messages": "Wiadomości",
     "external": "Połączone kalendarze",
     "closures": "Dni zamknięcia",
-    "absences": "Nieobecności"
+    "absences": "Nieobecności",
+    "appointments": "Wizyty"
   },
   "selectVehicle": "Wybierz pojazd do zlecenia",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Usunąć zaplanowaną wiadomość „{title}”? Nie zostanie wysłana.",
     "deleted": "Usunięto",
     "updated": "Zaktualizowano",
-    "failed": "Nie udało się. Spróbuj ponownie."
+    "failed": "Nie udało się. Spróbuj ponownie.",
+    "edit": "Edytuj",
+    "makeWorkOrder": "Zamień w zlecenie",
+    "deleteAppointment": "Usuń wizytę",
+    "deleteAppointmentConfirm": "Usunąć wizytę „{title}”? Jej powiadomienia nie zostaną wysłane."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "W kolejce",
       "busy": "Zajęte",
       "closed": "Zamknięte",
-      "away": "Nieobecność"
+      "away": "Nieobecność",
+      "booked": "Umówione"
     },
     "type": {
       "service": "Zlecenie",
@@ -140,7 +149,8 @@
       "message": "Wiadomość",
       "external": "Połączony kalendarz",
       "closure": "Zamknięte",
-      "absence": "Nieobecność"
+      "absence": "Nieobecność",
+      "appointment": "Wizyta"
     },
     "noEvents": "Brak wydarzeń",
     "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Opis",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Nie znaleziono pojazdów.",
     "noVehiclesMarine": "Nie znaleziono łodzi.",
     "none": "Brak",
+    "optional": "Opcjonalnie",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Nie udało się umówić wizyty",
+    "editTitle": "Edytuj rezerwację",
+    "convertTitle": "Zamień w zlecenie",
+    "dialogDescription": "Wpisz wizytę lub zlecenie do kalendarza, z informacją, kogo dotyczy, i powiadomieniami przed terminem.",
+    "titleLabel": "Tytuł",
+    "titlePlaceholder": "Dodaj tytuł",
+    "kind": {
+      "appointment": "Wizyta",
+      "workOrder": "Zlecenie",
+      "appointmentHint": "Tylko w kalendarzu. Zamień ją w zlecenie, gdy auto trafi do warsztatu.",
+      "appointmentHintMarine": "Tylko w kalendarzu. Zamień ją w zlecenie, gdy łódź trafi do warsztatu.",
+      "workOrderHint": "Zajmuje stanowisko lub mechanika, dostaje numer zlecenia i pojawia się na tablicy zleceń."
+    },
+    "when": "Kiedy",
+    "start": "Początek",
+    "end": "Koniec",
+    "durationHours": "{count, plural, one {# godzina} few {# godziny} many {# godzin} other {# godziny}}",
+    "durationMinutes": "{count, plural, one {# minuta} few {# minuty} many {# minut} other {# minuty}}",
+    "endAfterStart": "Koniec musi być po początku i najwyżej tydzień później.",
+    "appointmentWhoHint": "Wybierz klienta, aby móc powiadomić go przed wizytą.",
+    "appointmentWhoHintMarine": "Wybierz klienta, aby móc powiadomić go przed wizytą.",
+    "whoLocked": "Auto lub klienta zmienisz w zleceniu.",
+    "whoLockedMarine": "Łódź lub klienta zmienisz w zleceniu.",
+    "workHintTitle": "Jedna rzecz w wierszu. Każdy wiersz to osobne zgłoszenie w zleceniu. Jeśli zostawisz puste, użyty zostanie tytuł.",
+    "workLocked": "Do tych zgłoszeń zapisano już pracę. Zmień je w zleceniu.",
+    "concernLabel": "Zgłoszenie {number}",
+    "removeConcern": "Usuń zgłoszenie",
+    "addConcern": "Wpisz zgłoszenie i naciśnij Enter",
+    "addConcernMarine": "Wpisz zgłoszenie i naciśnij Enter",
+    "workEmptyHint": "Jeśli zostawisz puste, tytuł będzie zgłoszeniem zlecenia.",
+    "endsAnotherDay": "Kończy się innego dnia",
+    "video": "Rozmowa wideo",
+    "addMeeting": "Dodaj spotkanie {name}",
+    "meetingAdded": "Spotkanie {name} zostanie dodane po zapisaniu.",
+    "removeMeeting": "Nie dodawaj spotkania",
+    "joinMeeting": "Dołącz do rozmowy wideo",
+    "meetingFailed": "Rezerwacja została zapisana, ale nie udało się dodać spotkania. {error}",
+    "notifications": "Powiadomienia",
+    "addNotification": "Dodaj powiadomienie",
+    "removeNotification": "Usuń powiadomienie",
+    "channelLabel": "Jak",
+    "amountLabel": "Ile",
+    "unitLabel": "Minuty, godziny, dni lub tygodnie przed",
+    "at": "o",
+    "atLabel": "Godzina",
+    "channel": {
+      "toWorkshop": "Do warsztatu",
+      "toCustomer": "Do klienta",
+      "inApp": "W aplikacji",
+      "email": "E-mail do mnie",
+      "customerSms": "SMS do klienta",
+      "customerEmail": "E-mail do klienta"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuta przed} few {minuty przed} many {minut przed} other {minuty przed}}",
+      "hours": "{count, plural, one {godzina przed} few {godziny przed} many {godzin przed} other {godziny przed}}",
+      "days": "{count, plural, one {dzień przed} few {dni przed} many {dni przed} other {dnia przed}}",
+      "weeks": "{count, plural, one {tydzień przed} few {tygodnie przed} many {tygodni przed} other {tygodnia przed}}"
+    },
+    "customerChannelNeedsCustomer": "Wybierz klienta, aby go powiadomić.",
+    "descriptionPlaceholder": "Dodaj opis",
+    "convertSubmit": "Utwórz zlecenie",
+    "saved": "Rezerwacja zapisana"
+  },
+  "notify": {
+    "inAppTitle": "Wkrótce: {title}",
+    "emailSubject": "Wkrótce: {title}, {when}",
+    "emailFooter": "To powiadomienie ustawiono w kalendarzu warsztatu w Torqvoice.",
+    "customerSubject": "Twoja wizyta w warsztacie {workshop}",
+    "customerBody": "Cześć {name}, przypominamy o Twojej wizycie w warsztacie {workshop}: {when}. Do zobaczenia!"
   }
 }

+ 6 - 0
messages/pl/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Pokaż karty",
   "cards": {
     "maintenance": "Przewidywana konserwacja",
+    "calendarEvents": "Wydarzenia w kalendarzu",
     "reminders": "Nadchodzące przypomnienia",
     "inspections": "Przeglądy",
     "serviceRequests": "Zgłoszenia serwisowe",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Odrzuć",
     "dismissError": "Nie udało się odrzucić pojazdu. Spróbuj ponownie."
   },
+  "calendarEvents": {
+    "title": "Wydarzenia w kalendarzu",
+    "description": "{days, plural, one {Najbliższy # dzień} few {Najbliższe # dni} many {Najbliższe # dni} other {Najbliższe # dnia}}",
+    "noData": "Brak wydarzeń w kalendarzu na {days, plural, one {najbliższy # dzień} few {najbliższe # dni} many {najbliższe # dni} other {najbliższe # dnia}}."
+  },
   "reminders": {
     "title": "Nadchodzące przypomnienia",
     "overdue": "Zaległe",

+ 1 - 1
messages/pl/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Klienci",
     "optCustomersDesc": "Rekordy klientów i dane kontaktowe",
     "optVehicles": "Pojazdy i rekordy serwisowe",
-    "optVehiclesDesc": "Pojazdy, notatki, dzienniki paliwa, przypomnienia, rekordy serwisowe",
+    "optVehiclesDesc": "Pojazdy, notatki, dzienniki paliwa, przypomnienia, rekordy serwisowe, wizyty",
     "optQuotes": "Wyceny",
     "optQuotesDesc": "Wyceny z czesciami i robocizna",
     "optInventory": "Inwentarz",

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

@@ -70,6 +70,9 @@
     "note_delete": "Nota excluída",
     "reminder_create": "Lembrete criado",
     "reminder_delete": "Lembrete excluído",
+    "appointment_create": "Agendamento criado",
+    "appointment_update": "Agendamento atualizado",
+    "appointment_delete": "Agendamento excluído",
     "reminder_import": "Lembretes importados",
     "organization_create": "Organização criada",
     "subscription_cancel": "Assinatura cancelada",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Agendamento {ref} alterado",
+    "appointment_create": "Agendamento {title} adicionado",
+    "appointment_update": "Agendamento {title} alterado",
+    "appointment_delete": "Agendamento {title} excluído",
     "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",

+ 88 - 8
messages/pt-BR/calendar.json

@@ -70,8 +70,11 @@
       "message": "Abrir mensagens",
       "external": "Abrir",
       "closure": "Abrir horário de funcionamento",
-      "absence": "Abrir o quadro de trabalho"
-    }
+      "absence": "Abrir o quadro de trabalho",
+      "appointment": "Abrir calendário"
+    },
+    "makeWorkOrder": "Criar ordem de serviço",
+    "notifications": "{count, plural, one {# notificação} other {# notificações}}"
   },
   "filters": {
     "services": "Ordens de serviço",
@@ -80,7 +83,8 @@
     "messages": "Mensagens",
     "external": "Calendários conectados",
     "closures": "Dias fechados",
-    "absences": "Ausências"
+    "absences": "Ausências",
+    "appointments": "Agendamentos"
   },
   "selectVehicle": "Selecionar veículo para ordem de serviço",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Excluir a mensagem agendada \"{title}\"? Ela não será enviada.",
     "deleted": "Excluído",
     "updated": "Atualizado",
-    "failed": "Não foi possível concluir. Tente novamente."
+    "failed": "Não foi possível concluir. Tente novamente.",
+    "edit": "Editar",
+    "makeWorkOrder": "Transformar em ordem de serviço",
+    "deleteAppointment": "Excluir agendamento",
+    "deleteAppointmentConfirm": "Excluir o agendamento \"{title}\"? As notificações dele não serão enviadas."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "Na fila",
       "busy": "Ocupado",
       "closed": "Fechado",
-      "away": "Ausente"
+      "away": "Ausente",
+      "booked": "Reservado"
     },
     "type": {
       "service": "Ordem de serviço",
@@ -140,7 +149,8 @@
       "message": "Mensagem",
       "external": "Calendário conectado",
       "closure": "Fechado",
-      "absence": "Ausência"
+      "absence": "Ausência",
+      "appointment": "Agendamento"
     },
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Descrição",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Nenhum veículo encontrado.",
     "noVehiclesMarine": "Nenhum barco encontrado.",
     "none": "Nenhum",
+    "optional": "Opcional",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Não foi possível agendar o horário",
+    "editTitle": "Editar agendamento",
+    "convertTitle": "Transformar em ordem de serviço",
+    "dialogDescription": "Coloque um agendamento ou uma ordem de serviço no calendário, com quem é e as notificações antes dele.",
+    "titleLabel": "Título",
+    "titlePlaceholder": "Adicionar título",
+    "kind": {
+      "appointment": "Agendamento",
+      "workOrder": "Ordem de serviço",
+      "appointmentHint": "Só no calendário. Transforme em ordem de serviço quando o carro chegar.",
+      "appointmentHintMarine": "Só no calendário. Transforme em ordem de serviço quando o barco chegar.",
+      "workOrderHint": "Reserva uma baia ou um técnico, recebe um número de ordem e aparece no quadro de trabalho."
+    },
+    "when": "Quando",
+    "start": "Início",
+    "end": "Fim",
+    "durationHours": "{count, plural, one {# hora} other {# horas}}",
+    "durationMinutes": "{count, plural, one {# minuto} other {# minutos}}",
+    "endAfterStart": "O fim precisa ser depois do início e, no máximo, uma semana depois.",
+    "appointmentWhoHint": "Escolha um cliente para poder avisá-lo antes do agendamento.",
+    "appointmentWhoHintMarine": "Escolha um cliente para poder avisá-lo antes do agendamento.",
+    "whoLocked": "Altere o carro ou o cliente na ordem de serviço.",
+    "whoLockedMarine": "Altere o barco ou o cliente na ordem de serviço.",
+    "workHintTitle": "Uma coisa por linha. Cada linha vira um relato no serviço. Se ficar vazio, vale o título.",
+    "workLocked": "Há trabalho registrado nestes relatos. Altere-os na ordem de serviço.",
+    "concernLabel": "Relato {number}",
+    "removeConcern": "Remover relato",
+    "addConcern": "Digite um relato e pressione Enter",
+    "addConcernMarine": "Digite um relato e pressione Enter",
+    "workEmptyHint": "Se ficar vazio, o título vira o relato do serviço.",
+    "endsAnotherDay": "Termina em outro dia",
+    "video": "Videochamada",
+    "addMeeting": "Adicionar reunião do {name}",
+    "meetingAdded": "Uma reunião do {name} será adicionada ao salvar.",
+    "removeMeeting": "Não adicionar a reunião",
+    "joinMeeting": "Entrar na videochamada",
+    "meetingFailed": "O agendamento foi salvo, mas não foi possível adicionar a reunião. {error}",
+    "notifications": "Notificações",
+    "addNotification": "Adicionar notificação",
+    "removeNotification": "Remover notificação",
+    "channelLabel": "Como",
+    "amountLabel": "Quantos",
+    "unitLabel": "Minutos, horas, dias ou semanas antes",
+    "at": "às",
+    "atLabel": "Horário",
+    "channel": {
+      "toWorkshop": "Para a oficina",
+      "toCustomer": "Para o cliente",
+      "inApp": "No app",
+      "email": "E-mail para mim",
+      "customerSms": "SMS para o cliente",
+      "customerEmail": "E-mail para o cliente"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuto antes} other {minutos antes}}",
+      "hours": "{count, plural, one {hora antes} other {horas antes}}",
+      "days": "{count, plural, one {dia antes} other {dias antes}}",
+      "weeks": "{count, plural, one {semana antes} other {semanas antes}}"
+    },
+    "customerChannelNeedsCustomer": "Escolha um cliente para notificá-lo.",
+    "descriptionPlaceholder": "Adicionar descrição",
+    "convertSubmit": "Criar ordem de serviço",
+    "saved": "Agendamento salvo"
+  },
+  "notify": {
+    "inAppTitle": "Em breve: {title}",
+    "emailSubject": "Em breve: {title}, {when}",
+    "emailFooter": "Você configurou esta notificação no calendário da oficina no Torqvoice.",
+    "customerSubject": "Seu horário em {workshop}",
+    "customerBody": "Olá, {name}! Passando para lembrar do seu horário em {workshop}: {when}. Até lá!"
   }
 }

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

@@ -45,6 +45,7 @@
   "showCards": "Mostrar cartões",
   "cards": {
     "maintenance": "Manutenção prevista",
+    "calendarEvents": "Eventos do Calendário",
     "reminders": "Lembretes Próximos",
     "inspections": "Inspeções",
     "serviceRequests": "Solicitações de serviço",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Dispensar",
     "dismissError": "Não foi possível dispensar o veículo. Tente novamente."
   },
+  "calendarEvents": {
+    "title": "Eventos do Calendário",
+    "description": "Próximos {days} dias",
+    "noData": "Nada no calendário nos próximos {days} dias."
+  },
   "reminders": {
     "title": "Lembretes Próximos",
     "overdue": "Atrasado",

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

@@ -1388,7 +1388,7 @@
     "optCustomers": "Clientes",
     "optCustomersDesc": "Registros de clientes e informações de contato",
     "optVehicles": "Veículos e registros de serviço",
-    "optVehiclesDesc": "Veículos, notas, registros de combustível, lembretes, registros de serviço",
+    "optVehiclesDesc": "Veículos, notas, registros de combustível, lembretes, registros de serviço, agendamentos",
     "optQuotes": "Orçamentos",
     "optQuotesDesc": "Orçamentos com peças e mão de obra",
     "optInventory": "Inventário",

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

@@ -70,6 +70,9 @@
     "note_delete": "Nota eliminada",
     "reminder_create": "Lembrete criado",
     "reminder_delete": "Lembrete eliminado",
+    "appointment_create": "Marcação criada",
+    "appointment_update": "Marcação atualizada",
+    "appointment_delete": "Marcação eliminada",
     "reminder_import": "Lembretes importados",
     "organization_create": "Organização criada",
     "subscription_cancel": "Subscrição cancelada",
@@ -273,6 +276,10 @@
     "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_editAppointment": "Marcação {ref} alterada",
+    "appointment_create": "Marcação {title} adicionada",
+    "appointment_update": "Marcação {title} alterada",
+    "appointment_delete": "Marcação {title} eliminada",
     "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",

+ 88 - 8
messages/pt-PT/calendar.json

@@ -70,8 +70,11 @@
       "message": "Abrir mensagens",
       "external": "Abrir",
       "closure": "Abrir horário de funcionamento",
-      "absence": "Abrir o quadro de trabalho"
-    }
+      "absence": "Abrir o quadro de trabalho",
+      "appointment": "Abrir calendário"
+    },
+    "makeWorkOrder": "Criar ordem de serviço",
+    "notifications": "{count, plural, one {# notificação} other {# notificações}}"
   },
   "filters": {
     "services": "Ordens de serviço",
@@ -80,7 +83,8 @@
     "messages": "Mensagens",
     "external": "Calendários ligados",
     "closures": "Dias de encerramento",
-    "absences": "Ausências"
+    "absences": "Ausências",
+    "appointments": "Marcações"
   },
   "selectVehicle": "Selecionar viatura para a ordem de serviço",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Eliminar a mensagem agendada \"{title}\"? A mensagem não será enviada.",
     "deleted": "Eliminado",
     "updated": "Atualizado",
-    "failed": "Não foi possível concluir. Tente novamente."
+    "failed": "Não foi possível concluir. Tente novamente.",
+    "edit": "Editar",
+    "makeWorkOrder": "Converter em ordem de serviço",
+    "deleteAppointment": "Eliminar marcação",
+    "deleteAppointmentConfirm": "Eliminar a marcação \"{title}\"? As respetivas notificações não serão enviadas."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "Em fila",
       "busy": "Ocupado",
       "closed": "Encerrado",
-      "away": "Ausente"
+      "away": "Ausente",
+      "booked": "Marcado"
     },
     "type": {
       "service": "Ordem de serviço",
@@ -140,7 +149,8 @@
       "message": "Mensagem",
       "external": "Calendário ligado",
       "closure": "Encerrado",
-      "absence": "Ausência"
+      "absence": "Ausência",
+      "appointment": "Marcação"
     },
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Descrição",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Nenhuma viatura encontrada.",
     "noVehiclesMarine": "Nenhum barco encontrado.",
     "none": "Nenhum",
+    "optional": "Opcional",
     "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",
@@ -190,6 +201,75 @@
     "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"
+    "failed": "Não foi possível fazer a marcação",
+    "editTitle": "Editar marcação",
+    "convertTitle": "Converter em ordem de serviço",
+    "dialogDescription": "Coloque uma marcação ou uma ordem de serviço no calendário, com quem é e as notificações que a antecedem.",
+    "titleLabel": "Título",
+    "titlePlaceholder": "Adicionar título",
+    "kind": {
+      "appointment": "Marcação",
+      "workOrder": "Ordem de serviço",
+      "appointmentHint": "Apenas no calendário. Converta-a em ordem de serviço quando a viatura chegar.",
+      "appointmentHintMarine": "Apenas no calendário. Converta-a em ordem de serviço quando o barco chegar.",
+      "workOrderHint": "Ocupa um posto de trabalho ou um técnico, recebe um número de ordem e aparece no quadro de trabalho."
+    },
+    "when": "Quando",
+    "start": "Início",
+    "end": "Fim",
+    "durationHours": "{count, plural, one {# hora} other {# horas}}",
+    "durationMinutes": "{count, plural, one {# minuto} other {# minutos}}",
+    "endAfterStart": "O fim tem de ser depois do início e, no máximo, uma semana depois.",
+    "appointmentWhoHint": "Escolha um cliente para o poder avisar antes da marcação.",
+    "appointmentWhoHintMarine": "Escolha um cliente para o poder avisar antes da marcação.",
+    "whoLocked": "Altere a viatura ou o cliente na ordem de serviço.",
+    "whoLockedMarine": "Altere o barco ou o cliente na ordem de serviço.",
+    "workHintTitle": "Uma coisa por linha. Cada linha passa a ser uma queixa no trabalho. Se ficar vazio, é usado o título.",
+    "workLocked": "Há trabalho registado nestas queixas. Altere-as na ordem de serviço.",
+    "concernLabel": "Queixa {number}",
+    "removeConcern": "Remover queixa",
+    "addConcern": "Escreva uma queixa e prima Enter",
+    "addConcernMarine": "Escreva uma queixa e prima Enter",
+    "workEmptyHint": "Se ficar vazio, o título passa a ser a queixa do trabalho.",
+    "endsAnotherDay": "Termina noutro dia",
+    "video": "Videochamada",
+    "addMeeting": "Adicionar reunião do {name}",
+    "meetingAdded": "É adicionada uma reunião do {name} ao guardar.",
+    "removeMeeting": "Não adicionar a reunião",
+    "joinMeeting": "Entrar na videochamada",
+    "meetingFailed": "A marcação foi guardada, mas não foi possível adicionar a reunião. {error}",
+    "notifications": "Notificações",
+    "addNotification": "Adicionar notificação",
+    "removeNotification": "Remover notificação",
+    "channelLabel": "Como",
+    "amountLabel": "Quantos",
+    "unitLabel": "Minutos, horas, dias ou semanas antes",
+    "at": "às",
+    "atLabel": "Hora",
+    "channel": {
+      "toWorkshop": "Para a oficina",
+      "toCustomer": "Para o cliente",
+      "inApp": "Na aplicação",
+      "email": "E-mail para mim",
+      "customerSms": "SMS para o cliente",
+      "customerEmail": "E-mail para o cliente"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {minuto antes} other {minutos antes}}",
+      "hours": "{count, plural, one {hora antes} other {horas antes}}",
+      "days": "{count, plural, one {dia antes} other {dias antes}}",
+      "weeks": "{count, plural, one {semana antes} other {semanas antes}}"
+    },
+    "customerChannelNeedsCustomer": "Escolha um cliente para o notificar.",
+    "descriptionPlaceholder": "Adicionar descrição",
+    "convertSubmit": "Criar ordem de serviço",
+    "saved": "Marcação guardada"
+  },
+  "notify": {
+    "inAppTitle": "Em breve: {title}",
+    "emailSubject": "Em breve: {title}, {when}",
+    "emailFooter": "Configurou esta notificação no calendário da oficina no Torqvoice.",
+    "customerSubject": "A sua marcação em {workshop}",
+    "customerBody": "Olá, {name}! Lembramos a sua marcação em {workshop}: {when}. Até breve!"
   }
 }

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

@@ -45,6 +45,7 @@
   "showCards": "Mostrar cartões",
   "cards": {
     "maintenance": "Manutenção prevista",
+    "calendarEvents": "Eventos do Calendário",
     "reminders": "Próximos Lembretes",
     "inspections": "Inspeções",
     "serviceRequests": "Pedidos de serviço",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Dispensar",
     "dismissError": "Não foi possível dispensar a viatura. Tente novamente."
   },
+  "calendarEvents": {
+    "title": "Eventos do Calendário",
+    "description": "Próximos {days} dias",
+    "noData": "Nada no calendário nos próximos {days} dias."
+  },
   "reminders": {
     "title": "Próximos Lembretes",
     "overdue": "Em atraso",

+ 1 - 1
messages/pt-PT/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Clientes",
     "optCustomersDesc": "Registos de clientes e informações de contacto",
     "optVehicles": "Viaturas e registos de serviço",
-    "optVehiclesDesc": "Viaturas, notas, registos de combustível, lembretes, registos de serviço",
+    "optVehiclesDesc": "Viaturas, notas, registos de combustível, lembretes, registos de serviço, marcações",
     "optQuotes": "Orçamentos",
     "optQuotesDesc": "Orçamentos com peças e mão de obra",
     "optInventory": "Stock",

+ 7 - 0
messages/ru/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Удалена заметка",
     "reminder_create": "Создано напоминание",
     "reminder_delete": "Удалено напоминание",
+    "appointment_create": "Создана запись",
+    "appointment_update": "Обновлена запись",
+    "appointment_delete": "Удалена запись",
     "reminder_import": "Импортированы напоминания",
     "organization_create": "Создана организация",
     "subscription_cancel": "Отменена подписка",
@@ -273,6 +276,10 @@
     "scheduled_message_update": "Обновлено запланированное {channel, select, email {письмо} sms {SMS} telegram {сообщение в Telegram} in_app {уведомление в приложении} other {{channel}}}",
     "service_create": "Создан заказ {ref}",
     "service_bookAppointment": "Создана запись {ref}",
+    "service_editAppointment": "Изменена запись {ref}",
+    "appointment_create": "Добавлена запись {title}",
+    "appointment_update": "Изменена запись {title}",
+    "appointment_delete": "Удалена запись {title}",
     "service_createCounterSale": "Создана продажа со стойки {ref}",
     "service_createDraft": "Создан черновик заказа {ref}",
     "service_createFromInspection": "Создан наряд-заказ {ref} по итогам проверки",

+ 88 - 8
messages/ru/calendar.json

@@ -70,8 +70,11 @@
       "message": "Открыть сообщения",
       "external": "Открыть",
       "closure": "Открыть часы работы",
-      "absence": "Открыть доску работ"
-    }
+      "absence": "Открыть доску работ",
+      "appointment": "Открыть календарь"
+    },
+    "makeWorkOrder": "Создать заказ-наряд",
+    "notifications": "{count, plural, one {# уведомление} few {# уведомления} many {# уведомлений} other {# уведомления}}"
   },
   "filters": {
     "services": "Заказ-наряды",
@@ -80,7 +83,8 @@
     "messages": "Сообщения",
     "external": "Подключённые календари",
     "closures": "Нерабочие дни",
-    "absences": "Отсутствия"
+    "absences": "Отсутствия",
+    "appointments": "Записи"
   },
   "selectVehicle": "Выберите транспортное средство для заказ-наряда",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "Удалить запланированное сообщение «{title}»? Оно не будет отправлено.",
     "deleted": "Удалено",
     "updated": "Обновлено",
-    "failed": "Не получилось. Попробуйте ещё раз."
+    "failed": "Не получилось. Попробуйте ещё раз.",
+    "edit": "Редактировать",
+    "makeWorkOrder": "Оформить заказ-наряд",
+    "deleteAppointment": "Удалить запись",
+    "deleteAppointmentConfirm": "Удалить запись «{title}»? Уведомления по ней не будут отправлены."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "В очереди",
       "busy": "Занято",
       "closed": "Закрыто",
-      "away": "Отсутствует"
+      "away": "Отсутствует",
+      "booked": "Забронировано"
     },
     "type": {
       "service": "Заказ-наряд",
@@ -140,7 +149,8 @@
       "message": "Сообщение",
       "external": "Подключённый календарь",
       "closure": "Закрыто",
-      "absence": "Отсутствие"
+      "absence": "Отсутствие",
+      "appointment": "Запись"
     },
     "noEvents": "Нет событий",
     "selectDay": "Выберите день с событиями или создайте новый заказ-наряд.",
@@ -163,7 +173,7 @@
   "timeOffMissing": "Это отсутствие тем временем изменили или удалили.",
   "appointment": {
     "title": "Создать запись",
-    "description": "Внесите автомобиль или клиента в календарь вместе с тем, что нужно сделать, и примерной продолжительностью. Заполнять заказ-наряд сейчас не нужно: он откроется, когда автомобиль прибудет.",
+    "description": "Описание",
     "descriptionMarine": "Внесите лодку или клиента в календарь вместе с тем, что нужно сделать, и примерной продолжительностью. Заполнять заказ-наряд сейчас не нужно: он откроется, когда лодка прибудет.",
     "customer": "Клиент",
     "selectCustomer": "Выберите клиента",
@@ -178,6 +188,7 @@
     "noVehicles": "Транспортные средства не найдены.",
     "noVehiclesMarine": "Лодки не найдены.",
     "none": "Нет",
+    "optional": "Необязательно",
     "whoHint": "Клиент, автомобиль или и то, и другое. Автомобиль, которого ещё нет в базе, можно добавить в заказ-наряд, когда он прибудет.",
     "whoHintMarine": "Клиент, лодка или и то, и другое. Лодку, которой ещё нет в базе, можно добавить в заказ-наряд, когда она прибудет.",
     "work": "Необходимые работы",
@@ -190,6 +201,75 @@
     "submit": "Создать запись",
     "booked": "Запись создана",
     "bookedNobodyFree": "Запись создана, но на это время нет ни свободного поста, ни свободного механика. Проверьте доску работ.",
-    "failed": "Не удалось создать запись"
+    "failed": "Не удалось создать запись",
+    "editTitle": "Редактировать запись",
+    "convertTitle": "Оформить заказ-наряд",
+    "dialogDescription": "Внесите запись или заказ-наряд в календарь: с кем она и какие уведомления придут заранее.",
+    "titleLabel": "Название",
+    "titlePlaceholder": "Добавьте название",
+    "kind": {
+      "appointment": "Запись",
+      "workOrder": "Заказ-наряд",
+      "appointmentHint": "Только в календаре. Оформите заказ-наряд, когда автомобиль прибудет.",
+      "appointmentHintMarine": "Только в календаре. Оформите заказ-наряд, когда лодка прибудет.",
+      "workOrderHint": "Занимает пост или механика, получает номер заказа и отображается на доске работ."
+    },
+    "when": "Когда",
+    "start": "Начало",
+    "end": "Конец",
+    "durationHours": "{count, plural, one {# час} few {# часа} many {# часов} other {# часа}}",
+    "durationMinutes": "{count, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}",
+    "endAfterStart": "Конец должен быть позже начала, но не более чем через неделю.",
+    "appointmentWhoHint": "Выберите клиента, чтобы напомнить ему о записи заранее.",
+    "appointmentWhoHintMarine": "Выберите клиента, чтобы напомнить ему о записи заранее.",
+    "whoLocked": "Автомобиль или клиента можно изменить в заказ-наряде.",
+    "whoLockedMarine": "Лодку или клиента можно изменить в заказ-наряде.",
+    "workHintTitle": "По одной задаче на строку. Каждая строка становится обращением в заказ-наряде. Если оставить пустым, используется название.",
+    "workLocked": "По этим обращениям уже записана работа. Измените их в заказ-наряде.",
+    "concernLabel": "Обращение {number}",
+    "removeConcern": "Удалить обращение",
+    "addConcern": "Введите обращение и нажмите Enter",
+    "addConcernMarine": "Введите обращение и нажмите Enter",
+    "workEmptyHint": "Если оставить пустым, обращением станет название.",
+    "endsAnotherDay": "Заканчивается в другой день",
+    "video": "Видеозвонок",
+    "addMeeting": "Добавить встречу {name}",
+    "meetingAdded": "При сохранении будет добавлена встреча {name}.",
+    "removeMeeting": "Не добавлять встречу",
+    "joinMeeting": "Присоединиться к видеозвонку",
+    "meetingFailed": "Запись сохранена, но встречу добавить не удалось. {error}",
+    "notifications": "Уведомления",
+    "addNotification": "Добавить уведомление",
+    "removeNotification": "Удалить уведомление",
+    "channelLabel": "Как",
+    "amountLabel": "Сколько",
+    "unitLabel": "Минуты, часы, дни или недели до начала",
+    "at": "в",
+    "atLabel": "Время",
+    "channel": {
+      "toWorkshop": "Мастерской",
+      "toCustomer": "Клиенту",
+      "inApp": "В приложении",
+      "email": "Письмо мне",
+      "customerSms": "SMS клиенту",
+      "customerEmail": "Письмо клиенту"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {минута до начала} few {минуты до начала} many {минут до начала} other {минуты до начала}}",
+      "hours": "{count, plural, one {час до начала} few {часа до начала} many {часов до начала} other {часа до начала}}",
+      "days": "{count, plural, one {день до начала} few {дня до начала} many {дней до начала} other {дня до начала}}",
+      "weeks": "{count, plural, one {неделя до начала} few {недели до начала} many {недель до начала} other {недели до начала}}"
+    },
+    "customerChannelNeedsCustomer": "Выберите клиента, чтобы уведомить его.",
+    "descriptionPlaceholder": "Добавьте описание",
+    "convertSubmit": "Создать заказ-наряд",
+    "saved": "Запись сохранена"
+  },
+  "notify": {
+    "inAppTitle": "Скоро: {title}",
+    "emailSubject": "Скоро: {title}, {when}",
+    "emailFooter": "Вы настроили это уведомление в календаре мастерской в Torqvoice.",
+    "customerSubject": "Ваша запись в {workshop}",
+    "customerBody": "Здравствуйте, {name}! Напоминаем о вашей записи в {workshop} на {when}. Ждём вас!"
   }
 }

+ 6 - 0
messages/ru/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Показать карточки",
   "cards": {
     "maintenance": "Плановое ТО",
+    "calendarEvents": "События календаря",
     "reminders": "Предстоящие напоминания",
     "inspections": "Осмотры",
     "serviceRequests": "Заявки на обслуживание",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Отклонить",
     "dismissError": "Не удалось отклонить транспортное средство. Попробуйте ещё раз."
   },
+  "calendarEvents": {
+    "title": "События календаря",
+    "description": "{days, plural, one {Ближайший # день} few {Ближайшие # дня} many {Ближайшие # дней} other {Ближайшие # дня}}",
+    "noData": "В календаре ничего нет на {days, plural, one {ближайший # день} few {ближайшие # дня} many {ближайшие # дней} other {ближайшие # дня}}."
+  },
   "reminders": {
     "title": "Предстоящие напоминания",
     "overdue": "Просрочено",

+ 1 - 1
messages/ru/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Клиенты",
     "optCustomersDesc": "Записи клиентов и контактная информация",
     "optVehicles": "Транспортные средства и записи обслуживания",
-    "optVehiclesDesc": "Транспортные средства, заметки, журналы топлива, напоминания, записи обслуживания",
+    "optVehiclesDesc": "Транспортные средства, заметки, журналы топлива, напоминания, записи обслуживания, записи в календаре",
     "optQuotes": "Предложения",
     "optQuotesDesc": "Предложения с запчастями и работами",
     "optInventory": "Склад",

+ 7 - 0
messages/tr/audit.json

@@ -70,6 +70,9 @@
     "note_delete": "Not silindi",
     "reminder_create": "Hatırlatma oluşturuldu",
     "reminder_delete": "Hatırlatma silindi",
+    "appointment_create": "Randevu oluşturuldu",
+    "appointment_update": "Randevu güncellendi",
+    "appointment_delete": "Randevu silindi",
     "reminder_import": "Hatırlatmalar içe aktarıldı",
     "organization_create": "Organizasyon oluşturuldu",
     "subscription_cancel": "Abonelik iptal edildi",
@@ -273,6 +276,10 @@
     "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_editAppointment": "{ref} randevusu değiştirildi",
+    "appointment_create": "{title} randevusu eklendi",
+    "appointment_update": "{title} randevusu değiştirildi",
+    "appointment_delete": "{title} randevusu silindi",
     "service_createCounterSale": "{ref} tezgah satışı oluşturuldu",
     "service_createDraft": "{ref} taslak iş kaydı oluşturuldu",
     "service_createFromInspection": "Bir muayeneden {ref} iş emri oluşturuldu",

+ 88 - 8
messages/tr/calendar.json

@@ -70,8 +70,11 @@
       "message": "Mesajları aç",
       "external": "Aç",
       "closure": "Çalışma saatlerini aç",
-      "absence": "İş panosunu aç"
-    }
+      "absence": "İş panosunu aç",
+      "appointment": "Takvimi aç"
+    },
+    "makeWorkOrder": "İş emri oluştur",
+    "notifications": "{count, plural, one {# bildirim} other {# bildirim}}"
   },
   "filters": {
     "services": "İş emirleri",
@@ -80,7 +83,8 @@
     "messages": "Mesajlar",
     "external": "Bağlı takvimler",
     "closures": "Kapalı günler",
-    "absences": "İzinler"
+    "absences": "İzinler",
+    "appointments": "Randevular"
   },
   "selectVehicle": "İş emri için araç seçin",
   "dateChoice": {
@@ -112,7 +116,11 @@
     "deleteMessageConfirm": "\"{title}\" planlanmış mesajı silinsin mi? Gönderilmeyecek.",
     "deleted": "Silindi",
     "updated": "Güncellendi",
-    "failed": "Olmadı. Tekrar deneyin."
+    "failed": "Olmadı. Tekrar deneyin.",
+    "edit": "Düzenle",
+    "makeWorkOrder": "İş emrine dönüştür",
+    "deleteAppointment": "Randevuyu sil",
+    "deleteAppointmentConfirm": "\"{title}\" randevusu silinsin mi? Bildirimleri gönderilmeyecek."
   },
   "events": {
     "status": {
@@ -131,7 +139,8 @@
       "queued": "Sırada",
       "busy": "Meşgul",
       "closed": "Kapalı",
-      "away": "Müsait değil"
+      "away": "Müsait değil",
+      "booked": "Rezerve edildi"
     },
     "type": {
       "service": "İş emri",
@@ -140,7 +149,8 @@
       "message": "Mesaj",
       "external": "Bağlı takvim",
       "closure": "Kapalı",
-      "absence": "İzin"
+      "absence": "İzin",
+      "appointment": "Randevu"
     },
     "noEvents": "Etkinlik yok",
     "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun.",
@@ -163,7 +173,7 @@
   "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.",
+    "description": "Açıklama",
     "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",
@@ -178,6 +188,7 @@
     "noVehicles": "Araç bulunamadı.",
     "noVehiclesMarine": "Tekne bulunamadı.",
     "none": "Yok",
+    "optional": "İsteğe bağlı",
     "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ş",
@@ -190,6 +201,75 @@
     "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ı"
+    "failed": "Randevu oluşturulamadı",
+    "editTitle": "Randevuyu düzenle",
+    "convertTitle": "İş emrine dönüştür",
+    "dialogDescription": "Takvime bir randevu veya iş emri ekleyin; kiminle olduğunu ve öncesindeki bildirimleri belirleyin.",
+    "titleLabel": "Başlık",
+    "titlePlaceholder": "Başlık ekleyin",
+    "kind": {
+      "appointment": "Randevu",
+      "workOrder": "İş emri",
+      "appointmentHint": "Yalnızca takvimde. Araç geldiğinde iş emrine dönüştürün.",
+      "appointmentHintMarine": "Yalnızca takvimde. Tekne geldiğinde iş emrine dönüştürün.",
+      "workOrderHint": "Bir çalışma alanını veya teknisyeni ayırır, iş numarası alır ve iş panosunda görünür."
+    },
+    "when": "Ne zaman",
+    "start": "Başlangıç",
+    "end": "Bitiş",
+    "durationHours": "{count, plural, one {# saat} other {# saat}}",
+    "durationMinutes": "{count, plural, one {# dakika} other {# dakika}}",
+    "endAfterStart": "Bitiş, başlangıçtan sonra ve en fazla bir hafta sonra olmalıdır.",
+    "appointmentWhoHint": "Randevudan önce haber verebilmek için bir müşteri seçin.",
+    "appointmentWhoHintMarine": "Randevudan önce haber verebilmek için bir müşteri seçin.",
+    "whoLocked": "Aracı veya müşteriyi iş emrinde değiştirin.",
+    "whoLockedMarine": "Tekneyi veya müşteriyi iş emrinde değiştirin.",
+    "workHintTitle": "Her satıra bir iş. Her satır işte ayrı bir madde olur. Boş bırakılırsa başlık kullanılır.",
+    "workLocked": "Bu maddelere iş kaydedildi. Bunları iş emrinde değiştirin.",
+    "concernLabel": "Madde {number}",
+    "removeConcern": "Maddeyi kaldır",
+    "addConcern": "Bir madde yazıp Enter'a basın",
+    "addConcernMarine": "Bir madde yazıp Enter'a basın",
+    "workEmptyHint": "Boş bırakılırsa işin maddesi başlık olur.",
+    "endsAnotherDay": "Başka bir gün biter",
+    "video": "Görüntülü görüşme",
+    "addMeeting": "{name} toplantısı ekle",
+    "meetingAdded": "Kaydettiğinizde bir {name} toplantısı eklenir.",
+    "removeMeeting": "Toplantıyı ekleme",
+    "joinMeeting": "Görüntülü görüşmeye katıl",
+    "meetingFailed": "Randevu kaydedildi, ancak toplantı eklenemedi. {error}",
+    "notifications": "Bildirimler",
+    "addNotification": "Bildirim ekle",
+    "removeNotification": "Bildirimi kaldır",
+    "channelLabel": "Nasıl",
+    "amountLabel": "Kaç",
+    "unitLabel": "Dakika, saat, gün veya hafta önce",
+    "at": "saat",
+    "atLabel": "Saat",
+    "channel": {
+      "toWorkshop": "Atölyeye",
+      "toCustomer": "Müşteriye",
+      "inApp": "Uygulama içi",
+      "email": "Bana e-posta",
+      "customerSms": "Müşteriye SMS",
+      "customerEmail": "Müşteriye e-posta"
+    },
+    "unit": {
+      "minutes": "{count, plural, one {dakika önce} other {dakika önce}}",
+      "hours": "{count, plural, one {saat önce} other {saat önce}}",
+      "days": "{count, plural, one {gün önce} other {gün önce}}",
+      "weeks": "{count, plural, one {hafta önce} other {hafta önce}}"
+    },
+    "customerChannelNeedsCustomer": "Bildirim göndermek için bir müşteri seçin.",
+    "descriptionPlaceholder": "Açıklama ekleyin",
+    "convertSubmit": "İş emri oluştur",
+    "saved": "Randevu kaydedildi"
+  },
+  "notify": {
+    "inAppTitle": "Yaklaşıyor: {title}",
+    "emailSubject": "Yaklaşıyor: {title}, {when}",
+    "emailFooter": "Bu bildirimi Torqvoice atölye takviminde ayarladınız.",
+    "customerSubject": "{workshop} ile randevunuz",
+    "customerBody": "Merhaba {name}, {workshop} ile olan randevunuzu hatırlatmak isteriz: {when}. Görüşmek üzere!"
   }
 }

+ 6 - 0
messages/tr/dashboard.json

@@ -45,6 +45,7 @@
   "showCards": "Kartları göster",
   "cards": {
     "maintenance": "Tahmini Bakım",
+    "calendarEvents": "Takvim Etkinlikleri",
     "reminders": "Yaklaşan Hatırlatmalar",
     "inspections": "Muayeneler",
     "serviceRequests": "Servis talepleri",
@@ -78,6 +79,11 @@
     "dismissAriaLabel": "Kapat",
     "dismissError": "Araç kapatılamadı. Tekrar deneyin."
   },
+  "calendarEvents": {
+    "title": "Takvim Etkinlikleri",
+    "description": "Önümüzdeki {days} gün",
+    "noData": "Önümüzdeki {days} gün içinde takvimde bir şey yok."
+  },
   "reminders": {
     "title": "Yaklaşan Hatırlatmalar",
     "overdue": "Gecikmiş",

+ 1 - 1
messages/tr/settings.json

@@ -1388,7 +1388,7 @@
     "optCustomers": "Müşteriler",
     "optCustomersDesc": "Müşteri kayıtları ve iletişim bilgileri",
     "optVehicles": "Araçlar & Servis Kayıtları",
-    "optVehiclesDesc": "Araçlar, notlar, yakıt kayıtları, hatırlatmalar, servis kayıtları",
+    "optVehiclesDesc": "Araçlar, notlar, yakıt kayıtları, hatırlatmalar, servis kayıtları, randevular",
     "optQuotes": "Teklifler",
     "optQuotesDesc": "Parça ve işçilik içeren teklifler",
     "optInventory": "Envanter",

+ 76 - 0
prisma/migrations/20261113154240_appointments/migration.sql

@@ -0,0 +1,76 @@
+-- CreateTable
+CREATE TABLE "appointments" (
+    "id" TEXT NOT NULL,
+    "title" TEXT NOT NULL,
+    "description" TEXT,
+    "startAt" TIMESTAMP(3) NOT NULL,
+    "endAt" TIMESTAMP(3) NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "customerId" TEXT,
+    "vehicleId" TEXT,
+    "createdById" TEXT,
+
+    CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "appointment_notifications" (
+    "id" TEXT NOT NULL,
+    "channel" TEXT NOT NULL,
+    "amount" INTEGER NOT NULL,
+    "unit" TEXT NOT NULL,
+    "atTime" TEXT,
+    "sentForStart" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "userId" TEXT,
+    "appointmentId" TEXT,
+    "serviceRecordId" TEXT,
+
+    CONSTRAINT "appointment_notifications_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "appointments_organizationId_startAt_idx" ON "appointments"("organizationId", "startAt");
+
+-- CreateIndex
+CREATE INDEX "appointments_customerId_idx" ON "appointments"("customerId");
+
+-- CreateIndex
+CREATE INDEX "appointments_vehicleId_idx" ON "appointments"("vehicleId");
+
+-- CreateIndex
+CREATE INDEX "appointment_notifications_organizationId_idx" ON "appointment_notifications"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "appointment_notifications_appointmentId_idx" ON "appointment_notifications"("appointmentId");
+
+-- CreateIndex
+CREATE INDEX "appointment_notifications_serviceRecordId_idx" ON "appointment_notifications"("serviceRecordId");
+
+-- AddForeignKey
+ALTER TABLE "appointments" ADD CONSTRAINT "appointments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointments" ADD CONSTRAINT "appointments_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointments" ADD CONSTRAINT "appointments_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointments" ADD CONSTRAINT "appointments_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointment_notifications" ADD CONSTRAINT "appointment_notifications_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointment_notifications" ADD CONSTRAINT "appointment_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointment_notifications" ADD CONSTRAINT "appointment_notifications_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "appointments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "appointment_notifications" ADD CONSTRAINT "appointment_notifications_serviceRecordId_fkey" FOREIGN KEY ("serviceRecordId") REFERENCES "service_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 73 - 0
prisma/schema/appointments.prisma

@@ -0,0 +1,73 @@
+/// An appointment on the workshop calendar with no job behind it: a customer
+/// coming in to talk, a test drive, a supplier's visit. Work on a car is booked
+/// as a scheduled job instead (ServiceRecord with status `scheduled`), which
+/// holds a bay and gets a number. An appointment can become such a job, and is
+/// removed when it does.
+model Appointment {
+  id          String   @id @default(cuid())
+  title       String
+  /// Rich text from the editor, cleaned when it is saved and again when shown.
+  description String?
+  startAt     DateTime
+  endAt       DateTime
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  /// Who it is with, when it is with anyone on file.
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+  vehicleId  String?
+  vehicle    Vehicle?  @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
+
+  createdById String?
+  createdBy   User?   @relation("AppointmentCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
+
+  notifications AppointmentNotification[]
+
+  @@index([organizationId, startAt])
+  @@index([customerId])
+  @@index([vehicleId])
+  @@map("appointments")
+}
+
+/// One alert before an appointment or a booked job, the way a calendar sets
+/// them: how it goes out and how long before. It goes out once for each start
+/// time, so moving the appointment, here or on the work board, arms it again.
+model AppointmentNotification {
+  id           String    @id @default(cuid())
+  /// in_app | email | customer_sms | customer_email
+  channel      String
+  /// How long before the start, in `unit`s.
+  amount       Int
+  /// minutes | hours | days | weeks
+  unit         String
+  /// HH:MM on that day for a notice given in days or weeks ("1 day before at
+  /// 16:00"), on the workshop's clock. Null for minutes and hours.
+  atTime       String?
+  /// The start it last went out for. Any other start means it has not gone
+  /// out for this one yet.
+  sentForStart DateTime?
+  createdAt    DateTime  @default(now())
+  updatedAt    DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  /// Who set it. An email to the workshop goes to them.
+  userId String?
+  user   User?   @relation("AppointmentNotificationUser", fields: [userId], references: [id], onDelete: SetNull)
+
+  /// What it is for: an appointment, or a booked job. Exactly one is set.
+  appointmentId   String?
+  appointment     Appointment?   @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
+  serviceRecordId String?
+  serviceRecord   ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  @@index([organizationId])
+  @@index([appointmentId])
+  @@index([serviceRecordId])
+  @@map("appointment_notifications")
+}

+ 31 - 29
prisma/schema/auth.prisma

@@ -33,35 +33,37 @@ model User {
   /// Null = default layout. Shape validated in dashboardLayoutActions.
   dashboardLayout     Json?
 
-  sessions               Session[]
-  devices                UserDevice[]
-  accounts               Account[]
-  pushDevices            PushDevice[]
-  appSetupCodes          TechnicianSetupCode[]
-  vehicles               Vehicle[]
-  customers              Customer[]
-  settings               AppSetting[]
-  inventoryParts         InventoryPart[]
-  quotes                 Quote[]
-  customFieldDefinitions CustomFieldDefinition[]
-  twoFactor              TwoFactor?
-  passkeys               Passkey[]
-  organizationMembers    OrganizationMember[]
-  dashboardWidgets       DashboardWidget[]
-  auditLogs              AuditLog[]
-  openedServiceRecords   ServiceRecord[]         @relation("ServiceRecordCreatedBy")
-  confirmedConcerns      ServiceConcern[]        @relation("ConcernConfirmedBy")
-  recordedConditionMarks ConditionMark[]         @relation("ConditionMarkRecordedBy")
-  resolvedConditionMarks ConditionMark[]         @relation("ConditionMarkResolvedBy")
-  technicians            Technician[]
-  stockMovements         StockMovement[]
-  tireWarehouses         TireWarehouse[]
-  tireSets               TireSet[]
-  tireSetAttachments     TireSetAttachment[]
-  tireMeasurements       TireMeasurement[]
-  tireMovements          TireMovement[]
-  tireTreatments         TireTreatment[]
-  importBatches          ImportBatch[]
+  sessions                 Session[]
+  devices                  UserDevice[]
+  accounts                 Account[]
+  pushDevices              PushDevice[]
+  appSetupCodes            TechnicianSetupCode[]
+  vehicles                 Vehicle[]
+  customers                Customer[]
+  settings                 AppSetting[]
+  inventoryParts           InventoryPart[]
+  quotes                   Quote[]
+  customFieldDefinitions   CustomFieldDefinition[]
+  twoFactor                TwoFactor?
+  passkeys                 Passkey[]
+  organizationMembers      OrganizationMember[]
+  dashboardWidgets         DashboardWidget[]
+  auditLogs                AuditLog[]
+  openedServiceRecords     ServiceRecord[]           @relation("ServiceRecordCreatedBy")
+  confirmedConcerns        ServiceConcern[]          @relation("ConcernConfirmedBy")
+  appointmentsCreated      Appointment[]             @relation("AppointmentCreatedBy")
+  appointmentNotifications AppointmentNotification[] @relation("AppointmentNotificationUser")
+  recordedConditionMarks   ConditionMark[]           @relation("ConditionMarkRecordedBy")
+  resolvedConditionMarks   ConditionMark[]           @relation("ConditionMarkResolvedBy")
+  technicians              Technician[]
+  stockMovements           StockMovement[]
+  tireWarehouses           TireWarehouse[]
+  tireSets                 TireSet[]
+  tireSetAttachments       TireSetAttachment[]
+  tireMeasurements         TireMeasurement[]
+  tireMovements            TireMovement[]
+  tireTreatments           TireTreatment[]
+  importBatches            ImportBatch[]
 
   @@map("users")
 }

+ 1 - 0
prisma/schema/customers.prisma

@@ -37,6 +37,7 @@ model Customer {
 
   serviceRecords ServiceRecord[]
   reminders      Reminder[]
+  appointments   Appointment[]
   tireSets       TireSet[]
   aiChats        AiChat[]
 

+ 2 - 0
prisma/schema/organization.prisma

@@ -47,6 +47,8 @@ model Organization {
   vehicles                    Vehicle[]
   serviceRecords              ServiceRecord[]
   reminders                   Reminder[]
+  appointments                Appointment[]
+  appointmentNotifications    AppointmentNotification[]
   customers                   Customer[]
   quotes                      Quote[]
   inventoryParts              InventoryPart[]

+ 12 - 10
prisma/schema/service-records.prisma

@@ -155,7 +155,7 @@ model ServiceRecord {
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
   technicianId String?
-  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
+  technician   Technician?           @relation(fields: [technicianId], references: [id], onDelete: SetNull)
   /// Everybody else working the job with that technician.
   helpers      ServiceRecordHelper[]
 
@@ -174,10 +174,12 @@ model ServiceRecord {
   stockMovements StockMovement[]
   timeEntries    TimeEntry[]
 
-  concerns           ServiceConcern[]
-  conditionMarks     ConditionMark[]
-  discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
-  resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
+  concerns                 ServiceConcern[]
+  /// Alerts before a booked job, set when it was booked or edited from the calendar.
+  appointmentNotifications AppointmentNotification[]
+  conditionMarks           ConditionMark[]
+  discoveredFindings       VehicleFinding[]          @relation("FindingDiscoveredIn")
+  resolvedFindings         VehicleFinding[]          @relation("FindingResolvedIn")
 
   importBatchId String?
   importBatch   ImportBatch? @relation(fields: [importBatchId], references: [id], onDelete: SetNull)
@@ -332,12 +334,12 @@ model ServicePart {
 }
 
 model ServiceLabor {
-  id          String @id @default(cuid())
+  id          String  @id @default(cuid())
   description String
-  hours       Float  @default(0)
-  rate        Float  @default(0)
-  total       Float  @default(0)
-  pricingType String @default("hourly")
+  hours       Float   @default(0)
+  rate        Float   @default(0)
+  total       Float   @default(0)
+  pricingType String  @default("hourly")
   /// Whether the document's tax is charged on this line. Starts as the
   /// workshop's answer for labor, or for the shop fee, and is then the line's own.
   taxable     Boolean @default(true)

+ 1 - 0
prisma/schema/vehicles.prisma

@@ -48,6 +48,7 @@ model Vehicle {
   notes             Note[]
   fuelLogs          FuelLog[]
   reminders         Reminder[]
+  appointments      Appointment[]
   quotes            Quote[]
   recurringInvoices RecurringInvoice[]
   inspections       Inspection[]

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

@@ -1,165 +0,0 @@
-/**
- * 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')
-  })
-})

+ 697 - 0
src/__tests__/features/calendar/booking-actions.test.ts

@@ -0,0 +1,697 @@
+/**
+ * Booking from the calendar dialog: an appointment is only on the calendar,
+ * a work order books the car in as a scheduled job that holds a bay. Both
+ * are read on the workshop's own clock, carry their notifications, and only
+ * ever touch this workshop's rows.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Hoisted so a test that reloads the modules (demo mode) still sees the same mocks.
+const session = vi.hoisted(() => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('@/lib/cached-session', () => session)
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+// A plain function, so resetting the mocks cannot blank the zone.
+vi.mock('@/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,
+}))
+const db = vi.hoisted(() => ({
+  // The job number query: nothing yet in the workshop's format.
+  $queryRaw: async () => [],
+  user: { findUnique: vi.fn() },
+  auditLog: { create: 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() },
+  serviceConcern: { deleteMany: vi.fn(), createMany: vi.fn() },
+  appointment: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
+  appointmentNotification: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
+  $transaction: vi.fn(),
+}))
+vi.mock('@/lib/db', () => ({ db }))
+
+import { createBooking } from '@/features/calendar/Actions/createBooking'
+import { updateBooking } from '@/features/calendar/Actions/updateBooking'
+import { deleteAppointment } from '@/features/calendar/Actions/deleteAppointment'
+import type { SaveBookingInput } from '@/features/calendar/Schema/bookingSchema'
+
+const ORG = 'org-1'
+const USER = 'user-1'
+
+function signInAs(
+  role: 'owner' | 'member' = 'owner',
+  permissions: { action: string; subject: string }[] = []
+) {
+  session.getCachedMembership.mockResolvedValue({
+    organizationId: ORG,
+    role,
+    roleId: role === 'member' ? 'role-1' : null,
+    customRole: role === 'member' ? { isAdmin: false, permissions } : null,
+  })
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.unstubAllEnvs()
+  session.getCachedSession.mockResolvedValue({ user: { id: USER, email: 'office@example.com' } })
+  signInAs('owner')
+  db.user.findUnique.mockResolvedValue({ isSuperAdmin: false, name: 'Kari' })
+  db.auditLog.create.mockResolvedValue({})
+  db.appSetting.findMany.mockResolvedValue([])
+  db.appSetting.findUnique.mockResolvedValue(null)
+  db.organization.findUnique.mockResolvedValue({ name: 'Test Shop' })
+  db.technician.findFirst.mockResolvedValue({ name: 'Tech' })
+  db.workBay.findFirst.mockResolvedValue({ id: 'bay' })
+  db.vehicle.findFirst.mockResolvedValue({
+    id: 'veh-1',
+    customerId: 'cust-1',
+    customer: { taxExempt: false },
+  })
+  db.customer.findFirst.mockResolvedValue({ id: 'cust-1', taxExempt: false })
+  // The newest numbered job, for the next job number.
+  db.serviceRecord.findFirst.mockResolvedValue({ invoiceNumber: '2026-1043' })
+  db.serviceRecord.create.mockResolvedValue({ id: 'job-1' })
+  db.serviceRecord.update.mockResolvedValue({
+    id: 'job-1',
+    vehicleId: 'veh-1',
+    invoiceNumber: '2026-1044',
+  })
+  db.appointment.create.mockResolvedValue({ id: 'appt-1', title: 'Call Kari', vehicleId: null })
+  db.appointment.update.mockResolvedValue({ id: 'appt-1', title: 'Call Kari', vehicleId: null })
+  db.appointment.delete.mockResolvedValue({})
+  db.appointmentNotification.findMany.mockResolvedValue([])
+  db.appointmentNotification.deleteMany.mockResolvedValue({ count: 0 })
+  db.appointmentNotification.createMany.mockResolvedValue({ count: 0 })
+  db.serviceConcern.deleteMany.mockResolvedValue({ count: 0 })
+  db.serviceConcern.createMany.mockResolvedValue({ count: 0 })
+  // The transaction hands the same client in, so its writes are the ones read below.
+  db.$transaction.mockImplementation(async (fn: (tx: typeof db) => unknown) => fn(db))
+  // A shop with nothing set up to hold, unless a test says otherwise.
+  bookingResources.mockResolvedValue([])
+  loadBookingContext.mockResolvedValue({ bookings: [], hours: {}, closuresEnabled: false })
+})
+
+/** Monday 16 November 2026, 09:30 to 10:30 in Oslo: 08:30 to 09:30 UTC. */
+function booking(overrides: Partial<SaveBookingInput> = {}): SaveBookingInput {
+  return {
+    kind: 'appointment',
+    title: 'Call Kari',
+    startDate: '2026-11-16',
+    startTime: '09:30',
+    endDate: '2026-11-16',
+    endTime: '10:30',
+    notifications: [],
+    ...overrides,
+  }
+}
+
+/** Something already on the calendar on Monday, 08:00 to 10:00 UTC. */
+function held(resource: { workBayId?: string; technicianId?: string }, id = 'other-job') {
+  return {
+    id,
+    kind: 'serviceRecord' as const,
+    label: 'Brakes',
+    start: new Date('2026-11-16T08:00:00Z'),
+    end: new Date('2026-11-16T10:00:00Z'),
+    ...resource,
+  }
+}
+
+const appointmentData = () => db.appointment.create.mock.calls[0][0].data
+const jobData = () => db.serviceRecord.create.mock.calls[0][0].data
+const jobUpdate = () => db.serviceRecord.update.mock.calls[0][0]
+const createdNotifications = () => db.appointmentNotification.createMany.mock.calls[0][0].data
+
+describe('createBooking: an appointment', () => {
+  it("puts it on the workshop's clock with its title, description, customer and notifications", async () => {
+    const result = await createBooking(
+      booking({
+        vehicleId: 'veh-1',
+        description: '<p>Bring the <strong>spare key</strong></p><script>alert(1)</script>',
+        notifications: [
+          { channel: 'in_app', amount: 1, unit: 'hours', atTime: null },
+          { channel: 'email', amount: 1, unit: 'days', atTime: '16:00' },
+        ],
+      })
+    )
+
+    expect(result.success).toBe(true)
+    expect(result.data).toMatchObject({ kind: 'appointment', id: 'appt-1', overbooked: false })
+    const data = appointmentData()
+    expect(data.organizationId).toBe(ORG)
+    expect(data.title).toBe('Call Kari')
+    // 09:30 in Oslo in November is 08:30 UTC, whatever the server's own zone.
+    expect(data.startAt).toEqual(new Date('2026-11-16T08:30:00Z'))
+    expect(data.endAt).toEqual(new Date('2026-11-16T09:30:00Z'))
+    expect(data.description).toContain('<p>Bring the <strong>spare key</strong></p>')
+    expect(data.description).not.toContain('<script')
+    // The car brings its owner.
+    expect(data.vehicleId).toBe('veh-1')
+    expect(data.customerId).toBe('cust-1')
+    expect(db.vehicle.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'veh-1', organizationId: ORG } })
+    )
+
+    expect(createdNotifications()).toEqual([
+      {
+        organizationId: ORG,
+        userId: USER,
+        appointmentId: 'appt-1',
+        channel: 'in_app',
+        amount: 1,
+        unit: 'hours',
+        atTime: null,
+      },
+      {
+        organizationId: ORG,
+        userId: USER,
+        appointmentId: 'appt-1',
+        channel: 'email',
+        amount: 1,
+        unit: 'days',
+        atTime: '16:00',
+      },
+    ])
+
+    // Only on the calendar: no job, and nothing held.
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+    expect(bookingResources).not.toHaveBeenCalled()
+    expect(loadBookingContext).not.toHaveBeenCalled()
+  })
+
+  it('keeps no description that says nothing', async () => {
+    await createBooking(booking({ description: '<p>  </p>' }))
+    expect(appointmentData().description).toBeNull()
+  })
+
+  it("refuses another workshop's car and creates nothing", async () => {
+    db.vehicle.findFirst.mockResolvedValue(null)
+
+    const result = await createBooking(booking({ vehicleId: 'someone-elses-car' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Vehicle not found')
+    expect(db.appointment.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('createBooking: a work order', () => {
+  it('needs a car or a customer, and writes nothing without one', async () => {
+    const result = await createBooking(booking({ kind: 'workOrder', work: 'Brakes' }))
+
+    expect(result.success).toBe(false)
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+    expect(db.appointment.create).not.toHaveBeenCalled()
+    expect(db.appointmentNotification.createMany).not.toHaveBeenCalled()
+  })
+
+  it('books a scheduled job with the work lines as its concerns, in a free bay', async () => {
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }, { workBayId: 'bay-2' }])
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await createBooking(
+      booking({
+        kind: 'workOrder',
+        title: 'Brake job',
+        vehicleId: 'veh-1',
+        work: 'Front brakes squeal\n\n  Oil service  \n',
+        notifications: [{ channel: 'in_app', amount: 1, unit: 'hours', atTime: null }],
+      })
+    )
+
+    expect(result.success).toBe(true)
+    expect(result.data).toEqual({
+      kind: 'workOrder',
+      id: 'job-1',
+      ref: '2026-1044',
+      vehicleId: 'veh-1',
+      overbooked: false,
+    })
+    const job = jobData()
+    expect(job.organizationId).toBe(ORG)
+    expect(job.title).toBe('Brake job')
+    expect(job.vehicleId).toBe('veh-1')
+    expect(job.startDateTime).toEqual(new Date('2026-11-16T08:30:00Z'))
+    expect(job.endDateTime).toEqual(new Date('2026-11-16T09:30:00Z'))
+    expect(job.workBayId).toBe('bay-2')
+
+    expect(jobUpdate().where).toEqual({ id: 'job-1' })
+    expect(jobUpdate().data.status).toBe('scheduled')
+    expect(jobUpdate().data.concerns.create).toEqual([
+      { description: 'Front brakes squeal', sortOrder: 0 },
+      { description: 'Oil service', sortOrder: 1 },
+    ])
+    expect(createdNotifications()).toEqual([
+      expect.objectContaining({
+        organizationId: ORG,
+        userId: USER,
+        serviceRecordId: 'job-1',
+        channel: 'in_app',
+      }),
+    ])
+    expect(db.appointment.create).not.toHaveBeenCalled()
+  })
+
+  it('makes the title the only concern when no work lines are given', async () => {
+    db.vehicle.findFirst.mockResolvedValue(null)
+
+    const result = await createBooking(
+      booking({ kind: 'workOrder', title: 'Winter tyres on', customerId: 'cust-1' })
+    )
+
+    expect(result.success).toBe(true)
+    expect(jobData().customerId).toBe('cust-1')
+    expect(jobData().vehicleId).toBeNull()
+    expect(jobUpdate().data.concerns.create).toEqual([
+      { description: 'Winter tyres on', sortOrder: 0 },
+    ])
+  })
+
+  it('still books an hour where every bay is taken, and says so', async () => {
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }])
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await createBooking(
+      booking({ kind: 'workOrder', vehicleId: 'veh-1', work: 'Service' })
+    )
+
+    expect(result.success).toBe(true)
+    expect(result.data?.overbooked).toBe(true)
+    expect(jobData().workBayId).toBeUndefined()
+  })
+})
+
+describe('createBooking: an appointment made a work order', () => {
+  it('books the job and takes the appointment off the calendar', async () => {
+    db.appointment.findFirst.mockResolvedValue({ id: 'appt-1' })
+
+    const result = await createBooking(
+      booking({
+        kind: 'workOrder',
+        vehicleId: 'veh-1',
+        fromAppointmentId: 'appt-1',
+        work: 'Service',
+      })
+    )
+
+    expect(result.success).toBe(true)
+    expect(db.appointment.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'appt-1', organizationId: ORG } })
+    )
+    expect(db.serviceRecord.create).toHaveBeenCalledTimes(1)
+    expect(db.appointment.delete).toHaveBeenCalledWith({ where: { id: 'appt-1' } })
+  })
+
+  it("refuses another workshop's appointment and creates nothing", async () => {
+    db.appointment.findFirst.mockResolvedValue(null)
+
+    const result = await createBooking(
+      booking({
+        kind: 'workOrder',
+        vehicleId: 'veh-1',
+        fromAppointmentId: 'someone-elses-appt',
+        work: 'Service',
+      })
+    )
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Appointment not found')
+    expect(db.serviceRecord.create).not.toHaveBeenCalled()
+    expect(db.appointment.delete).not.toHaveBeenCalled()
+  })
+})
+
+describe('notifications to the customer', () => {
+  it('need somebody to go to', async () => {
+    const result = await createBooking(
+      booking({ notifications: [{ channel: 'customer_sms', amount: 1, unit: 'days' }] })
+    )
+
+    expect(result.success).toBe(false)
+    expect(db.appointment.create).not.toHaveBeenCalled()
+  })
+
+  it('are refused on the demo, while a notice in the app is not', async () => {
+    vi.stubEnv('DEMO_MODE', 'true')
+    vi.resetModules()
+    const { createBooking: demoCreateBooking } = await import(
+      '@/features/calendar/Actions/createBooking'
+    )
+
+    const toCustomer = await demoCreateBooking(
+      booking({
+        customerId: 'cust-1',
+        notifications: [{ channel: 'customer_sms', amount: 1, unit: 'days' }],
+      })
+    )
+    expect(toCustomer.success).toBe(false)
+    expect(toCustomer.error).toMatch(/disabled on the demo/)
+    expect(db.appointment.create).not.toHaveBeenCalled()
+
+    const inApp = await demoCreateBooking(
+      booking({
+        customerId: 'cust-1',
+        notifications: [{ channel: 'in_app', amount: 1, unit: 'hours' }],
+      })
+    )
+    expect(inApp.success).toBe(true)
+    expect(db.appointment.create).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('updateBooking: an appointment', () => {
+  it("refuses another workshop's appointment and changes nothing", async () => {
+    db.appointment.findFirst.mockResolvedValue(null)
+
+    const result = await updateBooking(booking({ id: 'someone-elses-appt' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Appointment not found')
+    expect(db.appointment.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'someone-elses-appt', organizationId: ORG } })
+    )
+    expect(db.appointment.update).not.toHaveBeenCalled()
+    expect(db.appointmentNotification.createMany).not.toHaveBeenCalled()
+  })
+
+  it('saves the new title, time and customer', async () => {
+    db.appointment.findFirst.mockResolvedValue({ id: 'appt-1' })
+
+    const result = await updateBooking(
+      booking({
+        id: 'appt-1',
+        title: 'Call Kari back',
+        customerId: 'cust-1',
+        startTime: '13:00',
+        endTime: '13:15',
+      })
+    )
+
+    expect(result.success).toBe(true)
+    expect(db.appointment.update).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { id: 'appt-1' },
+        data: expect.objectContaining({
+          title: 'Call Kari back',
+          startAt: new Date('2026-11-16T12:00:00Z'),
+          endAt: new Date('2026-11-16T12:15:00Z'),
+          vehicleId: null,
+          customerId: 'cust-1',
+        }),
+      })
+    )
+  })
+
+  it('keeps a notification left as it was, drops one removed and adds a new one', async () => {
+    db.appointment.findFirst.mockResolvedValue({ id: 'appt-1' })
+    db.appointmentNotification.findMany.mockResolvedValue([
+      { id: 'n-kept', channel: 'in_app', amount: 1, unit: 'hours', atTime: null },
+      { id: 'n-gone', channel: 'email', amount: 30, unit: 'minutes', atTime: null },
+    ])
+
+    const result = await updateBooking(
+      booking({
+        id: 'appt-1',
+        notifications: [
+          { channel: 'in_app', amount: 1, unit: 'hours', atTime: null },
+          { channel: 'email', amount: 2, unit: 'days', atTime: '16:00' },
+        ],
+      })
+    )
+
+    expect(result.success).toBe(true)
+    expect(db.appointmentNotification.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { organizationId: ORG, appointmentId: 'appt-1' } })
+    )
+    expect(db.appointmentNotification.deleteMany).toHaveBeenCalledTimes(1)
+    expect(db.appointmentNotification.deleteMany).toHaveBeenCalledWith({
+      where: { id: { in: ['n-gone'] }, organizationId: ORG },
+    })
+    expect(createdNotifications()).toEqual([
+      {
+        organizationId: ORG,
+        userId: USER,
+        appointmentId: 'appt-1',
+        channel: 'email',
+        amount: 2,
+        unit: 'days',
+        atTime: '16:00',
+      },
+    ])
+  })
+})
+
+/** A booked job whose car has not arrived: Monday 08:00 to 09:00 in Oslo, in bay 1. */
+function bookedJob(overrides: Record<string, unknown> = {}) {
+  return {
+    id: 'job-1',
+    status: 'scheduled',
+    invoiceNumber: '2026-1044',
+    vehicleId: 'veh-1',
+    customerId: null,
+    startDateTime: new Date('2026-11-16T07:00:00Z'),
+    endDateTime: new Date('2026-11-16T08:00:00Z'),
+    technicianId: null,
+    workBayId: 'bay-1',
+    concerns: [
+      {
+        description: 'Brakes',
+        cause: null,
+        correction: null,
+        confirmation: null,
+        confirmedAt: null,
+        _count: { findings: 0, attachments: 0 },
+      },
+    ],
+    ...overrides,
+  }
+}
+
+const jobEdit = (overrides: Partial<SaveBookingInput> = {}) =>
+  booking({
+    kind: 'workOrder',
+    id: 'job-1',
+    title: 'Brakes',
+    vehicleId: 'veh-1',
+    work: 'Brakes',
+    startTime: '08:00',
+    endTime: '09:00',
+    ...overrides,
+  })
+
+describe('updateBooking: a booked job', () => {
+  it('is refused once the job has started', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(bookedJob({ status: 'in_progress' }))
+
+    const result = await updateBooking(jobEdit({ title: 'Brakes and oil' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('The job has started. Change it on the work order.')
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it("refuses another workshop's job", async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(null)
+
+    const result = await updateBooking(jobEdit({ id: 'someone-elses-job' }))
+
+    expect(result.success).toBe(false)
+    expect(db.serviceRecord.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'someone-elses-job', organizationId: ORG } })
+    )
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('keeps its bay when moved to a time the bay is still free, not counting itself', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(bookedJob())
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }, { workBayId: 'bay-2' }])
+    // The job's own booking sits in bay 1 and would clash with the new time.
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' }, 'job-1')],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await updateBooking(jobEdit({ startTime: '09:30', endTime: '10:30' }))
+
+    expect(result.success).toBe(true)
+    expect(result.data?.overbooked).toBe(false)
+    expect(jobUpdate().where).toEqual({ id: 'job-1' })
+    expect(jobUpdate().data).toMatchObject({
+      startDateTime: new Date('2026-11-16T08:30:00Z'),
+      endDateTime: new Date('2026-11-16T09:30:00Z'),
+      workBayId: 'bay-1',
+    })
+  })
+
+  it('takes another bay when its own is taken at the new time', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(bookedJob())
+    bookingResources.mockResolvedValue([{ workBayId: 'bay-1' }, { workBayId: 'bay-2' }])
+    loadBookingContext.mockResolvedValue({
+      bookings: [held({ workBayId: 'bay-1' })],
+      hours: {},
+      closuresEnabled: false,
+    })
+
+    const result = await updateBooking(jobEdit({ startTime: '09:30', endTime: '10:30' }))
+
+    expect(result.success).toBe(true)
+    expect(jobUpdate().data.workBayId).toBe('bay-2')
+  })
+
+  it('replaces the concerns with the new work lines while nothing is recorded on them', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(bookedJob())
+
+    const result = await updateBooking(jobEdit({ work: 'Brakes\nOil service' }))
+
+    expect(result.success).toBe(true)
+    expect(db.serviceConcern.deleteMany).toHaveBeenCalledWith({
+      where: { serviceRecordId: 'job-1' },
+    })
+    expect(db.serviceConcern.createMany).toHaveBeenCalledWith({
+      data: [
+        { serviceRecordId: 'job-1', description: 'Brakes', sortOrder: 0 },
+        { serviceRecordId: 'job-1', description: 'Oil service', sortOrder: 1 },
+      ],
+    })
+  })
+
+  it.each([
+    ['a cause', { cause: 'Worn pads' }],
+    ['a correction', { correction: 'Replaced pads' }],
+    ['a confirmation', { confirmation: 'Road tested' }],
+    ['a confirmed time', { confirmedAt: new Date('2026-11-10T10:00:00Z') }],
+    ['a finding', { _count: { findings: 1, attachments: 0 } }],
+    ['an attachment', { _count: { findings: 0, attachments: 1 } }],
+  ])('refuses to change the work lines once a concern has %s', async (_what, recorded) => {
+    db.serviceRecord.findFirst.mockResolvedValue(
+      bookedJob({ concerns: [{ ...bookedJob().concerns[0], ...recorded }] })
+    )
+
+    const result = await updateBooking(jobEdit({ work: 'Brakes\nOil service' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Work is recorded on these concerns. Change them on the work order.')
+    expect(db.serviceConcern.deleteMany).not.toHaveBeenCalled()
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('still saves a job with recorded concerns when the work lines are left alone', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(
+      bookedJob({ concerns: [{ ...bookedJob().concerns[0], cause: 'Worn pads' }] })
+    )
+
+    const result = await updateBooking(jobEdit({ title: 'Brakes, front' }))
+
+    expect(result.success).toBe(true)
+    expect(jobUpdate().data.title).toBe('Brakes, front')
+    expect(db.serviceConcern.deleteMany).not.toHaveBeenCalled()
+  })
+
+  it("adds the customer's own car to a booking made for the customer alone", async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(
+      bookedJob({ vehicleId: null, customerId: 'cust-1' })
+    )
+
+    const result = await updateBooking(jobEdit({ vehicleId: 'veh-1', customerId: 'cust-1' }))
+
+    expect(result.success).toBe(true)
+    expect(db.vehicle.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'veh-1', organizationId: ORG } })
+    )
+    // A job on a car finds its customer through the car.
+    expect(jobUpdate().data).toMatchObject({ vehicleId: 'veh-1', customerId: null })
+  })
+
+  it("refuses another customer's car on a customer's booking", async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(
+      bookedJob({ vehicleId: null, customerId: 'cust-1' })
+    )
+    db.vehicle.findFirst.mockResolvedValue({
+      id: 'veh-2',
+      customerId: 'cust-2',
+      customer: { taxExempt: false },
+    })
+
+    const result = await updateBooking(jobEdit({ vehicleId: 'veh-2', customerId: 'cust-1' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('That car belongs to another customer')
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('refuses to change the car of a job that has one', async () => {
+    db.serviceRecord.findFirst.mockResolvedValue(bookedJob())
+
+    const result = await updateBooking(jobEdit({ vehicleId: 'veh-2' }))
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Change the car on the work order')
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+})
+
+describe('deleteAppointment', () => {
+  it('takes an appointment of this workshop off the calendar', async () => {
+    db.appointment.findFirst.mockResolvedValue({ id: 'appt-1', title: 'Call Kari' })
+
+    const result = await deleteAppointment('appt-1')
+
+    expect(result.success).toBe(true)
+    expect(db.appointment.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'appt-1', organizationId: ORG } })
+    )
+    expect(db.appointment.delete).toHaveBeenCalledWith({ where: { id: 'appt-1' } })
+  })
+
+  it("refuses another workshop's appointment", async () => {
+    db.appointment.findFirst.mockResolvedValue(null)
+
+    const result = await deleteAppointment('someone-elses-appt')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Appointment not found')
+    expect(db.appointment.delete).not.toHaveBeenCalled()
+  })
+})
+
+describe('who may book', () => {
+  it('refuses a role that may not create jobs', async () => {
+    signInAs('member', [{ action: 'read', subject: 'services' }])
+
+    const result = await createBooking(booking())
+
+    expect(result.success).toBe(false)
+    expect(result.forbidden).toBe(true)
+    expect(db.appointment.create).not.toHaveBeenCalled()
+  })
+
+  it('lets a role that may create jobs book', async () => {
+    signInAs('member', [{ action: 'create', subject: 'services' }])
+
+    const result = await createBooking(booking())
+
+    expect(result.success).toBe(true)
+    expect(db.appointment.create).toHaveBeenCalledTimes(1)
+  })
+})

+ 562 - 0
src/__tests__/features/calendar/booking-dialog.test.tsx

@@ -0,0 +1,562 @@
+/**
+ * The booking dialog on the calendar: a title, an appointment or a work
+ * order, when, who, and the notifications before it. It changes a booking
+ * that is still only a booking, and turns an appointment into a work order.
+ */
+
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { CalendarEvent } from '@/features/calendar/Actions/calendarActions'
+import type { BookingForEdit } from '@/features/calendar/Actions/getBooking'
+
+// Radix measures and captures pointers; jsdom does neither, and nothing here
+// needs measuring.
+const noop = () => undefined
+vi.stubGlobal(
+  'ResizeObserver',
+  class {
+    observe = noop
+    unobserve = noop
+    disconnect = noop
+  }
+)
+Element.prototype.scrollIntoView = noop
+Element.prototype.hasPointerCapture = () => false
+Element.prototype.releasePointerCapture = 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() } }))
+vi.mock('@/components/service-type-context', () => ({ useServiceType: () => 'automotive' }))
+// Tiptap does not run in jsdom; a plain text box stands in for the editor.
+vi.mock('@/components/ui/rich-text-editor', () => ({
+  RichTextEditor: ({
+    content,
+    onChange,
+    placeholder,
+  }: {
+    content: string
+    onChange: (html: string) => void
+    placeholder?: string
+  }) => (
+    <textarea
+      placeholder={placeholder}
+      value={content}
+      onChange={(e) => onChange(e.target.value)}
+    />
+  ),
+}))
+
+const createBooking = vi.hoisted(() => vi.fn())
+vi.mock('@/features/calendar/Actions/createBooking', () => ({ createBooking }))
+const updateBooking = vi.hoisted(() => vi.fn())
+vi.mock('@/features/calendar/Actions/updateBooking', () => ({ updateBooking }))
+const getBooking = vi.hoisted(() => vi.fn())
+vi.mock('@/features/calendar/Actions/getBooking', () => ({ getBooking }))
+const getBookingOptions = vi.hoisted(() => vi.fn())
+vi.mock('@/features/calendar/Actions/getBookingOptions', () => ({ getBookingOptions }))
+const createServiceMeeting = vi.hoisted(() => vi.fn())
+vi.mock('@/features/integrations/Actions/integrationActions', () => ({ createServiceMeeting }))
+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 }))
+
+import { toast } from 'sonner'
+import { BookingDialog } from '@/features/calendar/Components/BookingDialog'
+
+const kari = { id: 'cust-1', name: 'Kari Nordmann', company: null }
+const karisCar = {
+  id: 'veh-1',
+  make: 'Volvo',
+  model: 'V70',
+  year: 2012,
+  licensePlate: 'AB12345',
+  customerId: 'cust-1',
+  customer: { id: 'cust-1', name: 'Kari Nordmann' },
+}
+
+function calendarEvent(partial: Partial<CalendarEvent>): CalendarEvent {
+  return {
+    id: 'appt-1',
+    title: 'Call Kari',
+    date: '2026-11-16',
+    time: '09:30',
+    type: 'appointment',
+    status: 'scheduled',
+    vehicleId: null,
+    vehicleLabel: '',
+    customerName: 'Kari Nordmann',
+    invoiceNumber: null,
+    amount: null,
+    ...partial,
+  } as CalendarEvent
+}
+
+function loadedBooking(partial: Partial<BookingForEdit> = {}): BookingForEdit {
+  return {
+    kind: 'appointment',
+    id: 'appt-1',
+    title: 'Call Kari',
+    description: '',
+    customer: kari,
+    vehicle: null,
+    startDate: '2026-11-16',
+    startTime: '09:30',
+    endDate: '2026-11-16',
+    endTime: '10:30',
+    work: '',
+    notifications: [{ channel: 'in_app', amount: 1, unit: 'hours', atTime: null }],
+    locked: { who: false, work: false },
+    meeting: null,
+    ...partial,
+  }
+}
+
+async function renderDialog(props: Partial<Parameters<typeof BookingDialog>[0]> = {}) {
+  const onSaved = vi.fn()
+  const onOpenChange = vi.fn()
+  render(
+    <BookingDialog
+      open
+      onOpenChange={onOpenChange}
+      defaultDate="2026-11-16"
+      defaultTime="09:30"
+      onSaved={onSaved}
+      {...props}
+    />
+  )
+  // What the workshop offers arrives after the dialog opens.
+  await waitFor(() => expect(getBookingOptions).toHaveBeenCalled())
+  await act(async () => undefined)
+  return { onSaved, onOpenChange }
+}
+
+/** Types a concern into the list's add row and takes it with Enter. */
+function addConcern(text: string) {
+  const field = screen.getByLabelText('Add a concern and press Enter')
+  fireEvent.change(field, { target: { value: text } })
+  fireEvent.keyDown(field, { key: 'Enter' })
+}
+
+const titleField = () => screen.getByLabelText('Title')
+const saveButton = (name = 'Book appointment') => screen.getByRole('button', { name })
+const kindTab = (name: 'Appointment' | 'Work order') => screen.getByRole('tab', { name })
+/** The picker under a field's label in the form: "Customer", "Vehicle". */
+function fieldUnder(label: string) {
+  const row = screen.getByText(label, { selector: 'p' }).parentElement as HTMLElement
+  return within(row).getByRole('combobox')
+}
+const customerField = () => fieldUnder('Customer')
+const vehicleField = () => fieldUnder('Vehicle')
+
+function chooseKind(name: 'Appointment' | 'Work order') {
+  // Radix tabs switch on mouse down.
+  fireEvent.mouseDown(kindTab(name), { button: 0 })
+}
+
+/** Opens a dropdown in the form and picks an option from it. */
+async function pick(trigger: HTMLElement, option: string) {
+  fireEvent.keyDown(trigger, { key: 'Enter' })
+  fireEvent.click(await screen.findByRole('option', { name: option }))
+}
+
+async function chooseCustomer() {
+  fireEvent.click(customerField())
+  fireEvent.click(await screen.findByRole('option', { name: 'Kari Nordmann' }))
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  window.localStorage.clear()
+  getBookingOptions.mockResolvedValue({
+    success: true,
+    data: { channels: ['in_app', 'email'], video: [] },
+  })
+  searchCustomers.mockResolvedValue({ success: true, data: [kari] })
+  // The car list for a customer is empty, so choosing one picks no car for them.
+  searchVehicles.mockImplementation(async (_q, _size, _offset, customerId?: string) => ({
+    success: true,
+    data: customerId ? [] : [karisCar],
+  }))
+  createBooking.mockResolvedValue({
+    success: true,
+    data: {
+      kind: 'appointment',
+      id: 'appt-1',
+      ref: 'Call Kari',
+      vehicleId: null,
+      overbooked: false,
+    },
+  })
+  updateBooking.mockResolvedValue({
+    success: true,
+    data: {
+      kind: 'appointment',
+      id: 'appt-1',
+      ref: 'Call Kari',
+      vehicleId: null,
+      overbooked: false,
+    },
+  })
+  getBooking.mockResolvedValue({ success: true, data: loadedBooking() })
+})
+
+describe('a new booking', () => {
+  it('books an appointment with the time given and a notice in the app an hour before', async () => {
+    const { onSaved, onOpenChange } = await renderDialog()
+
+    fireEvent.change(titleField(), { target: { value: 'Call Kari' } })
+    chooseKind('Appointment')
+    // The end keeps the length when the start moves.
+    fireEvent.change(screen.getByLabelText('Start'), { target: { value: '10:00' } })
+    expect(screen.getByLabelText('End')).toHaveValue('11:00')
+    fireEvent.change(screen.getByLabelText('End'), { target: { value: '11:30' } })
+    fireEvent.click(saveButton())
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking).toHaveBeenCalledTimes(1)
+    const sent = createBooking.mock.calls[0][0]
+    expect(sent).toMatchObject({
+      kind: 'appointment',
+      title: 'Call Kari',
+      startDate: '2026-11-16',
+      startTime: '10:00',
+      endDate: '2026-11-16',
+      endTime: '11:30',
+    })
+    expect(sent.id).toBeUndefined()
+    expect(sent.fromAppointmentId).toBeUndefined()
+    expect(sent.work).toBeUndefined()
+    expect(sent.notifications).toEqual([
+      { channel: 'in_app', amount: 1, unit: 'hours', atTime: null },
+    ])
+    expect(updateBooking).not.toHaveBeenCalled()
+    expect(toast.success).toHaveBeenCalledWith('Appointment booked')
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+  })
+
+  it('will not save without a title or with the end before the start', async () => {
+    await renderDialog()
+    chooseKind('Appointment')
+    expect(saveButton()).toBeDisabled()
+
+    fireEvent.change(titleField(), { target: { value: 'Call Kari' } })
+    expect(saveButton()).toBeEnabled()
+
+    fireEvent.change(screen.getByLabelText('End'), { target: { value: '09:00' } })
+    expect(saveButton()).toBeDisabled()
+    expect(
+      screen.getByText('The end has to be after the start, and at most a week later.')
+    ).toBeInTheDocument()
+  })
+
+  it('will not book a work order until it knows the customer or the car', async () => {
+    createBooking.mockResolvedValue({
+      success: true,
+      data: {
+        kind: 'workOrder',
+        id: 'job-1',
+        ref: '2026-1044',
+        vehicleId: null,
+        overbooked: false,
+      },
+    })
+    const { onSaved } = await renderDialog()
+
+    chooseKind('Work order')
+    fireEvent.change(titleField(), { target: { value: 'Brake job' } })
+    // Each concern is typed on its own row and Enter takes it.
+    addConcern('Front brakes squeal')
+    addConcern('Oil service')
+    expect(screen.getByLabelText('Concern 1')).toHaveValue('Front brakes squeal')
+    expect(screen.getByLabelText('Concern 2')).toHaveValue('Oil service')
+    expect(saveButton()).toBeDisabled()
+
+    await chooseCustomer()
+    expect(customerField()).toHaveTextContent('Kari Nordmann')
+    expect(saveButton()).toBeEnabled()
+    fireEvent.click(saveButton())
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking).toHaveBeenCalledWith(
+      expect.objectContaining({
+        kind: 'workOrder',
+        title: 'Brake job',
+        customerId: 'cust-1',
+        vehicleId: undefined,
+        work: 'Front brakes squeal\nOil service',
+      })
+    )
+  })
+
+  it('says so when no bay was free, though it booked all the same', async () => {
+    createBooking.mockResolvedValue({
+      success: true,
+      data: { kind: 'workOrder', id: 'job-1', ref: '2026-1044', vehicleId: null, overbooked: true },
+    })
+    const { onSaved } = await renderDialog()
+    chooseKind('Work order')
+    fireEvent.change(titleField(), { target: { value: 'Brake job' } })
+    await chooseCustomer()
+    fireEvent.click(saveButton())
+
+    await waitFor(() => expect(onSaved).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('starts on the kind this person booked last', async () => {
+    window.localStorage.setItem('torqvoice.calendar.bookingKind', 'appointment')
+    await renderDialog()
+    expect(kindTab('Appointment')).toHaveAttribute('aria-selected', 'true')
+    expect(screen.queryByLabelText('Add a concern and press Enter')).not.toBeInTheDocument()
+  })
+})
+
+describe('notifications', () => {
+  it('adds a notice in days at a time of day, and removes one', async () => {
+    const { onSaved } = await renderDialog()
+    chooseKind('Appointment')
+    fireEvent.change(titleField(), { target: { value: 'Call Kari' } })
+
+    fireEvent.click(screen.getByRole('button', { name: 'Add notification' }))
+    expect(screen.getAllByLabelText('How')).toHaveLength(2)
+
+    // Two days before, at four in the afternoon.
+    fireEvent.change(screen.getAllByLabelText('How many')[1], { target: { value: '2' } })
+    // No time of day until the notice is in days or weeks.
+    expect(screen.queryByLabelText('Time of day')).not.toBeInTheDocument()
+    await pick(screen.getAllByLabelText('Minutes, hours, days or weeks before')[1], 'days before')
+    const at = await screen.findByLabelText('Time of day')
+    expect(at).toHaveValue('09:00')
+    fireEvent.change(at, { target: { value: '16:00' } })
+
+    // The first, an hour before in the app, goes.
+    fireEvent.click(screen.getAllByRole('button', { name: 'Remove notification' })[0])
+    expect(screen.getAllByLabelText('How')).toHaveLength(1)
+
+    fireEvent.click(saveButton())
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking.mock.calls[0][0].notifications).toEqual([
+      { channel: 'in_app', amount: 2, unit: 'days', atTime: '16:00' },
+    ])
+  })
+
+  it('saves a booking with no notifications at all', async () => {
+    const { onSaved } = await renderDialog()
+    chooseKind('Appointment')
+    fireEvent.change(titleField(), { target: { value: 'Call Kari' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Remove notification' }))
+    fireEvent.click(saveButton())
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking.mock.calls[0][0].notifications).toEqual([])
+  })
+
+  it('offers only the workshop channels when no customer messaging is set up', async () => {
+    await renderDialog()
+    fireEvent.keyDown(screen.getByLabelText('How'), { key: 'Enter' })
+
+    const list = await screen.findByRole('listbox')
+    expect(within(list).getByRole('option', { name: 'In the app' })).toBeInTheDocument()
+    expect(within(list).getByRole('option', { name: 'Email to me' })).toBeInTheDocument()
+    expect(within(list).queryByRole('option', { name: 'SMS to customer' })).not.toBeInTheDocument()
+    expect(
+      within(list).queryByRole('option', { name: 'Email to customer' })
+    ).not.toBeInTheDocument()
+  })
+
+  it('offers the customer channels the workshop has, once there is a customer', async () => {
+    getBookingOptions.mockResolvedValue({
+      success: true,
+      data: { channels: ['in_app', 'email', 'customer_sms', 'customer_email'], video: [] },
+    })
+    const { onSaved } = await renderDialog()
+    chooseKind('Appointment')
+    fireEvent.change(titleField(), { target: { value: 'Call Kari' } })
+
+    fireEvent.keyDown(screen.getByLabelText('How'), { key: 'Enter' })
+    const sms = await screen.findByRole('option', { name: 'SMS to customer' })
+    expect(sms).toHaveAttribute('aria-disabled', 'true')
+    expect(screen.getByRole('option', { name: 'Email to customer' })).toHaveAttribute(
+      'aria-disabled',
+      'true'
+    )
+    fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' })
+    await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
+
+    await chooseCustomer()
+    await pick(screen.getByLabelText('How'), 'SMS to customer')
+    expect(screen.getByLabelText('How')).toHaveTextContent('SMS to customer')
+    fireEvent.click(saveButton())
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking).toHaveBeenCalledWith(
+      expect.objectContaining({
+        customerId: 'cust-1',
+        notifications: [{ channel: 'customer_sms', amount: 1, unit: 'hours', atTime: null }],
+      })
+    )
+  })
+})
+
+describe('changing a booking', () => {
+  it('loads the appointment and saves the change to it', async () => {
+    const { onSaved } = await renderDialog({
+      edit: { mode: 'edit', event: calendarEvent({}) },
+    })
+
+    expect(getBooking).toHaveBeenCalledWith({ kind: 'appointment', id: 'appt-1' })
+    expect(await screen.findByDisplayValue('Call Kari')).toBeInTheDocument()
+    expect(customerField()).toHaveTextContent('Kari Nordmann')
+    // An appointment stays an appointment; it becomes a work order another way.
+    expect(kindTab('Work order')).toBeDisabled()
+
+    fireEvent.change(titleField(), { target: { value: 'Call Kari back' } })
+    fireEvent.click(saveButton('Save'))
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(createBooking).not.toHaveBeenCalled()
+    expect(updateBooking).toHaveBeenCalledWith(
+      expect.objectContaining({
+        kind: 'appointment',
+        id: 'appt-1',
+        title: 'Call Kari back',
+        customerId: 'cust-1',
+        startDate: '2026-11-16',
+        startTime: '09:30',
+        endTime: '10:30',
+        notifications: [{ channel: 'in_app', amount: 1, unit: 'hours', atTime: null }],
+      })
+    )
+    expect(toast.success).toHaveBeenCalledWith('Booking saved')
+  })
+
+  it('shows the car of a booked job but does not let it change here', async () => {
+    getBooking.mockResolvedValue({
+      success: true,
+      data: loadedBooking({
+        kind: 'workOrder',
+        id: 'job-1',
+        title: 'Brake job',
+        vehicle: karisCar,
+        work: 'Front brakes squeal',
+        locked: { who: true, work: false },
+      }),
+    })
+    await renderDialog({
+      edit: {
+        mode: 'edit',
+        event: calendarEvent({ id: 'job-1', type: 'service', title: 'Brake job' }),
+      },
+    })
+
+    expect(getBooking).toHaveBeenCalledWith({ kind: 'workOrder', id: 'job-1' })
+    expect(await screen.findByDisplayValue('Brake job')).toBeInTheDocument()
+    expect(vehicleField()).toHaveTextContent('2012 Volvo V70 (AB12345)')
+    expect(vehicleField()).toBeDisabled()
+    expect(customerField()).toBeDisabled()
+    expect(
+      screen.getByText('Change the car or the customer on the work order.')
+    ).toBeInTheDocument()
+    expect(screen.getByLabelText('Concern 1')).toHaveValue('Front brakes squeal')
+    expect(kindTab('Appointment')).toBeDisabled()
+
+    fireEvent.click(saveButton('Save'))
+    await waitFor(() => expect(updateBooking).toHaveBeenCalled())
+    expect(updateBooking).toHaveBeenCalledWith(
+      expect.objectContaining({
+        kind: 'workOrder',
+        id: 'job-1',
+        vehicleId: 'veh-1',
+        work: 'Front brakes squeal',
+      })
+    )
+  })
+
+  it('locks the work lines once work is recorded on them', async () => {
+    getBooking.mockResolvedValue({
+      success: true,
+      data: loadedBooking({
+        kind: 'workOrder',
+        id: 'job-1',
+        title: 'Brake job',
+        vehicle: karisCar,
+        work: 'Front brakes squeal',
+        locked: { who: true, work: true },
+      }),
+    })
+    await renderDialog({
+      edit: { mode: 'edit', event: calendarEvent({ id: 'job-1', type: 'service' }) },
+    })
+
+    expect(await screen.findByLabelText('Concern 1')).toBeDisabled()
+    // Nothing can be added to concerns that already carry work.
+    expect(screen.queryByLabelText('Add a concern and press Enter')).not.toBeInTheDocument()
+    expect(
+      screen.getByText('Work is recorded on these concerns. Change them on the work order.')
+    ).toBeInTheDocument()
+  })
+
+  it('closes and says why when the booking cannot be loaded', async () => {
+    getBooking.mockResolvedValue({
+      success: false,
+      error: 'The job has started. Change it on the work order.',
+    })
+    const { onOpenChange } = await renderDialog({
+      edit: { mode: 'edit', event: calendarEvent({ id: 'job-1', type: 'service' }) },
+    })
+
+    await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
+    expect(toast.error).toHaveBeenCalledWith('The job has started. Change it on the work order.')
+  })
+})
+
+describe('making an appointment a work order', () => {
+  it('opens on a work order with the appointment, and books it from the appointment', async () => {
+    createBooking.mockResolvedValue({
+      success: true,
+      data: {
+        kind: 'workOrder',
+        id: 'job-1',
+        ref: '2026-1044',
+        vehicleId: null,
+        overbooked: false,
+      },
+    })
+    const { onSaved } = await renderDialog({
+      edit: { mode: 'convert', event: calendarEvent({}) },
+    })
+
+    expect(await screen.findByDisplayValue('Call Kari')).toBeInTheDocument()
+    expect(kindTab('Work order')).toHaveAttribute('aria-selected', 'true')
+    expect(kindTab('Appointment')).toBeDisabled()
+    // No concerns yet: the appointment's title becomes the job's concern unless some are added.
+    expect(screen.queryByLabelText('Concern 1')).not.toBeInTheDocument()
+    expect(screen.getByText("Left empty, the title is the job's concern.")).toBeInTheDocument()
+
+    fireEvent.click(saveButton('Make work order'))
+
+    await waitFor(() => expect(onSaved).toHaveBeenCalled())
+    expect(updateBooking).not.toHaveBeenCalled()
+    const sent = createBooking.mock.calls[0][0]
+    expect(sent).toMatchObject({
+      kind: 'workOrder',
+      fromAppointmentId: 'appt-1',
+      title: 'Call Kari',
+      customerId: 'cust-1',
+      startDate: '2026-11-16',
+      startTime: '09:30',
+    })
+    expect(sent.id).toBeUndefined()
+  })
+})

+ 225 - 0
src/__tests__/features/calendar/calendar-events-access.test.ts

@@ -0,0 +1,225 @@
+/**
+ * What the calendar lists for one person: jobs for whoever may open the
+ * calendar, and quotes, reminders and scheduled messages only for a role that
+ * may read those records on their own pages. The dashboard's calendar card
+ * reads the same feed, so this is what both screens show.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next-intl/server', () => ({
+  getTranslations: async () => {
+    const t = (key: string) => key
+    t.has = () => false
+    return t
+  },
+}))
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: vi.fn(async () => 'Europe/Oslo'),
+}))
+vi.mock('@/features/workshop-calendar/Lib/loadCalendar.server', () => ({
+  loadWorkshopCalendar: vi.fn(),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    auditLog: { create: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    reminder: { findMany: vi.fn() },
+    quote: { findMany: vi.fn() },
+    scheduledMessage: { findMany: vi.fn() },
+    externalCalendarEvent: { findMany: vi.fn() },
+    appointment: { findMany: vi.fn() },
+    technician: { findMany: vi.fn() },
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { getCalendarEvents } from '@/features/calendar/Actions/calendarActions'
+
+const ORG = 'org-a'
+const MONDAY = new Date('2026-11-16T09:00:00Z')
+
+function signInAs(role: 'owner' | 'member', permissions: { action: string; subject: string }[]) {
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role,
+    roleId: role === 'member' ? 'role-1' : null,
+    customRole: role === 'member' ? { isAdmin: false, permissions } : null,
+  } as never)
+}
+
+const read = (subject: string) => ({ action: 'read', subject })
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-a', email: 'a@x.com' },
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.auditLog.create).mockResolvedValue({} as never)
+  vi.mocked(loadWorkshopCalendar).mockResolvedValue({
+    calendar: {
+      closures: [
+        {
+          name: 'Workshop closed',
+          startDate: '2026-11-17',
+          endDate: '2026-11-17',
+          startTime: null,
+          endTime: null,
+          source: 'own',
+        },
+      ],
+      absences: [],
+    },
+  } as never)
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    {
+      id: 'job-1',
+      title: 'Brake service',
+      serviceDate: MONDAY,
+      startDateTime: MONDAY,
+      endDateTime: null,
+      status: 'scheduled',
+      invoiceNumber: null,
+      totalAmount: 0,
+      cost: 0,
+      vehicleId: 'car-1',
+      customer: { name: 'Kari' },
+      vehicle: { make: 'Volvo', model: 'V70', year: 2012, customer: null },
+      _count: { appointmentNotifications: 0 },
+    },
+  ] as never)
+  vi.mocked(db.reminder.findMany).mockResolvedValue([
+    {
+      id: 'rem-1',
+      title: 'Call about tyres',
+      dueDate: MONDAY,
+      hasDueTime: false,
+      isCompleted: false,
+      vehicleId: 'car-1',
+      customer: null,
+      vehicle: { make: 'Volvo', model: 'V70', year: 2012, customer: { name: 'Kari' } },
+    },
+  ] as never)
+  vi.mocked(db.quote.findMany).mockResolvedValue([
+    {
+      id: 'quote-1',
+      title: 'Timing belt',
+      quoteNumber: 'Q-1',
+      validUntil: MONDAY,
+      status: 'sent',
+      totalAmount: 9000,
+      vehicleId: 'car-1',
+      vehicle: { make: 'Volvo', model: 'V70', year: 2012, customer: { name: 'Kari' } },
+      customer: null,
+    },
+  ] as never)
+  vi.mocked(db.scheduledMessage.findMany).mockResolvedValue([
+    {
+      id: 'msg-1',
+      channel: 'sms',
+      subject: 'Your car is booked in',
+      body: 'See you Monday',
+      recipient: '+4799999999',
+      status: 'scheduled',
+      sendAt: MONDAY,
+      vehicleId: 'car-1',
+      customer: { name: 'Kari' },
+      vehicle: { make: 'Volvo', model: 'V70', year: 2012 },
+    },
+  ] as never)
+  vi.mocked(db.externalCalendarEvent.findMany).mockResolvedValue([])
+  vi.mocked(db.appointment.findMany).mockResolvedValue([
+    {
+      id: 'appt-1',
+      title: 'Tyre quote talk',
+      description: '<p>Wants winter tyres</p><script>x()</script>',
+      startAt: MONDAY,
+      endAt: new Date(MONDAY.getTime() + 30 * 60_000),
+      vehicleId: null,
+      customer: { name: 'Kari' },
+      vehicle: null,
+      _count: { notifications: 1 },
+    },
+  ] as never)
+  vi.mocked(db.technician.findMany).mockResolvedValue([])
+})
+
+async function titles() {
+  const res = await getCalendarEvents({ start: '2026-11-16', end: '2026-11-22' })
+  expect(res.success).toBe(true)
+  return (res.data ?? []).map((e) => `${e.type}: ${e.title}`).sort()
+}
+
+describe('calendar events by role', () => {
+  it('shows an owner everything on the calendar', async () => {
+    signInAs('owner', [])
+    expect(await titles()).toEqual([
+      'appointment: Tyre quote talk',
+      'closure: Workshop closed',
+      'message: Your car is booked in',
+      'quote: Timing belt',
+      'reminder: Call about tyres',
+      'service: Brake service',
+    ])
+  })
+
+  it('shows a role that reads jobs only the jobs and the workshop days', async () => {
+    signInAs('member', [read('services')])
+    expect(await titles()).toEqual([
+      'appointment: Tyre quote talk',
+      'closure: Workshop closed',
+      'service: Brake service',
+    ])
+    // Not fetched and dropped: never asked for.
+    expect(db.quote.findMany).not.toHaveBeenCalled()
+    expect(db.reminder.findMany).not.toHaveBeenCalled()
+    expect(db.scheduledMessage.findMany).not.toHaveBeenCalled()
+  })
+
+  it('adds each kind of record the role may read', async () => {
+    signInAs('member', [read('services'), read('quotes'), read('vehicles')])
+    expect(await titles()).toEqual([
+      'appointment: Tyre quote talk',
+      'closure: Workshop closed',
+      'quote: Timing belt',
+      'reminder: Call about tyres',
+      'service: Brake service',
+    ])
+  })
+
+  it('shows scheduled messages to a role that reads customers', async () => {
+    signInAs('member', [read('services'), read('customers')])
+    expect(await titles()).toContain('message: Your car is booked in')
+  })
+
+  it('lists appointments with their description cleaned', async () => {
+    signInAs('owner', [])
+    const res = await getCalendarEvents({ start: '2026-11-16', end: '2026-11-22' })
+    const appointment = res.data?.find((e) => e.type === 'appointment')
+    expect(appointment).toMatchObject({
+      title: 'Tyre quote talk',
+      date: '2026-11-16',
+      time: '10:00',
+      endTime: '10:30',
+      customerName: 'Kari',
+      notificationCount: 1,
+    })
+    expect(appointment?.description).toContain('Wants winter tyres')
+    expect(appointment?.description).not.toContain('script')
+  })
+
+  it('refuses the calendar to a role that cannot read jobs', async () => {
+    signInAs('member', [read('quotes'), read('vehicles'), read('customers')])
+    const res = await getCalendarEvents({ start: '2026-11-16', end: '2026-11-22' })
+    expect(res.success).toBe(false)
+    expect(res.forbidden).toBe(true)
+  })
+})

+ 134 - 0
src/__tests__/features/calendar/calendar-events-card.test.tsx

@@ -0,0 +1,134 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import type { CalendarEvent } from '@/features/calendar/Actions/calendarActions'
+
+vi.mock('@/components/date-settings-context', () => ({
+  useDateSettings: () => ({
+    dateFormat: 'MMM d, yyyy',
+    timeFormat: '24h',
+    timezone: '',
+    weekStartDay: 1,
+  }),
+}))
+vi.mock('@/components/currency-settings-context', () => ({
+  useFormatCurrency: () => (amount: number, code: string) => `${amount} ${code}`,
+}))
+
+const push = vi.fn()
+const refresh = vi.fn()
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push, refresh }) }))
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => vi.fn() }))
+vi.mock('@/features/scheduled-messages/Actions/scheduledMessageActions', () => ({
+  deleteScheduledMessage: vi.fn(),
+}))
+vi.mock('@/features/calendar/Actions/deleteAppointment', () => ({ deleteAppointment: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/reminderActions', () => ({
+  toggleReminder: vi.fn(),
+  deleteReminder: vi.fn(),
+}))
+// The booking form talks to the server; what matters here is that the card
+// opens it, on the day the card starts.
+vi.mock('@/features/calendar/Components/BookingDialog', () => ({
+  BookingDialog: ({ open, defaultDate }: { open: boolean; defaultDate?: string }) =>
+    open ? <div role="dialog">Booking for {defaultDate}</div> : null,
+}))
+vi.mock('@/features/calendar/Components/useDayFormatter', () => ({
+  useDayFormatter: () => ({
+    dateTime: (date: Date, options: Intl.DateTimeFormatOptions) =>
+      new Intl.DateTimeFormat('en-US', options).format(date),
+  }),
+}))
+
+import { CalendarEventsCard } from '@/features/calendar/Components/CalendarEventsCard'
+
+const TODAY = '2026-11-16'
+
+function event(partial: Partial<CalendarEvent> & Pick<CalendarEvent, 'id' | 'title'>) {
+  return {
+    date: TODAY,
+    time: null,
+    type: 'service',
+    status: 'scheduled',
+    vehicleId: 'car-1',
+    vehicleLabel: '2012 Volvo V70',
+    customerName: 'Kari Nordmann',
+    invoiceNumber: null,
+    amount: null,
+    ...partial,
+  } as CalendarEvent
+}
+
+function renderCard(events: CalendarEvent[]) {
+  return render(<CalendarEventsCard events={events} todayKey={TODAY} days={7} currencyCode="NOK" />)
+}
+
+beforeEach(() => {
+  push.mockReset()
+})
+
+describe('calendar events card', () => {
+  it('lists what is coming, day by day, and leaves out what is done', () => {
+    renderCard([
+      event({ id: 'job-1', title: 'Brake service', time: '09:30' }),
+      event({ id: 'job-2', title: 'Oil change', status: 'completed', time: '08:00' }),
+      event({
+        id: 'rem-1',
+        title: 'Call about tyres',
+        type: 'reminder',
+        status: 'upcoming',
+        date: '2026-11-18',
+      }),
+      event({
+        id: 'closure:0:2026-11-20',
+        title: 'Workshop closed',
+        type: 'closure',
+        status: 'closed',
+        date: '2026-11-20',
+        vehicleId: null,
+        vehicleLabel: '',
+        customerName: null,
+      }),
+    ])
+
+    expect(screen.getByText('Calendar Events')).toBeInTheDocument()
+    expect(screen.getByText('Next 7 days')).toBeInTheDocument()
+    expect(screen.getByText('Brake service')).toBeInTheDocument()
+    // Beside the title on a wide screen, above it on a phone.
+    expect(screen.getAllByText('09:30').length).toBeGreaterThan(0)
+    expect(screen.getByText('Call about tyres')).toBeInTheDocument()
+    expect(screen.getByText('Workshop closed')).toBeInTheDocument()
+    expect(screen.queryByText('Oil change')).not.toBeInTheDocument()
+
+    // Three days have something on them: the 16th, 18th and 20th.
+    const days = screen.getAllByRole('button', { name: /^(16|18|20)/ })
+    expect(days.map((d) => d.textContent?.slice(0, 2))).toEqual(['16', '18', '20'])
+  })
+
+  it('opens the calendar on a day, and on the whole schedule', () => {
+    renderCard([event({ id: 'job-1', title: 'Brake service', time: '09:30' })])
+
+    fireEvent.click(screen.getByRole('button', { name: /^16/ }))
+    expect(push).toHaveBeenCalledWith('/calendar?view=day&date=2026-11-16')
+
+    fireEvent.click(screen.getByRole('button', { name: 'View All' }))
+    expect(push).toHaveBeenCalledWith('/calendar?view=schedule')
+  })
+
+  it('books an appointment from the card', () => {
+    renderCard([event({ id: 'job-1', title: 'Brake service' })])
+
+    fireEvent.click(screen.getByRole('button', { name: 'Book appointment' }))
+    expect(within(screen.getByRole('dialog')).getByText(`Booking for ${TODAY}`)).toBeInTheDocument()
+  })
+
+  it('says when nothing is coming and still offers a booking', () => {
+    renderCard([event({ id: 'job-2', title: 'Oil change', status: 'completed' })])
+
+    expect(screen.getByText('Nothing on the calendar in the next 7 days.')).toBeInTheDocument()
+    expect(screen.queryByText('Oil change')).not.toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'View All' })).not.toBeInTheDocument()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Book appointment' }))
+    expect(screen.getByRole('dialog')).toBeInTheDocument()
+  })
+})

+ 71 - 0
src/__tests__/features/calendar/concern-list-editor.test.tsx

@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import { useState } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ConcernListEditor } from '@/features/calendar/Components/ConcernListEditor'
+
+/** The editor as the dialog uses it, with its text kept in state. */
+function Harness({ initial = '', disabled = false }: { initial?: string; disabled?: boolean }) {
+  const [value, setValue] = useState(initial)
+  return (
+    <>
+      <ConcernListEditor value={value} onChange={setValue} disabled={disabled} />
+      <output data-testid="value">{JSON.stringify(value)}</output>
+    </>
+  )
+}
+
+const value = () => JSON.parse(screen.getByTestId('value').textContent ?? '""')
+const addField = () => screen.getByLabelText('Add a concern and press Enter')
+
+describe('the list of concerns', () => {
+  it('takes a concern per Enter, in order', () => {
+    render(<Harness />)
+    fireEvent.change(addField(), { target: { value: 'Front brakes squeal' } })
+    fireEvent.keyDown(addField(), { key: 'Enter' })
+    fireEvent.change(addField(), { target: { value: 'Oil service' } })
+    fireEvent.keyDown(addField(), { key: 'Enter' })
+
+    expect(value()).toBe('Front brakes squeal\nOil service')
+    expect(screen.getByLabelText('Concern 1')).toHaveValue('Front brakes squeal')
+    expect(addField()).toHaveValue('')
+  })
+
+  it('keeps what was typed when the field is left without Enter', () => {
+    render(<Harness />)
+    fireEvent.change(addField(), { target: { value: 'Check AC' } })
+    fireEvent.blur(addField())
+    expect(value()).toBe('Check AC')
+  })
+
+  it('turns a pasted list into one concern per line', () => {
+    render(<Harness initial="Oil service" />)
+    fireEvent.paste(addField(), {
+      clipboardData: { getData: () => 'Front brakes squeal\n\n  Check AC  \nWipers' },
+    })
+    expect(value()).toBe('Oil service\nFront brakes squeal\nCheck AC\nWipers')
+  })
+
+  it('opens a row below on Enter and drops an empty one on Backspace', () => {
+    render(<Harness initial={'Oil service\nCheck AC'} />)
+    fireEvent.keyDown(screen.getByLabelText('Concern 1'), { key: 'Enter' })
+    expect(value()).toBe('Oil service\n\nCheck AC')
+    fireEvent.keyDown(screen.getByLabelText('Concern 2'), { key: 'Backspace' })
+    expect(value()).toBe('Oil service\nCheck AC')
+  })
+
+  it('removes a concern with its button, and edits one in place', () => {
+    render(<Harness initial={'Oil service\nCheck AC'} />)
+    fireEvent.change(screen.getByLabelText('Concern 2'), {
+      target: { value: 'Check AC, no cold air' },
+    })
+    fireEvent.click(screen.getAllByRole('button', { name: 'Remove concern' })[0])
+    expect(value()).toBe('Check AC, no cold air')
+  })
+
+  it('shows recorded concerns without letting them change', () => {
+    render(<Harness initial="Oil service" disabled />)
+    expect(screen.getByLabelText('Concern 1')).toBeDisabled()
+    expect(screen.queryByLabelText('Add a concern and press Enter')).not.toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'Remove concern' })).not.toBeInTheDocument()
+  })
+})

+ 99 - 0
src/__tests__/features/calendar/notification-timing.test.ts

@@ -0,0 +1,99 @@
+/**
+ * When a notification before an appointment goes out: counted back in
+ * minutes and hours, or on an earlier day at a time of day, on the
+ * workshop's own clock.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { isNotificationDue, notificationFiresAt } from '@/features/calendar/Lib/notificationTiming'
+
+const OSLO = 'Europe/Oslo'
+// Monday 16 November 2026, 09:30 in Oslo (UTC+1).
+const START = new Date('2026-11-16T08:30:00Z')
+
+describe('when a notification goes out', () => {
+  it('counts minutes and hours back from the start', () => {
+    expect(
+      notificationFiresAt(START, { amount: 1, unit: 'hours', atTime: null }, OSLO).toISOString()
+    ).toBe('2026-11-16T07:30:00.000Z')
+    expect(
+      notificationFiresAt(START, { amount: 15, unit: 'minutes', atTime: null }, OSLO).toISOString()
+    ).toBe('2026-11-16T08:15:00.000Z')
+  })
+
+  it('goes out on an earlier day at the time of day given, on the workshop clock', () => {
+    // 1 day before at 16:00 Oslo: Sunday 15 November, 15:00 UTC.
+    expect(
+      notificationFiresAt(START, { amount: 1, unit: 'days', atTime: '16:00' }, OSLO).toISOString()
+    ).toBe('2026-11-15T15:00:00.000Z')
+    // 1 week before at 09:00: Monday 9 November.
+    expect(
+      notificationFiresAt(START, { amount: 1, unit: 'weeks', atTime: '09:00' }, OSLO).toISOString()
+    ).toBe('2026-11-09T08:00:00.000Z')
+  })
+
+  it('keeps the time of day across a change of the clocks', () => {
+    // Monday 30 March 2026 09:00 Oslo is summer time (UTC+2); the Saturday
+    // before is winter time (UTC+1). "2 days before at 18:00" is 18:00 both ways.
+    const afterChange = new Date('2026-03-30T07:00:00Z')
+    expect(
+      notificationFiresAt(
+        afterChange,
+        { amount: 2, unit: 'days', atTime: '18:00' },
+        OSLO
+      ).toISOString()
+    ).toBe('2026-03-28T17:00:00.000Z')
+  })
+})
+
+describe('whether it is due', () => {
+  const hourBefore = { amount: 1, unit: 'hours', atTime: null }
+  const setLongAgo = new Date('2026-11-01T00:00:00Z')
+
+  it('is due from its time until the appointment starts', () => {
+    const base = { start: START, timing: hourBefore, sentForStart: null, setAt: setLongAgo }
+    expect(isNotificationDue(base, new Date('2026-11-16T07:29:00Z'), OSLO)).toBe(false)
+    expect(isNotificationDue(base, new Date('2026-11-16T07:30:00Z'), OSLO)).toBe(true)
+    expect(isNotificationDue(base, new Date('2026-11-16T08:30:00Z'), OSLO)).toBe(false)
+  })
+
+  it('goes out once for a start, and again when the appointment moves', () => {
+    const now = new Date('2026-11-16T07:45:00Z')
+    expect(
+      isNotificationDue(
+        { start: START, timing: hourBefore, sentForStart: START, setAt: setLongAgo },
+        now,
+        OSLO
+      )
+    ).toBe(false)
+    // Sent for Friday's start, then moved to Monday: due again.
+    expect(
+      isNotificationDue(
+        {
+          start: START,
+          timing: hourBefore,
+          sentForStart: new Date('2026-11-13T08:30:00Z'),
+          setAt: setLongAgo,
+        },
+        now,
+        OSLO
+      )
+    ).toBe(true)
+  })
+
+  it('does not fire for a notice whose time had passed when it was set', () => {
+    // Booked at 08:00 for 09:30 Oslo with "1 hour before" (07:30 UTC): too late.
+    expect(
+      isNotificationDue(
+        {
+          start: START,
+          timing: hourBefore,
+          sentForStart: null,
+          setAt: new Date('2026-11-16T07:40:00Z'),
+        },
+        new Date('2026-11-16T07:41:00Z'),
+        OSLO
+      )
+    ).toBe(false)
+  })
+})

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

@@ -269,3 +269,62 @@ describe('TimeGridView', () => {
     expect(actions.onNewWorkOrder).toHaveBeenCalledWith('2026-09-05', '14:00')
   })
 })
+
+/**
+ * Three jobs on one morning, the way they stack on a busy day: one from
+ * 07:00, one from 08:00 drawn in from the left, and one from 09:30 that
+ * starts after the first has ended and so takes its column again.
+ */
+describe('overlapping jobs', () => {
+  const at = (id: string, title: string, time: string, endTime: string): CalendarEvent => ({
+    ...job,
+    id,
+    title,
+    time,
+    endTime,
+    status: 'scheduled',
+    customerName: `${title} owner`,
+  })
+  const first = at('a', 'Gearbox oil', '07:00', '09:00')
+  const second = at('b', 'Coolant leak', '08:00', '10:30')
+  const third = at('c', 'Rear wheel bearing', '09:30', '12:00')
+
+  function renderMorning() {
+    return render(
+      <EventPeekProvider currencyCode="NOK">
+        <TimeGridView
+          days={days}
+          eventsByDate={new Map([['2026-09-03', [first, second, third]]])}
+          todayStr="2026-09-06"
+          selectedDateStr="2026-09-06"
+          showWeekends
+          actions={{
+            onNewWorkOrder: vi.fn(),
+            onBookAppointment: vi.fn(),
+            onNewReminder: vi.fn(),
+            onNewQuote: vi.fn(),
+            onScheduleMessage: vi.fn(),
+          }}
+          onSelectDate={vi.fn()}
+          onOpenDay={vi.fn()}
+        />
+      </EventPeekProvider>
+    )
+  }
+  const block = (title: string) => screen.getByTitle(new RegExp(title))
+  const z = (el: HTMLElement) => Number(el.style.zIndex)
+
+  it('draws a job that starts later over one already running', () => {
+    renderMorning()
+    // The 09:30 job shares the first job's column, yet sits over the 08:00
+    // one drawn in beside it, so its title is never hidden.
+    expect(z(block('Rear wheel bearing'))).toBeGreaterThan(z(block('Coolant leak')))
+    expect(z(block('Coolant leak'))).toBeGreaterThan(z(block('Gearbox oil')))
+  })
+
+  it('names the customer under the time, since the title names the car', () => {
+    renderMorning()
+    expect(block('Coolant leak')).toHaveTextContent('Coolant leak owner')
+    expect(block('Coolant leak')).not.toHaveTextContent('2019 Volvo V90')
+  })
+})

+ 114 - 1
src/__tests__/features/dashboard-layout.test.ts

@@ -158,7 +158,12 @@ describe('moved defaults', () => {
     const layout = normalizeLayout({
       version: 1,
       hidden: [],
-      cards: { reminders: somewhereElse, activeJobs: { x: 0, y: 0, w: 12, h: 6 } },
+      cards: {
+        // Already placed, so the card added since has no room to make.
+        calendarEvents: { x: 0, y: 50, w: 12, h: 5 },
+        reminders: somewhereElse,
+        activeJobs: { x: 0, y: 0, w: 12, h: 6 },
+      },
     })
 
     expect(layout.cards.reminders).toEqual(somewhereElse)
@@ -170,6 +175,114 @@ describe('moved defaults', () => {
   })
 })
 
+/**
+ * A card added after somebody arranged their dashboard has to find a place
+ * among their cards, not drop onto its default row on top of one of them.
+ */
+describe('added cards', () => {
+  /** A layout saved before the calendar card existed, every card where version 2 put it. */
+  const savedBeforeCalendar = (cards: Record<string, CardLayout> = {}) => {
+    const v2: Record<string, CardLayout> = {}
+    for (const id of DASHBOARD_CARD_IDS) {
+      if (id === 'calendarEvents') continue
+      const { y, ...rest } = DEFAULT_LAYOUT.cards[id]
+      // Version 2 had every card five rows higher, with nothing at row 4.
+      v2[id] = { ...rest, y: y >= 9 ? y - 5 : y }
+    }
+    return { version: 2, hidden: [], cards: { ...v2, ...cards } }
+  }
+
+  it('puts the calendar across the row above the reminders, wherever they are', () => {
+    // Moved under everything else.
+    const layout = normalizeLayout(
+      savedBeforeCalendar({
+        reminders: { x: 0, y: 60, w: 6, h: 4 },
+        maintenance: { x: 6, y: 60, w: 6, h: 4 },
+      })
+    )
+
+    expect(layout.cards.calendarEvents).toEqual({ x: 0, y: 60, w: 12, h: 5 })
+    expect(layout.cards.reminders.y).toBe(65)
+    expect(layout.cards.maintenance.y).toBe(65)
+    expect(layout.cards.activeJobs).toEqual(savedBeforeCalendar().cards.activeJobs)
+  })
+
+  it('leaves the cards above it alone and keeps the rest in order below', () => {
+    const before = savedBeforeCalendar()
+    const layout = normalizeLayout(before)
+    const row = layout.cards.calendarEvents.y
+
+    for (const [id, card] of Object.entries(before.cards)) {
+      const after = layout.cards[id]
+      if (card.y < row) expect(after, `${id} moved`).toEqual(card)
+      else expect(after, `${id} lost its place`).toEqual({ ...card, y: card.y + 5 })
+    }
+  })
+
+  it('never lands in the middle of a card that runs across its row', () => {
+    // A tall card beside the reminders, starting two rows above them.
+    const layout = normalizeLayout(
+      savedBeforeCalendar({
+        reminders: { x: 0, y: 62, w: 6, h: 4 },
+        maintenance: { x: 6, y: 60, w: 6, h: 8 },
+      })
+    )
+
+    const ids = DASHBOARD_CARD_IDS.filter((id) => id !== 'notifications')
+    for (let i = 0; i < ids.length; i++) {
+      for (let j = i + 1; j < ids.length; j++) {
+        expect(
+          overlaps(layout.cards[ids[i]], layout.cards[ids[j]]),
+          `${ids[i]} overlaps ${ids[j]} after adding the calendar`
+        ).toBe(false)
+      }
+    }
+    expect(layout.cards.calendarEvents.y).toBe(60)
+  })
+
+  it("makes room among the user's own cards too", () => {
+    // A custom card beside the reminders, on their row.
+    const widget: CardLayout = { x: 6, y: 60, w: 6, h: 4 }
+    const layout = normalizeLayout(
+      savedBeforeCalendar({
+        reminders: { x: 0, y: 60, w: 6, h: 4 },
+        maintenance: { x: 0, y: 64, w: 6, h: 4 },
+        'custom:w1': widget,
+      }),
+      ['custom:w1', 'custom:w2']
+    )
+
+    expect(layout.cards.calendarEvents.y).toBe(60)
+    expect(layout.cards['custom:w1']).toEqual({ ...widget, y: 65 })
+    expect(overlaps(layout.cards.calendarEvents, layout.cards['custom:w1'])).toBe(false)
+    // A widget made since then goes under everything.
+    const bottom = Math.max(
+      ...Object.entries(layout.cards)
+        .filter(([id]) => id !== 'custom:w2')
+        .map(([, c]) => c.y + c.h)
+    )
+    expect(layout.cards['custom:w2'].y).toBe(bottom)
+  })
+
+  it('keeps the calendar where the user put it once it is saved', () => {
+    const mine: CardLayout = { x: 6, y: 40, w: 6, h: 6 }
+    const layout = normalizeLayout({
+      version: LAYOUT_VERSION,
+      hidden: [],
+      cards: { ...savedBeforeCalendar().cards, calendarEvents: mine },
+    })
+
+    expect(layout.cards.calendarEvents).toEqual(mine)
+  })
+
+  it('gives somebody who never arranged anything the default dashboard', () => {
+    expect(normalizeLayout(null).cards).toEqual(DEFAULT_LAYOUT.cards)
+    expect(normalizeLayout({ version: 2, hidden: [], cards: {} }).cards).toEqual(
+      DEFAULT_LAYOUT.cards
+    )
+  })
+})
+
 /**
  * Cards are drawn only as tall as their content needs, so a height the user
  * set by hand has to survive the round trip or the resize handle looks

+ 22 - 1
src/__tests__/lib/backup/build-data.test.ts

@@ -95,6 +95,8 @@ describe('organisation scope', () => {
       'vehicles',
       'orgReminders',
       'counterSales',
+      'appointments',
+      'appointmentNotifications',
       'quotes',
       'technicians',
       'workBays',
@@ -129,6 +131,13 @@ describe('JSON shape', () => {
     state.pages.documentAssetSnapshot = [
       [{ id: 'a1', organizationId: 'org-1', data: new Uint8Array([1, 2, 3]) }],
     ]
+    const startAt = new Date('2026-11-20T09:00:00.000Z')
+    state.pages.appointment = [
+      [{ id: 'ap1', title: 'Test drive', startAt, customerId: 'c1', organizationId: 'org-1' }],
+    ]
+    state.pages.appointmentNotification = [
+      [{ id: 'an1', channel: 'email', amount: 1, unit: 'days', appointmentId: 'ap1' }],
+    ]
 
     const { json, raw, result } = await run({
       ...NONE,
@@ -151,9 +160,21 @@ describe('JSON shape', () => {
         vehicles: [],
         orgReminders: [],
         counterSales: [],
+        appointments: [
+          {
+            id: 'ap1',
+            title: 'Test drive',
+            startAt: '2026-11-20T09:00:00.000Z',
+            customerId: 'c1',
+            organizationId: 'org-1',
+          },
+        ],
+        appointmentNotifications: [
+          { id: 'an1', channel: 'email', amount: 1, unit: 'days', appointmentId: 'ap1' },
+        ],
       },
     })
-    expect(result.rows).toBe(2)
+    expect(result.rows).toBe(4)
   })
 
   it('says in the header whether the secrets are in it, before the data', async () => {

+ 125 - 0
src/__tests__/lib/backup/import-tenancy.test.ts

@@ -13,6 +13,8 @@ const state = vi.hoisted(() => ({
   foreign: {} as Record<string, string[]>,
   /** Rows this workshop already has, by model. */
   own: {} as Record<string, string[]>,
+  /** People who are members of this workshop. */
+  members: [] as string[],
   calls: [] as { model: string; method: string; args: any }[],
   cloud: false,
   role: 'admin',
@@ -28,6 +30,10 @@ vi.mock('@/lib/db', () => {
           {
             get: (_t, method: string) => async (args: any) => {
               state.calls.push({ model, method, args })
+              if (method === 'findMany' && model === 'organizationMember') {
+                const ids: string[] = args?.where?.userId?.in ?? []
+                return state.members.filter((id) => ids.includes(id)).map((userId) => ({ userId }))
+              }
               if (method === 'findMany' && args?.where?.id?.in) {
                 const ids: string[] = args.where.id.in
                 const table = 'NOT' in args.where ? state.foreign : state.own
@@ -114,6 +120,7 @@ function file() {
 beforeEach(() => {
   state.foreign = {}
   state.own = {}
+  state.members = []
   state.calls = []
   state.cloud = false
   state.role = 'admin'
@@ -171,6 +178,124 @@ describe('a file whose foreign keys point at another workshop', () => {
   })
 })
 
+describe('appointments and their alerts', () => {
+  /** The workshop file, with the calendar: an appointment and three alerts. */
+  function withCalendar() {
+    return {
+      ...file(),
+      appointments: [
+        {
+          id: 'appt-a',
+          title: 'Test drive',
+          startAt: '2026-11-20T09:00:00.000Z',
+          endAt: '2026-11-20T10:00:00.000Z',
+          customerId: 'cust-a',
+          vehicleId: 'veh-of-org-b',
+          createdById: 'user-member',
+        },
+      ],
+      appointmentNotifications: [
+        {
+          id: 'alert-appt',
+          channel: 'email',
+          amount: 1,
+          unit: 'days',
+          atTime: '16:00',
+          sentForStart: '2026-11-20T09:00:00.000Z',
+          appointmentId: 'appt-a',
+          userId: 'user-elsewhere',
+        },
+        {
+          id: 'alert-job',
+          channel: 'in_app',
+          amount: 30,
+          unit: 'minutes',
+          serviceRecordId: 'job-a',
+        },
+        {
+          id: 'alert-theirs',
+          channel: 'in_app',
+          amount: 2,
+          unit: 'hours',
+          serviceRecordId: 'job-of-org-b',
+        },
+      ],
+    }
+  }
+
+  it('clears the alerts first, and never writes a link to another workshop', async () => {
+    // What the restore finds in this workshop once the file's rows are in.
+    state.own = { customer: ['cust-a'], appointment: ['appt-a'], serviceRecord: ['job-a'] }
+    state.members = ['user-member']
+    const response = await importJson(withCalendar())
+    expect(response.status).toBe(200)
+
+    const cleared = writes()
+      .filter((c) => c.method === 'deleteMany')
+      .map((c) => c.model)
+    expect(cleared.indexOf('appointmentNotification')).toBeGreaterThanOrEqual(0)
+    expect(cleared.indexOf('appointmentNotification')).toBeLessThan(cleared.indexOf('appointment'))
+    expect(cleared.indexOf('appointment')).toBeLessThan(cleared.indexOf('vehicle'))
+    expect(cleared.indexOf('appointment')).toBeLessThan(cleared.indexOf('customer'))
+
+    // The vehicle was someone else's, so the appointment comes back without it;
+    // the person who booked it is a member here and stays.
+    const appointments = state.calls.find(
+      (c) => c.model === 'appointment' && c.method === 'createMany'
+    )
+    expect(appointments?.args.data).toEqual([
+      expect.objectContaining({
+        id: 'appt-a',
+        title: 'Test drive',
+        organizationId: 'org-a',
+        customerId: 'cust-a',
+        vehicleId: null,
+        createdById: 'user-member',
+        startAt: new Date('2026-11-20T09:00:00.000Z'),
+      }),
+    ])
+
+    // The alert on someone else's job points at nothing here and is left out;
+    // the one set by a person who is not a member goes to the importing user,
+    // and remembers which start it already went out for.
+    const alerts = state.calls.find(
+      (c) => c.model === 'appointmentNotification' && c.method === 'createMany'
+    )
+    expect(
+      alerts?.args.data.map((n: Record<string, unknown>) => [
+        n.id,
+        n.appointmentId,
+        n.serviceRecordId,
+        n.userId,
+        n.organizationId,
+      ])
+    ).toEqual([
+      ['alert-appt', 'appt-a', null, 'user-1', 'org-a'],
+      ['alert-job', null, 'job-a', 'user-1', 'org-a'],
+    ])
+    expect(alerts?.args.data[0].sentForStart).toEqual(new Date('2026-11-20T09:00:00.000Z'))
+    expect(JSON.stringify(writes())).not.toContain('org-b')
+  })
+
+  it('leaves out an alert whose appointment did not come back', async () => {
+    // An appointment with no readable time is not restored, so after the
+    // write the workshop has the job but not the appointment.
+    state.own = { customer: ['cust-a'], serviceRecord: ['job-a'] }
+    const data = withCalendar()
+    data.appointments[0].startAt = 'not a date'
+    const response = await importJson(data)
+    expect(response.status).toBe(200)
+
+    expect(state.calls.some((c) => c.model === 'appointment' && c.method === 'createMany')).toBe(
+      false
+    )
+    const alerts = state.calls.find(
+      (c) => c.model === 'appointmentNotification' && c.method === 'createMany'
+    )
+    expect(alerts?.args.data.map((n: Record<string, unknown>) => n.id)).toEqual(['alert-job'])
+  })
+})
+
 describe('one restore at a time', () => {
   it('tells a second restore the server is busy', async () => {
     expect(acquireRestoreLock('another-restore')).toBe(true)

+ 319 - 0
src/__tests__/lib/cron/appointment-notifications.test.ts

@@ -0,0 +1,319 @@
+/**
+ * The minute-by-minute run that sends the notifications set on appointments
+ * and booked jobs: each goes out once, at its minute, through the channel
+ * chosen, and one that fails does not hold up the rest.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The run builds its texts with next-intl's own translator, which the global
+// test mock leaves out.
+vi.mock('next-intl', async (importOriginal) => importOriginal())
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'Europe/Oslo' }))
+vi.mock('@/i18n/locale-from-request', () => ({ resolveDocumentLocale: async () => 'en' }))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn() }))
+vi.mock('@/lib/email', () => ({ sendOrgMail: vi.fn(), getOrgFromAddress: vi.fn() }))
+vi.mock('@/features/scheduled-messages/Lib/dispatchScheduledMessage', () => ({
+  dispatchScheduledMessage: vi.fn(),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    appointmentNotification: { findMany: vi.fn(), updateMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    appSetting: { findUnique: vi.fn() },
+  },
+}))
+
+import { db } from '@/lib/db'
+import { notify } from '@/lib/notify'
+import { getOrgFromAddress, sendOrgMail } from '@/lib/email'
+import { dispatchScheduledMessage } from '@/features/scheduled-messages/Lib/dispatchScheduledMessage'
+import { processDueAppointmentNotifications } from '@/lib/cron/appointment-notifications'
+
+const ORG = 'org-1'
+/** Monday 16 November 2026, 09:30 in Oslo. */
+const START = new Date('2026-11-16T08:30:00Z')
+/** An hour before it. */
+const HOUR_BEFORE = new Date('2026-11-16T07:30:00Z')
+
+type Row = Record<string, unknown> & { id: string; sentForStart: Date | null }
+
+function appointmentRow(overrides: Partial<Row> = {}): Row {
+  return {
+    id: 'n-1',
+    channel: 'in_app',
+    amount: 1,
+    unit: 'hours',
+    atTime: null,
+    sentForStart: null,
+    updatedAt: new Date('2026-11-10T12:00:00Z'),
+    organizationId: ORG,
+    user: { email: 'kari@shop.example' },
+    appointment: {
+      id: 'appt-1',
+      title: 'Brake check',
+      startAt: START,
+      customerId: 'cust-1',
+      vehicleId: 'veh-1',
+      customer: { name: 'Ola Nordmann' },
+      vehicle: { year: 2012, make: 'Volvo', model: 'V70', licensePlate: 'AB12345' },
+    },
+    serviceRecord: null,
+    ...overrides,
+  }
+}
+
+function jobRow(overrides: Partial<Row> = {}): Row {
+  return {
+    ...appointmentRow(),
+    id: 'n-job',
+    appointment: null,
+    serviceRecord: {
+      id: 'job-1',
+      title: 'Timing belt',
+      startDateTime: START,
+      invoiceNumber: '2026-1044',
+      customerId: null,
+      vehicleId: 'veh-1',
+      customer: null,
+      vehicle: {
+        year: 2012,
+        make: 'Volvo',
+        model: 'V70',
+        licensePlate: 'AB12345',
+        customerId: 'cust-1',
+        customer: { name: 'Ola Nordmann' },
+      },
+    },
+    ...overrides,
+  }
+}
+
+/**
+ * The notifications table as the run sees it: read each minute, and a claim
+ * stamps the start it went out for, as the database would.
+ */
+function table(rows: Row[]) {
+  vi.mocked(db.appointmentNotification.findMany).mockImplementation((async () =>
+    rows.map((r) => ({ ...r }))) as never)
+  vi.mocked(db.appointmentNotification.updateMany).mockImplementation((async (args: {
+    where: { id: string }
+    data: { sentForStart: Date }
+  }) => {
+    const row = rows.find((r) => r.id === args.where.id)
+    if (!row || row.sentForStart?.getTime() === args.data.sentForStart.getTime()) {
+      return { count: 0 }
+    }
+    row.sentForStart = args.data.sentForStart
+    return { count: 1 }
+  }) as never)
+  return rows
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Test Shop' } as never)
+  // No time format chosen: the 12-hour clock.
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
+  vi.mocked(getOrgFromAddress).mockResolvedValue('Test Shop <shop@example.com>')
+  // A failing send is logged; keep the test output quiet.
+  vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+describe('a notification in the app', () => {
+  it('goes out an hour before, once, and is marked sent for that start', async () => {
+    const rows = table([appointmentRow()])
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(1)
+    expect(notify).toHaveBeenCalledTimes(1)
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        organizationId: ORG,
+        type: 'appointment.due',
+        title: 'Coming up: Brake check',
+        entityType: 'Appointment',
+        entityId: 'appt-1',
+        entityUrl: '/calendar?view=day&date=2026-11-16',
+      })
+    )
+    const message = vi.mocked(notify).mock.calls[0][0].message
+    expect(message).toContain('Ola Nordmann')
+    expect(message).toContain('2012 Volvo V70 (AB12345)')
+    expect(db.appointmentNotification.updateMany).toHaveBeenCalledWith({
+      where: { id: 'n-1', OR: [{ sentForStart: null }, { sentForStart: { not: START } }] },
+      data: { sentForStart: START },
+    })
+    expect(rows[0].sentForStart).toEqual(START)
+
+    // The next run in the same minute finds it already sent.
+    const again = await processDueAppointmentNotifications(new Date(HOUR_BEFORE.getTime() + 30_000))
+    expect(again).toBe(0)
+    expect(notify).toHaveBeenCalledTimes(1)
+  })
+
+  it("says when on the workshop's own clock, 12-hour unless it chose 24-hour", async () => {
+    table([appointmentRow()])
+    await processDueAppointmentNotifications(HOUR_BEFORE)
+    expect(vi.mocked(notify).mock.calls[0][0].message).toContain('Monday, November 16 at 09:30 AM')
+
+    vi.mocked(notify).mockClear()
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: '24h' } as never)
+    table([appointmentRow()])
+    await processDueAppointmentNotifications(HOUR_BEFORE)
+    const message = vi.mocked(notify).mock.calls[0][0].message
+    expect(message).toContain('Monday, November 16 at 09:30')
+    expect(message).not.toContain('AM')
+    expect(db.appSetting.findUnique).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId_key: { organizationId: ORG, key: 'workshop.timeFormat' } },
+      })
+    )
+  })
+
+  it('sends nothing when another server claimed it first', async () => {
+    vi.mocked(db.appointmentNotification.findMany).mockResolvedValue([appointmentRow()] as never)
+    vi.mocked(db.appointmentNotification.updateMany).mockResolvedValue({ count: 0 } as never)
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(0)
+    expect(notify).not.toHaveBeenCalled()
+  })
+
+  it('waits until its minute comes', async () => {
+    table([appointmentRow()])
+
+    const sent = await processDueAppointmentNotifications(new Date('2026-11-16T07:29:00Z'))
+
+    expect(sent).toBe(0)
+    expect(notify).not.toHaveBeenCalled()
+    expect(db.appointmentNotification.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('is not sent once the appointment has started', async () => {
+    table([appointmentRow()])
+
+    const sent = await processDueAppointmentNotifications(new Date('2026-11-16T08:31:00Z'))
+
+    expect(sent).toBe(0)
+    expect(notify).not.toHaveBeenCalled()
+    expect(db.appointmentNotification.updateMany).not.toHaveBeenCalled()
+  })
+})
+
+describe('an email notification', () => {
+  it('goes to whoever set it, with the title made safe for HTML', async () => {
+    table([
+      appointmentRow({
+        channel: 'email',
+        appointment: {
+          ...(appointmentRow().appointment as Record<string, unknown>),
+          title: 'Brakes <b>& pads</b>',
+        },
+      }),
+    ])
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(1)
+    expect(sendOrgMail).toHaveBeenCalledTimes(1)
+    const [org, mail] = vi.mocked(sendOrgMail).mock.calls[0]
+    expect(org).toBe(ORG)
+    expect(mail).toMatchObject({ from: 'Test Shop <shop@example.com>', to: 'kari@shop.example' })
+    expect(mail.subject).toMatch(/^Coming up: Brakes <b>& pads<\/b>, Monday/)
+    expect(mail.html).toContain('Brakes &lt;b&gt;&amp; pads&lt;/b&gt;')
+    expect(mail.html).not.toContain('<b>&')
+    expect(notify).not.toHaveBeenCalled()
+  })
+})
+
+describe('a notification to the customer', () => {
+  it("goes out by SMS through the workshop's own messaging, to the appointment's customer", async () => {
+    table([appointmentRow({ channel: 'customer_sms' })])
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(1)
+    expect(dispatchScheduledMessage).toHaveBeenCalledWith(
+      expect.objectContaining({
+        channel: 'sms',
+        organizationId: ORG,
+        customerId: 'cust-1',
+        vehicleId: 'veh-1',
+        recipient: null,
+      })
+    )
+    const { body } = vi.mocked(dispatchScheduledMessage).mock.calls[0][0]
+    expect(body).toContain('Hi Ola Nordmann')
+    expect(body).toContain('Test Shop')
+  })
+
+  it('logs a send that fails and carries on with the rest', async () => {
+    table([
+      appointmentRow({ id: 'n-sms', channel: 'customer_sms' }),
+      appointmentRow({ id: 'n-bell', channel: 'in_app' }),
+    ])
+    vi.mocked(dispatchScheduledMessage).mockRejectedValue(new Error('SMS provider down'))
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(1)
+    expect(console.error).toHaveBeenCalledWith(
+      expect.stringContaining('customer_sms for n-sms failed'),
+      'SMS provider down'
+    )
+    expect(notify).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('a booked job', () => {
+  it("goes out by the job's start, and opens the job", async () => {
+    table([jobRow()])
+
+    const now = HOUR_BEFORE
+    const sent = await processDueAppointmentNotifications(now)
+
+    expect(sent).toBe(1)
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        title: 'Coming up: Timing belt',
+        entityType: 'ServiceRecord',
+        entityId: 'job-1',
+        entityUrl: '/vehicles/veh-1/service/job-1',
+      })
+    )
+    // Only jobs still booked are read: once the car is in, nothing more goes out.
+    const where = vi.mocked(db.appointmentNotification.findMany).mock.calls[0][0]?.where
+    expect(where?.OR).toContainEqual({
+      serviceRecord: {
+        status: 'scheduled',
+        startDateTime: { gt: now, lte: expect.any(Date) },
+      },
+    })
+  })
+
+  it("reaches the customer through the job's car when the job names no customer", async () => {
+    table([jobRow({ channel: 'customer_email' })])
+
+    await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(dispatchScheduledMessage).toHaveBeenCalledWith(
+      expect.objectContaining({ channel: 'email', customerId: 'cust-1', vehicleId: 'veh-1' })
+    )
+  })
+
+  it('moves with the job: a job moved later is not due at the old time', async () => {
+    const moved = jobRow()
+    ;(moved.serviceRecord as { startDateTime: Date }).startDateTime = new Date(
+      '2026-11-16T12:00:00Z'
+    )
+    table([moved])
+
+    const sent = await processDueAppointmentNotifications(HOUR_BEFORE)
+
+    expect(sent).toBe(0)
+    expect(notify).not.toHaveBeenCalled()
+  })
+})

+ 18 - 0
src/app/(authenticated)/dashboard-client.tsx

@@ -98,6 +98,9 @@ import { useConfirm } from '@/components/confirm-dialog'
 import { GettingStartedCard } from '@/features/onboarding/Components/GettingStartedCard'
 import type { OnboardingChecklistData } from '@/features/onboarding/Actions/checklistActions'
 import { useServiceLabels } from '@/features/vehicles/Lib/useServiceLabels'
+import { CalendarEventsCard } from '@/features/calendar/Components/CalendarEventsCard'
+import { isEventDone } from '@/features/calendar/Components/calendar-utils'
+import type { CalendarEvent } from '@/features/calendar/Actions/calendarActions'
 
 interface ServiceItem {
   id: string
@@ -301,6 +304,7 @@ export function DashboardClient({
   onboardingChecklist = null,
   tireHotelSummary = null,
   inspectionsDue = null,
+  calendarEvents = null,
 }: {
   stats: DashboardStats
   currencyCode?: string
@@ -335,6 +339,8 @@ export function DashboardClient({
   tireHotelSummary?: TireHotelSummary | null
   /** Null until a registry has put an inspection date on a vehicle, which hides the card. */
   inspectionsDue?: InspectionsDueSummary | null
+  /** The days ahead on the calendar. Null when this role may not open the calendar, which hides the card. */
+  calendarEvents?: { events: CalendarEvent[]; todayKey: string; days: number } | null
 }) {
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('dashboard')
@@ -389,6 +395,7 @@ export function DashboardClient({
     ...(tireHotelSummary ? [] : (['tireHotel'] as const)),
     ...(inspectionsDue ? [] : (['inspectionsDue'] as const)),
     ...(serviceRequests ? [] : (['serviceRequests'] as const)),
+    ...(calendarEvents ? [] : (['calendarEvents'] as const)),
   ])
   const availableIds = DASHBOARD_CARD_IDS.filter((id) => !excludedIds.has(id))
   const hiddenSet = new Set<string>(layout.hidden)
@@ -478,6 +485,7 @@ export function DashboardClient({
     vehiclesDueForService.length === 0 && dismissedMaintenanceVehicles.length === 0
       ? 'maintenance'
       : null,
+    !calendarEvents?.events.some((e) => !isEventDone(e)) ? 'calendarEvents' : null,
     upcomingReminders.length === 0 ? 'reminders' : null,
     smsThreads.length === 0 ? 'sms' : null,
     notifications.length === 0 ? 'notifications' : null,
@@ -878,6 +886,16 @@ export function DashboardClient({
               </AppCard>
             ),
 
+            // The days ahead on the calendar (only offered when it may be read; see availableIds)
+            calendarEvents: calendarEvents ? (
+              <CalendarEventsCard
+                events={calendarEvents.events}
+                todayKey={calendarEvents.todayKey}
+                days={calendarEvents.days}
+                currencyCode={currencyCode}
+              />
+            ) : null,
+
             // Upcoming Reminders
             reminders: (
               <AppCard

+ 24 - 0
src/app/(authenticated)/page.tsx

@@ -33,6 +33,12 @@ import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { PageHeader } from '@/components/page-header'
 import { getTireHotelSummary } from '@/features/tire-hotel/Actions/getTireHotelSummary'
 import { getInspectionsDueSummary } from '@/features/vehicles/Actions/inspectionStatusActions'
+import { getCalendarEvents } from '@/features/calendar/Actions/calendarActions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { addZonedDays, zonedDayKey } from '@/lib/timezone'
+
+/** Days the calendar card looks ahead, today included. */
+const CALENDAR_CARD_DAYS = 7
 
 export default async function DashboardPage() {
   const auth = await getAuthContext()
@@ -54,6 +60,13 @@ export default async function DashboardPage() {
   const access = await getViewerAccess()
   const S = PermissionSubject
 
+  // The week ahead on the workshop's own clock: a server in UTC must not
+  // start "today" at 01:00 Oslo time.
+  const timeZone = auth ? await workshopTimeZone(auth.organizationId) : 'UTC'
+  const now = new Date()
+  const todayKey = zonedDayKey(now, timeZone)
+  const lastDayKey = zonedDayKey(addZonedDays(now, CALENDAR_CARD_DAYS - 1, timeZone), timeZone)
+
   const [
     result,
     settingsResult,
@@ -73,6 +86,7 @@ export default async function DashboardPage() {
     tireHotelResult,
     serviceRequestsResult,
     inspectionsDueResult,
+    calendarEventsResult,
   ] = await Promise.all([
     readIfAllowed(access, S.DASHBOARD, () => getDashboardStats()),
     getDisplaySettings([
@@ -102,6 +116,11 @@ export default async function DashboardPage() {
       ? getPendingServiceRequests()
       : Promise.resolve(null),
     readIfAllowed(access, S.VEHICLES, () => getInspectionsDueSummary()),
+    // The calendar's own read: whoever may open the calendar sees its card,
+    // and the feed leaves out the kinds of record their role cannot read.
+    readIfAllowed(access, S.SERVICES, () =>
+      getCalendarEvents({ start: todayKey, end: lastDayKey })
+    ),
   ])
 
   const [layoutUser, widgetRows] = auth
@@ -236,6 +255,11 @@ export default async function DashboardPage() {
               ? inspectionsDueResult.data
               : null
           }
+          calendarEvents={
+            calendarEventsResult.success && calendarEventsResult.data
+              ? { events: calendarEventsResult.data, todayKey, days: CALENDAR_CARD_DAYS }
+              : null
+          }
         />
       </div>
     </>

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

@@ -286,7 +286,7 @@ export function CompanySettings({
         </SaveButton>
       </ReadOnlyWrapper>
       <Dialog open={showCreateOrg} onOpenChange={setShowCreateOrg}>
-        <DialogContent className="sm:max-w-md">
+        <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{t('company.addNewCompany')}</DialogTitle>
           </DialogHeader>

+ 2 - 2
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -748,7 +748,7 @@ export function DataSettings({
           }
         }}
       >
-        <DialogContent className="sm:max-w-lg">
+        <DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-3">
               <Image
@@ -1181,7 +1181,7 @@ export function DataSettings({
           }
         }}
       >
-        <DialogContent className="sm:max-w-lg">
+        <DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-3">
               <Image

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

@@ -631,7 +631,7 @@ export function ReportScheduleSettings({ schedules, members }: Props) {
 
       {/* Create / Edit dialog */}
       <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
-        <DialogContent className="sm:max-w-2xl">
+        <DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{editingSchedule ? t('editSchedule') : t('newSchedule')}</DialogTitle>
           </DialogHeader>

+ 1 - 1
src/app/(authenticated)/tire-hotel/storage/storage-client.tsx

@@ -331,7 +331,7 @@ export function StorageClient({
       )}
 
       <Dialog open={showWarehouse} onOpenChange={setShowWarehouse}>
-        <DialogContent className="sm:max-w-md">
+        <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{t('storage.addWarehouse')}</DialogTitle>
           </DialogHeader>

+ 1 - 1
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -1774,7 +1774,7 @@ export function VehicleDetailClient({
           if (!open) setSelectedNote(null)
         }}
       >
-        <DialogContent className="sm:max-w-3xl">
+        <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
               {selectedNote?.isPinned && <Pin className="h-4 w-4 text-primary" />}

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

@@ -752,7 +752,7 @@ export function AppSidebar({
       {isAdminOrOwner && <NotificationPanel />}
 
       <Dialog open={showCreateOrg} onOpenChange={setShowCreateOrg}>
-        <DialogContent className="sm:max-w-md">
+        <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{t('sidebar.addNewCompany')}</DialogTitle>
           </DialogHeader>

+ 6 - 1
src/components/ui/rich-text-editor.tsx

@@ -23,6 +23,8 @@ interface RichTextEditorProps {
   content: string
   onChange: (html: string) => void
   placeholder?: string
+  /** Extra classes for the writing area, such as a taller minimum height. */
+  contentClassName?: string
 }
 
 function ToolbarButton({
@@ -54,6 +56,7 @@ export function RichTextEditor({
   content,
   onChange,
   placeholder = 'Write something...',
+  contentClassName,
 }: RichTextEditorProps) {
   const editor = useEditor({
     immediatelyRender: false,
@@ -73,8 +76,10 @@ export function RichTextEditor({
     editorProps: {
       handleClick: openLinkOnModifierClick,
       attributes: {
-        class:
+        class: cn(
           'tiptap-content min-h-[120px] max-h-[40vh] overflow-y-auto overflow-x-hidden px-3 py-2 text-sm outline-none break-all',
+          contentClassName
+        ),
       },
     },
   })

+ 1 - 0
src/cronTasks.ts

@@ -10,6 +10,7 @@ export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/w
 export { checkLowStock } from './lib/cron/low-stock-alerts'
 export { checkDueReminders } from './lib/cron/reminder-alerts'
 export { processScheduledMessages } from './lib/cron/scheduled-messages'
+export { processAppointmentNotifications } from './lib/cron/appointment-notifications'
 export { processIntegrationJobs, cleanupIntegrationLogs } from './lib/cron/integration-jobs'
 export { processBackupRuns } from './lib/cron/backup-runs'
 export { syncWorkshopHolidays } from './lib/cron/workshop-holidays'

+ 1 - 1
src/features/billing/Components/RecurringInvoicesClient.tsx

@@ -574,7 +574,7 @@ export default function RecurringInvoicesClient({
 
       {/* Create dialog */}
       <Dialog open={showCreate} onOpenChange={setShowCreate}>
-        <DialogContent className="max-h-[85vh] sm:max-w-2xl">
+        <DialogContent className="max-h-[85vh] sm:max-w-2xl" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{t('recurring.dialogTitle')}</DialogTitle>
           </DialogHeader>

+ 1 - 38
src/features/calendar/Actions/bookAppointment.ts

@@ -6,12 +6,7 @@ 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 { resourceFor } from '../Lib/booking.server'
 import { bookAppointmentSchema, type BookAppointmentInput } from '../Schema/appointmentSchema'
 import { appointmentConcerns, appointmentTitle } from '../Lib/appointment'
 
@@ -116,35 +111,3 @@ export async function bookAppointment(input: BookAppointmentInput) {
     }
   )
 }
-
-/**
- * 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 }
-}

+ 191 - 115
src/features/calendar/Actions/calendarActions.ts

@@ -6,6 +6,8 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getManifest } from '@/integrations/registry'
+import { accessFor } from '@/lib/viewer-access'
+import { sanitizeHtml } from '@/lib/sanitize-html'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { addZonedDays, zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
 import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
@@ -21,6 +23,8 @@ export type CalendarEventType =
   | 'closure'
   /** A technician on leave or out of office. */
   | 'absence'
+  /** An appointment with no job behind it: a talk, a test drive, a visit. */
+  | 'appointment'
 
 export type CalendarEvent = {
   id: string
@@ -38,6 +42,10 @@ export type CalendarEvent = {
   customerName: string | null
   invoiceNumber: string | null
   amount: number | null
+  /** Only on appointments: what it is about, as cleaned rich text. */
+  description?: string | null
+  /** Appointments and booked jobs: how many notifications go out before it. */
+  notificationCount?: number
   /** Only on external events: whether it fills the day, where it came from. */
   allDay?: boolean
   source?: string | null
@@ -62,7 +70,8 @@ function toTimeStr(date: Date, timeZone: string): string {
  */
 export async function getCalendarEvents(params: { start: string; end: string }) {
   return withAuth(
-    async ({ organizationId }) => {
+    async (ctx) => {
+      const { organizationId } = ctx
       const timeZone = await workshopTimeZone(organizationId)
       const startKey = parseDayKey(params.start)
       const endKey = parseDayKey(params.end)
@@ -78,125 +87,167 @@ export async function getCalendarEvents(params: { start: string; end: string })
       )
       const inRange = { gte: start, lt: endExclusive }
 
-      const [services, reminders, quotes, scheduledMessages, external] = await Promise.all([
-        db.serviceRecord.findMany({
-          where: {
-            organizationId,
-            OR: [
-              { startDateTime: inRange },
-              // A job entered with only a service date, never put on the
-              // board, still belongs on its day.
-              { startDateTime: null, serviceDate: inRange },
-            ],
-          },
-          select: {
-            id: true,
-            title: true,
-            serviceDate: true,
-            startDateTime: true,
-            endDateTime: true,
-            status: true,
-            invoiceNumber: true,
-            totalAmount: true,
-            cost: true,
-            vehicleId: true,
-            customer: { select: { name: true } },
-            vehicle: {
-              select: {
-                make: true,
-                model: true,
-                year: true,
-                customer: { select: { name: true } },
-              },
+      // Seeing the calendar is reading jobs. What else it lists is somebody
+      // else's record and follows that record's own read: a role without
+      // quotes sees no quote totals here, one without customers no messages
+      // to them. Closed days, time off and connected calendars are the
+      // workshop's day, which everyone who plans it needs.
+      const access = await accessFor(ctx)
+      const sees = {
+        reminders: access.reads(PermissionSubject.VEHICLES),
+        quotes: access.reads(PermissionSubject.QUOTES),
+        messages: access.reads(PermissionSubject.CUSTOMERS),
+      }
+
+      const [services, reminders, quotes, scheduledMessages, external, appointments] =
+        await Promise.all([
+          db.serviceRecord.findMany({
+            where: {
+              organizationId,
+              OR: [
+                { startDateTime: inRange },
+                // A job entered with only a service date, never put on the
+                // board, still belongs on its day.
+                { startDateTime: null, serviceDate: inRange },
+              ],
             },
-          },
-          orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
-        }),
-        db.reminder.findMany({
-          where: {
-            organizationId,
-            dueDate: inRange,
-          },
-          select: {
-            id: true,
-            title: true,
-            dueDate: true,
-            hasDueTime: true,
-            isCompleted: true,
-            vehicleId: true,
-            customer: { select: { name: true } },
-            vehicle: {
-              select: {
-                make: true,
-                model: true,
-                year: true,
-                customer: { select: { name: true } },
+            select: {
+              id: true,
+              title: true,
+              serviceDate: true,
+              startDateTime: true,
+              endDateTime: true,
+              status: true,
+              invoiceNumber: true,
+              totalAmount: true,
+              cost: true,
+              vehicleId: true,
+              _count: { select: { appointmentNotifications: true } },
+              customer: { select: { name: true } },
+              vehicle: {
+                select: {
+                  make: true,
+                  model: true,
+                  year: true,
+                  customer: { select: { name: true } },
+                },
               },
             },
-          },
-          orderBy: { dueDate: 'asc' },
-        }),
-        db.quote.findMany({
-          where: {
-            organizationId,
-            validUntil: inRange,
-            status: { notIn: ['converted', 'rejected'] },
-          },
-          select: {
-            id: true,
-            title: true,
-            quoteNumber: true,
-            validUntil: true,
-            status: true,
-            totalAmount: true,
-            vehicleId: true,
-            vehicle: {
-              select: {
-                make: true,
-                model: true,
-                year: true,
-                customer: { select: { name: true } },
+            orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
+          }),
+          sees.reminders
+            ? db.reminder.findMany({
+                where: {
+                  organizationId,
+                  dueDate: inRange,
+                },
+                select: {
+                  id: true,
+                  title: true,
+                  dueDate: true,
+                  hasDueTime: true,
+                  isCompleted: true,
+                  vehicleId: true,
+                  customer: { select: { name: true } },
+                  vehicle: {
+                    select: {
+                      make: true,
+                      model: true,
+                      year: true,
+                      customer: { select: { name: true } },
+                    },
+                  },
+                },
+                orderBy: { dueDate: 'asc' },
+              })
+            : [],
+          sees.quotes
+            ? db.quote.findMany({
+                where: {
+                  organizationId,
+                  validUntil: inRange,
+                  status: { notIn: ['converted', 'rejected'] },
+                },
+                select: {
+                  id: true,
+                  title: true,
+                  quoteNumber: true,
+                  validUntil: true,
+                  status: true,
+                  totalAmount: true,
+                  vehicleId: true,
+                  vehicle: {
+                    select: {
+                      make: true,
+                      model: true,
+                      year: true,
+                      customer: { select: { name: true } },
+                    },
+                  },
+                  customer: { select: { name: true } },
+                },
+                orderBy: { validUntil: 'asc' },
+              })
+            : [],
+          sees.messages
+            ? db.scheduledMessage.findMany({
+                where: {
+                  organizationId,
+                  status: { not: 'cancelled' },
+                  sendAt: inRange,
+                },
+                select: {
+                  id: true,
+                  channel: true,
+                  subject: true,
+                  body: true,
+                  recipient: true,
+                  status: true,
+                  sendAt: true,
+                  vehicleId: true,
+                  customer: { select: { name: true } },
+                  vehicle: { select: { make: true, model: true, year: true } },
+                },
+                orderBy: { sendAt: 'asc' },
+              })
+            : [],
+          // Busy time pulled from connected calendars; read-only on this side.
+          db.externalCalendarEvent.findMany({
+            where: { organizationId, startAt: { lt: endExclusive }, endAt: { gt: start } },
+            select: {
+              id: true,
+              title: true,
+              startAt: true,
+              endAt: true,
+              allDay: true,
+              remoteUrl: true,
+              connection: { select: { connectorId: true } },
+            },
+            orderBy: { startAt: 'asc' },
+          }),
+          db.appointment.findMany({
+            where: { organizationId, startAt: inRange },
+            select: {
+              id: true,
+              title: true,
+              description: true,
+              startAt: true,
+              endAt: true,
+              vehicleId: true,
+              customer: { select: { name: true } },
+              vehicle: {
+                select: {
+                  make: true,
+                  model: true,
+                  year: true,
+                  customer: { select: { name: true } },
+                },
               },
+              _count: { select: { notifications: true } },
             },
-            customer: { select: { name: true } },
-          },
-          orderBy: { validUntil: 'asc' },
-        }),
-        db.scheduledMessage.findMany({
-          where: {
-            organizationId,
-            status: { not: 'cancelled' },
-            sendAt: inRange,
-          },
-          select: {
-            id: true,
-            channel: true,
-            subject: true,
-            body: true,
-            recipient: true,
-            status: true,
-            sendAt: true,
-            vehicleId: true,
-            customer: { select: { name: true } },
-            vehicle: { select: { make: true, model: true, year: true } },
-          },
-          orderBy: { sendAt: 'asc' },
-        }),
-        // Busy time pulled from connected calendars; read-only on this side.
-        db.externalCalendarEvent.findMany({
-          where: { organizationId, startAt: { lt: endExclusive }, endAt: { gt: start } },
-          select: {
-            id: true,
-            title: true,
-            startAt: true,
-            endAt: true,
-            allDay: true,
-            remoteUrl: true,
-            connection: { select: { connectorId: true } },
-          },
-          orderBy: { startAt: 'asc' },
-        }),
-      ])
+            orderBy: { startAt: 'asc' },
+          }),
+        ])
 
       const now = new Date()
       const vehicleLabel = (v: { year: number; make: string; model: string } | null) =>
@@ -349,6 +400,31 @@ export async function getCalendarEvents(params: { start: string; end: string })
             customerName: (s.customer ?? s.vehicle?.customer)?.name ?? null,
             invoiceNumber: s.invoiceNumber,
             amount: s.totalAmount > 0 ? s.totalAmount : s.cost > 0 ? s.cost : null,
+            notificationCount: s._count.appointmentNotifications,
+          }
+        }),
+        ...appointments.map((a) => {
+          const date = zonedDayKey(a.startAt, timeZone)
+          return {
+            id: a.id,
+            title: a.title,
+            date,
+            time: toTimeStr(a.startAt, timeZone),
+            endTime:
+              a.endAt > a.startAt
+                ? zonedDayKey(a.endAt, timeZone) === date
+                  ? toTimeStr(a.endAt, timeZone)
+                  : '23:59'
+                : null,
+            type: 'appointment' as const,
+            status: 'booked',
+            vehicleId: a.vehicleId,
+            vehicleLabel: vehicleLabel(a.vehicle),
+            customerName: (a.customer ?? a.vehicle?.customer)?.name ?? null,
+            invoiceNumber: null,
+            amount: null,
+            description: a.description ? sanitizeHtml(a.description) : null,
+            notificationCount: a._count.notifications,
           }
         }),
         ...reminders

+ 167 - 0
src/features/calendar/Actions/createBooking.ts

@@ -0,0 +1,167 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { withAuth } from '@/lib/with-auth'
+import type { AuditEvent } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { createDraftRecord } from '@/features/vehicles/Lib/createDraftRecord'
+import {
+  bookingConcerns,
+  bookingParties,
+  cleanDescription,
+  resourceFor,
+  syncNotifications,
+  wallClock,
+} from '../Lib/booking.server'
+import {
+  isCustomerChannel,
+  type SaveBookingInput,
+  saveBookingSchema,
+} from '../Schema/bookingSchema'
+
+/**
+ * Puts something on the calendar from the booking dialog.
+ *
+ * An appointment is only that: a time, who it is with, what it is about, and
+ * the notifications before it. Work on a car is booked as 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. The job starts out `scheduled` and holds a bay, or in a shop
+ * without bays a technician, as an online booking does (`resourceFor`).
+ *
+ * An appointment can become a work order: the dialog opens on it, and the
+ * appointment goes once the job exists, in the same transaction.
+ */
+export async function createBooking(input: SaveBookingInput) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = saveBookingSchema.parse(input)
+      if (data.id) throw new Error('This booking already exists')
+      // A message to the customer will go out from the workshop's own SMS or
+      // email, which the public demo must not do.
+      if (data.notifications.some((n) => isCustomerChannel(n.channel))) demoGuard()
+
+      const timeZone = await workshopTimeZone(organizationId)
+      const start = wallClock(data.startDate, data.startTime, timeZone)
+      const end = wallClock(data.endDate, data.endTime, timeZone)
+      const { vehicle, customer } = await bookingParties(organizationId, data)
+      const description = cleanDescription(data.description)
+
+      const from = data.fromAppointmentId
+        ? await db.appointment.findFirst({
+            where: { id: data.fromAppointmentId, organizationId },
+            select: { id: true },
+          })
+        : null
+      if (data.fromAppointmentId && !from) throw new Error('Appointment not found')
+
+      if (data.kind === 'appointment') {
+        if (from) throw new Error('An appointment becomes a work order, not another appointment')
+        const created = await db.$transaction(async (tx) => {
+          const appointment = await tx.appointment.create({
+            data: {
+              organizationId,
+              title: data.title,
+              description,
+              startAt: start,
+              endAt: end,
+              vehicleId: vehicle?.id ?? null,
+              // A car brings its owner.
+              customerId: vehicle?.customerId ?? customer?.id ?? null,
+              createdById: userId,
+            },
+            select: { id: true, title: true, vehicleId: true },
+          })
+          await syncNotifications(
+            tx,
+            { organizationId, userId, appointmentId: appointment.id },
+            data.notifications
+          )
+          return appointment
+        })
+        return {
+          kind: 'appointment' as const,
+          id: created.id,
+          ref: created.title,
+          vehicleId: created.vehicleId,
+          overbooked: false,
+        }
+      }
+
+      const minutes = Math.round((end.getTime() - start.getTime()) / 60_000)
+      const { resource, full } = await resourceFor(organizationId, start, minutes)
+      const job = 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: data.title,
+            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,
+          }
+        )
+        const record = await tx.serviceRecord.update({
+          where: { id: created.id },
+          data: {
+            status: 'scheduled',
+            description,
+            concerns: {
+              create: bookingConcerns(data.work, data.title).map((text, sortOrder) => ({
+                description: text,
+                sortOrder,
+              })),
+            },
+          },
+          select: { id: true, vehicleId: true, invoiceNumber: true },
+        })
+        await syncNotifications(
+          tx,
+          { organizationId, userId, serviceRecordId: record.id },
+          data.notifications
+        )
+        if (from) await tx.appointment.delete({ where: { id: from.id } })
+        return record
+      })
+      // Booked all the same when everything is taken then: the office may
+      // fill an hour the booking page would refuse. The dialog says so.
+      return {
+        kind: 'workOrder' as const,
+        id: job.id,
+        ref: job.invoiceNumber || job.id,
+        vehicleId: job.vehicleId,
+        overbooked: full,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }): AuditEvent =>
+        result.kind === 'appointment'
+          ? {
+              action: 'appointment.create',
+              entity: 'Appointment',
+              entityId: result.id,
+              details: { key: 'appointment_create', params: { title: result.ref } },
+            }
+          : {
+              action: 'service.create',
+              entity: 'ServiceRecord',
+              entityId: result.id,
+              details: { key: 'service_bookAppointment', params: { ref: result.ref } },
+              metadata: { serviceRecordId: result.id, vehicleId: result.vehicleId },
+            },
+    }
+  )
+}

+ 36 - 0
src/features/calendar/Actions/deleteAppointment.ts

@@ -0,0 +1,36 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+
+/**
+ * Takes an appointment off the calendar, with its notifications. A booked
+ * job is not an appointment: it is cancelled or deleted as a work order.
+ */
+export async function deleteAppointment(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const appointmentId = z.string().min(1).parse(id)
+      const appointment = await db.appointment.findFirst({
+        where: { id: appointmentId, organizationId },
+        select: { id: true, title: true },
+      })
+      if (!appointment) throw new Error('Appointment not found')
+      await db.appointment.delete({ where: { id: appointment.id } })
+      return appointment
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: 'appointment.delete',
+        entity: 'Appointment',
+        entityId: result.id,
+        details: { key: 'appointment_delete', params: { title: result.title } },
+      }),
+    }
+  )
+}

+ 174 - 0
src/features/calendar/Actions/getBooking.ts

@@ -0,0 +1,174 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { zonedDateInput, zonedTimeInput } from '@/lib/timezone'
+import { serviceVideoLink } from '@/features/integrations/Lib/video-call'
+import { BOOKING_KINDS, type BookingKind, type NotificationChannel } from '../Schema/bookingSchema'
+
+const vehicleSelect = {
+  id: true,
+  make: true,
+  model: true,
+  year: true,
+  licensePlate: true,
+  customerId: true,
+  customer: { select: { id: true, name: true } },
+} as const
+
+const notificationSelect = {
+  select: { channel: true, amount: true, unit: true, atTime: true },
+  orderBy: { createdAt: 'asc' as const },
+}
+
+export interface BookingForEdit {
+  kind: BookingKind
+  id: string
+  title: string
+  description: string
+  customer: { id: string; name: string; company: string | null } | null
+  vehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+    customerId: string | null
+    customer: { id: string; name: string } | null
+  } | null
+  startDate: string
+  startTime: string
+  endDate: string
+  endTime: string
+  /** Work order only: the concerns, one per line. */
+  work: string
+  notifications: {
+    channel: NotificationChannel
+    amount: number
+    unit: string
+    atTime: string | null
+  }[]
+  /** What the dialog shows but does not let change here, and why. */
+  locked: { who: boolean; work: boolean }
+  /** The video call already on the job, if any. */
+  meeting: { url: string; provider: string } | null
+}
+
+/**
+ * A booking as the dialog edits it: an appointment, or a booked job whose
+ * car has not arrived, read on the workshop's clock.
+ */
+export async function getBooking(input: { kind: BookingKind; id: string }) {
+  return withAuth(
+    async ({ organizationId }): Promise<BookingForEdit> => {
+      const { kind, id } = z
+        .object({ kind: z.enum(BOOKING_KINDS), id: z.string().min(1) })
+        .parse(input)
+      const timeZone = await workshopTimeZone(organizationId)
+      const times = (start: Date, end: Date) => ({
+        startDate: zonedDateInput(start, timeZone),
+        startTime: zonedTimeInput(start, timeZone),
+        endDate: zonedDateInput(end, timeZone),
+        endTime: zonedTimeInput(end, timeZone),
+      })
+
+      if (kind === 'appointment') {
+        const a = await db.appointment.findFirst({
+          where: { id, organizationId },
+          select: {
+            id: true,
+            title: true,
+            description: true,
+            startAt: true,
+            endAt: true,
+            customer: { select: { id: true, name: true, company: true } },
+            vehicle: { select: vehicleSelect },
+            notifications: notificationSelect,
+          },
+        })
+        if (!a) throw new Error('Appointment not found')
+        return {
+          kind,
+          id: a.id,
+          title: a.title,
+          description: a.description ?? '',
+          customer: a.customer,
+          vehicle: a.vehicle,
+          ...times(a.startAt, a.endAt),
+          work: '',
+          notifications: a.notifications as BookingForEdit['notifications'],
+          locked: { who: false, work: false },
+          meeting: null,
+        }
+      }
+
+      const job = await db.serviceRecord.findFirst({
+        where: { id, organizationId },
+        select: {
+          id: true,
+          title: true,
+          description: true,
+          status: true,
+          startDateTime: true,
+          endDateTime: true,
+          customer: { select: { id: true, name: true, company: true } },
+          vehicle: {
+            select: {
+              ...vehicleSelect,
+              customer: { select: { id: true, name: true, company: true } },
+            },
+          },
+          concerns: {
+            orderBy: { sortOrder: 'asc' },
+            select: {
+              description: true,
+              cause: true,
+              correction: true,
+              confirmation: true,
+              confirmedAt: true,
+              _count: { select: { findings: true, attachments: true } },
+            },
+          },
+          appointmentNotifications: notificationSelect,
+        },
+      })
+      if (!job) throw new Error('Work order not found')
+      if (job.status !== 'scheduled' || !job.startDateTime) {
+        throw new Error('The job has started. Change it on the work order.')
+      }
+      const end =
+        job.endDateTime && job.endDateTime > job.startDateTime
+          ? job.endDateTime
+          : new Date(job.startDateTime.getTime() + 60 * 60_000)
+      const recorded = job.concerns.some(
+        (c) =>
+          c.cause ||
+          c.correction ||
+          c.confirmation ||
+          c.confirmedAt ||
+          c._count.findings > 0 ||
+          c._count.attachments > 0
+      )
+      return {
+        kind,
+        id: job.id,
+        title: job.title,
+        description: job.description ?? '',
+        // A job on a car finds its customer through the car.
+        customer: job.customer ?? job.vehicle?.customer ?? null,
+        vehicle: job.vehicle,
+        ...times(job.startDateTime, end),
+        work: job.concerns.map((c) => c.description).join('\n'),
+        notifications: job.appointmentNotifications as BookingForEdit['notifications'],
+        locked: { who: Boolean(job.vehicle), work: recorded },
+        meeting: await serviceVideoLink(organizationId, job.id),
+      }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 49 - 0
src/features/calendar/Actions/getBookingOptions.ts

@@ -0,0 +1,49 @@
+'use server'
+
+import { getTranslations } from 'next-intl/server'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getManifest } from '@/integrations/registry'
+import { connectorName } from '@/features/integrations/Lib/display-name'
+import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availableChannels'
+import type { NotificationChannel } from '../Schema/bookingSchema'
+
+export interface BookingOptions {
+  /** Ways a notification can go out in this workshop. */
+  channels: NotificationChannel[]
+  /** Connected services that can add a video call to a booked job. */
+  video: { connectorId: string; name: string }[]
+}
+
+/**
+ * What the booking dialog can offer in this workshop: the customer channels
+ * it has set up, and the video call services it has connected. Nothing is
+ * offered that would fail when the time comes.
+ */
+export async function getBookingOptions() {
+  return withAuth(
+    async ({ organizationId }): Promise<BookingOptions> => {
+      const [available, connections] = await Promise.all([
+        getAvailableChannels(organizationId),
+        db.integrationConnection.findMany({
+          where: { organizationId, status: 'active' },
+          select: { connectorId: true },
+        }),
+      ])
+      const channels: NotificationChannel[] = ['in_app', 'email']
+      if (available.includes('sms')) channels.push('customer_sms')
+      if (available.includes('email')) channels.push('customer_email')
+
+      const ti = await getTranslations('integrations')
+      const video = connections
+        .map((c) => getManifest(c.connectorId))
+        .filter((m) => Boolean(m?.meetingProvider))
+        .map((m) => ({ connectorId: m!.id, name: connectorName(ti, m!) }))
+      return { channels, video }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}

+ 229 - 0
src/features/calendar/Actions/updateBooking.ts

@@ -0,0 +1,229 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { withAuth } from '@/lib/with-auth'
+import type { AuditEvent } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import {
+  bookingConcerns,
+  bookingParties,
+  cleanDescription,
+  resourceFor,
+  syncNotifications,
+  wallClock,
+} from '../Lib/booking.server'
+import {
+  isCustomerChannel,
+  type SaveBookingInput,
+  saveBookingSchema,
+} from '../Schema/bookingSchema'
+
+/**
+ * Changes a booking from the calendar: an appointment, or a booked job whose
+ * car has not arrived yet.
+ *
+ * Once the car is in, the job is a live work order and is changed there; the
+ * calendar only moves what is still a booking. On a booked job:
+ *
+ * - The time moves, and the job keeps its bay or technician when that is
+ *   still free then; otherwise it takes the first free one, as a new booking
+ *   would.
+ * - The car can be added to a booking made for a customer whose car was not
+ *   on file. A different car or customer is changed on the work order, where
+ *   the prices and tax that follow the customer are worked out again.
+ * - The work lines are the job's concerns. They are replaced only while
+ *   nobody has recorded anything on them; after that they are the job's
+ *   history and are changed on the work order.
+ */
+export async function updateBooking(input: SaveBookingInput) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = saveBookingSchema.parse(input)
+      if (!data.id) throw new Error('Nothing to change')
+      if (data.fromAppointmentId) throw new Error('An appointment becomes a work order when booked')
+      if (data.notifications.some((n) => isCustomerChannel(n.channel))) demoGuard()
+
+      const timeZone = await workshopTimeZone(organizationId)
+      const start = wallClock(data.startDate, data.startTime, timeZone)
+      const end = wallClock(data.endDate, data.endTime, timeZone)
+      const description = cleanDescription(data.description)
+
+      if (data.kind === 'appointment') {
+        const existing = await db.appointment.findFirst({
+          where: { id: data.id, organizationId },
+          select: { id: true },
+        })
+        if (!existing) throw new Error('Appointment not found')
+        const { vehicle, customer } = await bookingParties(organizationId, data)
+        const saved = await db.$transaction(async (tx) => {
+          const appointment = await tx.appointment.update({
+            where: { id: existing.id },
+            data: {
+              title: data.title,
+              description,
+              startAt: start,
+              endAt: end,
+              vehicleId: vehicle?.id ?? null,
+              customerId: vehicle?.customerId ?? customer?.id ?? null,
+            },
+            select: { id: true, title: true, vehicleId: true },
+          })
+          await syncNotifications(
+            tx,
+            { organizationId, userId, appointmentId: appointment.id },
+            data.notifications
+          )
+          return appointment
+        })
+        return {
+          kind: 'appointment' as const,
+          id: saved.id,
+          ref: saved.title,
+          vehicleId: saved.vehicleId,
+          overbooked: false,
+        }
+      }
+
+      const job = await db.serviceRecord.findFirst({
+        where: { id: data.id, organizationId },
+        select: {
+          id: true,
+          status: true,
+          invoiceNumber: true,
+          vehicleId: true,
+          customerId: true,
+          startDateTime: true,
+          endDateTime: true,
+          technicianId: true,
+          workBayId: true,
+          concerns: {
+            orderBy: { sortOrder: 'asc' },
+            select: {
+              description: true,
+              cause: true,
+              correction: true,
+              confirmation: true,
+              confirmedAt: true,
+              _count: { select: { findings: true, attachments: true } },
+            },
+          },
+        },
+      })
+      if (!job) throw new Error('Work order not found')
+      if (job.status !== 'scheduled') {
+        throw new Error('The job has started. Change it on the work order.')
+      }
+
+      // Who: only a car added to a booking that had none, and only the
+      // customer's own.
+      let who: { vehicleId: string; customerId: null } | null = null
+      if ((data.vehicleId ?? null) !== job.vehicleId) {
+        if (job.vehicleId || !data.vehicleId) {
+          throw new Error('Change the car on the work order')
+        }
+        const { vehicle } = await bookingParties(organizationId, { vehicleId: data.vehicleId })
+        if (!vehicle) throw new Error('Vehicle not found')
+        if (job.customerId && vehicle.customerId !== job.customerId) {
+          throw new Error('That car belongs to another customer')
+        }
+        // A job on a car finds its customer through the car.
+        who = { vehicleId: vehicle.id, customerId: null }
+      } else if (!job.vehicleId && (data.customerId ?? null) !== job.customerId) {
+        throw new Error('Change the customer on the work order')
+      }
+
+      const lines = bookingConcerns(data.work, data.title)
+      const workChanged = lines.join('\n') !== job.concerns.map((c) => c.description).join('\n')
+      if (workChanged) {
+        const recorded = job.concerns.some(
+          (c) =>
+            c.cause ||
+            c.correction ||
+            c.confirmation ||
+            c.confirmedAt ||
+            c._count.findings > 0 ||
+            c._count.attachments > 0
+        )
+        if (recorded) {
+          throw new Error('Work is recorded on these concerns. Change them on the work order.')
+        }
+      }
+
+      const moved =
+        start.getTime() !== job.startDateTime?.getTime() ||
+        end.getTime() !== job.endDateTime?.getTime()
+      let holds: { workBayId?: string; technicianId?: string } = {}
+      let full = false
+      if (moved) {
+        const minutes = Math.round((end.getTime() - start.getTime()) / 60_000)
+        const found = await resourceFor(organizationId, start, minutes, {
+          jobId: job.id,
+          current: { workBayId: job.workBayId, technicianId: job.technicianId },
+        })
+        full = found.full
+        if (found.resource?.workBayId) holds = { workBayId: found.resource.workBayId }
+        else if (found.resource?.technicianId) holds = { technicianId: found.resource.technicianId }
+      }
+
+      const saved = await db.$transaction(async (tx) => {
+        const record = await tx.serviceRecord.update({
+          where: { id: job.id },
+          data: {
+            title: data.title,
+            description,
+            startDateTime: start,
+            endDateTime: end,
+            ...(who ?? {}),
+            ...holds,
+          },
+          select: { id: true, vehicleId: true, invoiceNumber: true },
+        })
+        if (workChanged) {
+          await tx.serviceConcern.deleteMany({ where: { serviceRecordId: job.id } })
+          await tx.serviceConcern.createMany({
+            data: lines.map((text, sortOrder) => ({
+              serviceRecordId: job.id,
+              description: text,
+              sortOrder,
+            })),
+          })
+        }
+        await syncNotifications(
+          tx,
+          { organizationId, userId, serviceRecordId: job.id },
+          data.notifications
+        )
+        return record
+      })
+      return {
+        kind: 'workOrder' as const,
+        id: saved.id,
+        ref: saved.invoiceNumber || saved.id,
+        vehicleId: saved.vehicleId,
+        overbooked: full,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }): AuditEvent =>
+        result.kind === 'appointment'
+          ? {
+              action: 'appointment.update',
+              entity: 'Appointment',
+              entityId: result.id,
+              details: { key: 'appointment_update', params: { title: result.ref } },
+            }
+          : {
+              action: 'service.update',
+              entity: 'ServiceRecord',
+              entityId: result.id,
+              details: { key: 'service_editAppointment', params: { ref: result.ref } },
+              metadata: { serviceRecordId: result.id, vehicleId: result.vehicleId },
+            },
+    }
+  )
+}

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

@@ -1,252 +0,0 @@
-'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>
-  )
-}

+ 829 - 0
src/features/calendar/Components/BookingDialog.tsx

@@ -0,0 +1,829 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import {
+  AlignLeft,
+  Bell,
+  CalendarDays,
+  Car,
+  Clock,
+  ExternalLink,
+  ListChecks,
+  Loader2,
+  Plus,
+  User,
+  Video,
+  Wrench,
+  X,
+} 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 { RichTextEditor } from '@/components/ui/rich-text-editor'
+import {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { useServiceType } from '@/components/service-type-context'
+import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
+import { VehicleCombobox } from '@/features/quotes/Components/VehicleCombobox'
+import { createServiceMeeting } from '@/features/integrations/Actions/integrationActions'
+import { createBooking } from '../Actions/createBooking'
+import { updateBooking } from '../Actions/updateBooking'
+import { getBooking, type BookingForEdit } from '../Actions/getBooking'
+import { getBookingOptions, type BookingOptions } from '../Actions/getBookingOptions'
+import {
+  type BookingKind,
+  DEFAULT_NOTICE_TIME,
+  DEFAULT_NOTIFICATION,
+  MAX_NOTIFICATIONS,
+  MAX_NOTICE_MINUTES,
+  type NotificationChannel,
+  type NotificationUnit,
+  NOTIFICATION_UNITS,
+  UNIT_MINUTES,
+  isCustomerChannel,
+  isDayUnit,
+} from '../Schema/bookingSchema'
+import { MAX_APPOINTMENT_MINUTES } from '../Schema/appointmentSchema'
+import { minutesToTime, timeToMinutes } from '../Lib/calendar-range'
+import type { BookingEdit } from './EventPeek'
+import { ConcernListEditor } from './ConcernListEditor'
+
+/** The kind a new booking starts as: whatever this person booked last. */
+const KIND_KEY = 'torqvoice.calendar.bookingKind'
+const DEFAULT_MINUTES = 60
+
+interface NotificationRow {
+  key: number
+  channel: NotificationChannel
+  amount: string
+  unit: NotificationUnit
+  atTime: string
+}
+
+let rowKey = 0
+const newRow = (n?: Partial<Omit<NotificationRow, 'key'>>): NotificationRow => ({
+  key: ++rowKey,
+  channel: n?.channel ?? DEFAULT_NOTIFICATION.channel,
+  amount: n?.amount ?? String(DEFAULT_NOTIFICATION.amount),
+  unit: n?.unit ?? DEFAULT_NOTIFICATION.unit,
+  atTime: n?.atTime ?? DEFAULT_NOTICE_TIME,
+})
+
+function storedKind(): BookingKind {
+  try {
+    return window.localStorage.getItem(KIND_KEY) === 'appointment' ? 'appointment' : 'workOrder'
+  } catch {
+    return 'workOrder'
+  }
+}
+
+/** Day key and time `minutes` after a day key and a time, on a plain calendar. */
+function addMinutes(date: string, time: string, minutes: number): { date: string; time: string } {
+  const [y, m, d] = date.split('-').map(Number)
+  const total = timeToMinutes(time) + minutes
+  const dayShift = Math.floor(total / (24 * 60))
+  const shifted = new Date(Date.UTC(y, m - 1, d + dayShift))
+  const key = shifted.toISOString().slice(0, 10)
+  return { date: key, time: minutesToTime(((total % (24 * 60)) + 24 * 60) % (24 * 60)) }
+}
+
+function minutesBetween(a: { date: string; time: string }, b: { date: string; time: string }) {
+  const at = (v: { date: string; time: string }) => {
+    const [y, m, d] = v.date.split('-').map(Number)
+    return Date.UTC(y, m - 1, d) / 60_000 + timeToMinutes(v.time)
+  }
+  return at(b) - at(a)
+}
+
+/**
+ * Puts something on the calendar, the way a calendar app does: a title, an
+ * appointment or a work order, when, who, and the notifications before it.
+ *
+ * An appointment is only on the calendar. A work order books the car in as a
+ * scheduled job that holds a bay or a technician. The same dialog changes
+ * either while it is still a booking, and turns an appointment into a work
+ * order when the car does come in.
+ */
+export function BookingDialog({
+  open,
+  onOpenChange,
+  defaultDate,
+  defaultTime,
+  edit,
+  onSaved,
+}: {
+  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
+  /** An appointment or a booked job to change, or an appointment to make a work order. */
+  edit?: BookingEdit | null
+  onSaved?: () => void
+}) {
+  const t = useTranslations('calendar.appointment')
+  const [saving, setSaving] = useState(false)
+  const title = edit ? (edit.mode === 'convert' ? t('convertTitle') : t('editTitle')) : t('title')
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(next) => {
+        if (!saving) onOpenChange(next)
+      }}
+    >
+      {/* One height for every kind of booking: switching between an appointment
+          and a work order changes fields inside, never the size of the dialog. */}
+      <DialogContent
+        className="h-[min(44rem,92vh)] max-h-[92vh] sm:max-w-5xl"
+        showCloseButton={!saving}
+      >
+        <DialogHeader className="sr-only">
+          <DialogTitle>{title}</DialogTitle>
+          <DialogDescription>{t('dialogDescription')}</DialogDescription>
+        </DialogHeader>
+        {/* Mounted with the dialog, so what was typed and then cancelled is
+            gone the next time it opens. */}
+        {open && (
+          <BookingForm
+            key={edit ? `${edit.mode}:${edit.event.id}` : 'new'}
+            defaultDate={defaultDate}
+            defaultTime={defaultTime}
+            edit={edit ?? null}
+            saving={saving}
+            onSavingChange={setSaving}
+            onCancel={() => onOpenChange(false)}
+            onSaved={() => {
+              onOpenChange(false)
+              onSaved?.()
+            }}
+          />
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function BookingForm({
+  defaultDate,
+  defaultTime,
+  edit,
+  saving,
+  onSavingChange,
+  onCancel,
+  onSaved,
+}: {
+  defaultDate?: string
+  defaultTime?: string
+  edit: BookingEdit | null
+  saving: boolean
+  onSavingChange: (saving: boolean) => void
+  onCancel: () => void
+  onSaved: () => void
+}) {
+  const t = useTranslations('calendar.appointment')
+  const tc = useTranslations('common.buttons')
+  const isMarine = useServiceType() === 'marine'
+
+  // What is being changed: nothing (a new booking), an appointment, a job.
+  const editing = edit?.mode === 'edit' ? edit.event : null
+  const converting = edit?.mode === 'convert' ? edit.event : null
+  const sourceKind: BookingKind | null = edit
+    ? edit.event.type === 'appointment'
+      ? 'appointment'
+      : 'workOrder'
+    : null
+
+  const [loaded, setLoaded] = useState<BookingForEdit | null>(null)
+  const [options, setOptions] = useState<BookingOptions | null>(null)
+  const [loading, setLoading] = useState(Boolean(edit))
+
+  const [kind, setKind] = useState<BookingKind>(() =>
+    converting ? 'workOrder' : editing ? (sourceKind ?? 'workOrder') : storedKind()
+  )
+  const [titleText, setTitleText] = useState('')
+  const [description, setDescription] = useState('')
+  const [customerId, setCustomerId] = useState('')
+  const [customerInitial, setCustomerInitial] = useState<{
+    id: string
+    name: string
+    company: string | null
+  } | null>(null)
+  const [vehicleId, setVehicleId] = useState('')
+  const [vehicleInitial, setVehicleInitial] = useState<BookingForEdit['vehicle']>(null)
+  const [vehicleCustomerId, setVehicleCustomerId] = useState<string | null>(null)
+  const [work, setWork] = useState('')
+  const startDefault = defaultTime ?? '09:00'
+  const [start, setStart] = useState({ date: defaultDate ?? '', time: startDefault })
+  const [end, setEnd] = useState(() =>
+    defaultDate ? addMinutes(defaultDate, startDefault, DEFAULT_MINUTES) : { date: '', time: '' }
+  )
+  const [rows, setRows] = useState<NotificationRow[]>(() => (edit ? [] : [newRow()]))
+  const [meeting, setMeeting] = useState<string | null>(null)
+  // The end date shows for a booking that runs into another day, or once asked for.
+  const [multiDay, setMultiDay] = useState(false)
+
+  // What the workshop offers, and the booking being changed.
+  useEffect(() => {
+    let live = true
+    const load = async () => {
+      const [opts, booking] = await Promise.all([
+        getBookingOptions().catch(() => null),
+        edit
+          ? getBooking({
+              kind: edit.event.type === 'appointment' ? 'appointment' : 'workOrder',
+              id: edit.event.id,
+            }).catch(() => null)
+          : null,
+      ])
+      if (!live) return
+      if (opts?.success && opts.data) setOptions(opts.data)
+      if (edit) {
+        if (!booking?.success || !booking.data) {
+          toast.error(booking?.error || t('failed'))
+          onCancel()
+          return
+        }
+        const b = booking.data
+        setLoaded(b)
+        setTitleText(b.title)
+        setDescription(b.description)
+        setCustomerId(b.customer?.id ?? '')
+        setCustomerInitial(b.customer)
+        setVehicleId(b.vehicle?.id ?? '')
+        setVehicleInitial(b.vehicle)
+        setVehicleCustomerId(b.vehicle?.customerId ?? null)
+        // An appointment made a work order: its words become the work needed.
+        setWork(converting ? '' : b.work)
+        setStart({ date: b.startDate, time: b.startTime })
+        setEnd({ date: b.endDate, time: b.endTime })
+        setRows(
+          b.notifications.map((n) =>
+            newRow({
+              channel: n.channel,
+              amount: String(n.amount),
+              unit: n.unit as NotificationUnit,
+              atTime: n.atTime ?? DEFAULT_NOTICE_TIME,
+            })
+          )
+        )
+        setLoading(false)
+      }
+    }
+    void load()
+    return () => {
+      live = false
+    }
+    // Loaded once per opening; the form is remounted for the next.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const isWorkOrder = kind === 'workOrder'
+  const showEndDate = multiDay || Boolean(end.date && start.date && end.date !== start.date)
+  const lockedWho = Boolean(editing && sourceKind === 'workOrder' && loaded?.locked.who)
+  const lockedWork = Boolean(editing && sourceKind === 'workOrder' && loaded?.locked.work)
+  const channels = options?.channels ?? ['in_app', 'email']
+  const video = options?.video ?? []
+  const hasCustomer = Boolean(customerId || vehicleId)
+  // An appointment needs neither; a work order needs one of the two.
+  const optionalLabel = isWorkOrder ? undefined : t('optional')
+
+  const duration = minutesBetween(start, end)
+  const durationLabel = useMemo(() => {
+    if (!Number.isFinite(duration) || duration <= 0) return null
+    const h = Math.floor(duration / 60)
+    const m = duration % 60
+    return [
+      h ? t('durationHours', { count: h }) : null,
+      m ? t('durationMinutes', { count: m }) : null,
+    ]
+      .filter(Boolean)
+      .join(' ')
+  }, [duration, t])
+
+  const rowsValid = rows.every((r) => {
+    const n = Number(r.amount)
+    return Number.isInteger(n) && n >= 0 && n * UNIT_MINUTES[r.unit] <= MAX_NOTICE_MINUTES
+  })
+  const customerRowsOk = hasCustomer || !rows.some((r) => isCustomerChannel(r.channel))
+  const timesValid = Boolean(
+    start.date &&
+      start.time &&
+      end.date &&
+      end.time &&
+      duration >= 5 &&
+      duration <= MAX_APPOINTMENT_MINUTES
+  )
+  const ready =
+    !loading &&
+    Boolean(titleText.trim()) &&
+    timesValid &&
+    rowsValid &&
+    customerRowsOk &&
+    (!isWorkOrder || Boolean(vehicleId || customerId))
+
+  const chooseKind = (next: BookingKind) => {
+    setKind(next)
+    if (!edit) {
+      try {
+        window.localStorage.setItem(KIND_KEY, next)
+      } catch {
+        // A browser that keeps nothing simply starts on a work order.
+      }
+    }
+  }
+
+  const moveStart = (next: { date: string; time: string }) => {
+    // The end keeps the length, as a calendar's does.
+    const keep = duration > 0 ? duration : DEFAULT_MINUTES
+    setStart(next)
+    if (next.date && next.time) setEnd(addMinutes(next.date, next.time, keep))
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!ready || saving) return
+    onSavingChange(true)
+    try {
+      const payload = {
+        kind,
+        id: editing?.id,
+        fromAppointmentId: converting?.id,
+        title: titleText.trim(),
+        description,
+        customerId: customerId || undefined,
+        vehicleId: vehicleId || undefined,
+        work: isWorkOrder ? work : undefined,
+        startDate: start.date,
+        startTime: start.time,
+        endDate: end.date,
+        endTime: end.time,
+        notifications: rows.map((r) => ({
+          channel: r.channel,
+          amount: Number(r.amount),
+          unit: r.unit,
+          atTime: isDayUnit(r.unit) ? r.atTime : null,
+        })),
+      }
+      const result: Awaited<ReturnType<typeof createBooking>> = editing
+        ? await updateBooking(payload)
+        : await createBooking(payload)
+      if (!result.success || !result.data) {
+        toast.error(result.error || t('failed'))
+        return
+      }
+      if (result.data.overbooked) toast.warning(t('bookedNobodyFree'))
+      else toast.success(editing ? t('saved') : t('booked'))
+      // The meeting is made once the job exists; a failure leaves the booking standing.
+      if (meeting && result.data.kind === 'workOrder') {
+        const made = await createServiceMeeting(result.data.id, meeting).catch(() => null)
+        if (!made?.success) toast.error(t('meetingFailed', { error: made?.error ?? '' }))
+      }
+      onSaved()
+    } catch {
+      toast.error(t('failed'))
+    } finally {
+      onSavingChange(false)
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex min-h-0 flex-1 items-center justify-center">
+        <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+      </div>
+    )
+  }
+
+  const channelLabel: Record<NotificationChannel, string> = {
+    in_app: t('channel.inApp'),
+    email: t('channel.email'),
+    customer_sms: t('channel.customerSms'),
+    customer_email: t('channel.customerEmail'),
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
+      <DialogBody className="space-y-4">
+        {/* Title and what it is, the way a calendar app opens. */}
+        <div className="space-y-2.5 pr-6">
+          <Input
+            aria-label={t('titleLabel')}
+            value={titleText}
+            onChange={(e) => setTitleText(e.target.value)}
+            placeholder={t('titlePlaceholder')}
+            maxLength={200}
+            autoFocus
+            className="h-14 rounded-none border-0 border-b-2 border-border bg-transparent px-1 text-2xl font-medium tracking-tight shadow-none placeholder:font-normal placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-0 md:text-2xl dark:bg-transparent"
+          />
+          <div className="flex flex-wrap items-center gap-3">
+            <Tabs value={kind} onValueChange={(v) => chooseKind(v as BookingKind)}>
+              <TabsList>
+                <TabsTrigger
+                  value="appointment"
+                  disabled={Boolean(converting || (editing && sourceKind === 'workOrder'))}
+                  className="gap-1.5"
+                >
+                  <CalendarDays className="h-3.5 w-3.5" />
+                  {t('kind.appointment')}
+                </TabsTrigger>
+                <TabsTrigger
+                  value="workOrder"
+                  disabled={Boolean(editing && sourceKind === 'appointment')}
+                  className="gap-1.5"
+                >
+                  <Wrench className="h-3.5 w-3.5" />
+                  {t('kind.workOrder')}
+                </TabsTrigger>
+              </TabsList>
+            </Tabs>
+            <p className="text-xs text-muted-foreground">
+              {isWorkOrder
+                ? t('kind.workOrderHint')
+                : isMarine
+                  ? t('kind.appointmentHintMarine')
+                  : t('kind.appointmentHint')}
+            </p>
+          </div>
+        </div>
+
+        {/* When: one day, start to end, as a calendar shows it. A second date
+            only for a booking that runs into another day. */}
+        <Row icon={Clock} label={t('when')}>
+          <div className="flex flex-wrap items-center gap-2">
+            <div className="w-40">
+              <DateInput
+                id="booking-start-date"
+                value={start.date}
+                onChange={(date) => moveStart({ ...start, date })}
+              />
+            </div>
+            <Input
+              aria-label={t('start')}
+              type="time"
+              value={start.time}
+              onChange={(e) => moveStart({ ...start, time: e.target.value })}
+              className="w-28"
+              required
+            />
+            <span className="text-muted-foreground">–</span>
+            <Input
+              aria-label={t('end')}
+              type="time"
+              value={end.time}
+              onChange={(e) => setEnd({ ...end, time: e.target.value })}
+              className="w-28"
+              required
+            />
+            {showEndDate ? (
+              <div className="w-40">
+                <DateInput
+                  id="booking-end-date"
+                  value={end.date}
+                  onChange={(date) => setEnd({ ...end, date })}
+                />
+              </div>
+            ) : (
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                className="h-8 px-2 text-xs text-primary"
+                onClick={() => setMultiDay(true)}
+              >
+                {t('endsAnotherDay')}
+              </Button>
+            )}
+            {timesValid && durationLabel && (
+              <span className="text-xs text-muted-foreground">{durationLabel}</span>
+            )}
+          </div>
+          {!timesValid && <p className="text-xs text-destructive">{t('endAfterStart')}</p>}
+        </Row>
+
+        <div className="grid gap-x-8 gap-y-4 md:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
+          <div className="space-y-4">
+            <Row icon={User} label={t('customer')} optional={optionalLabel}>
+              <CustomerCombobox
+                value={customerId}
+                initialCustomer={customerInitial}
+                placeholder={t('selectCustomer')}
+                searchPlaceholder={t('searchCustomer')}
+                noneLabel={t('none')}
+                emptyLabel={t('noCustomers')}
+                // On a booked job only a missing car can be added here; the
+                // customer, and so the prices, change on the work order.
+                disabled={Boolean(editing && sourceKind === 'workOrder')}
+                onChange={(id) => {
+                  setCustomerId(id)
+                  // A car belonging to someone else no longer fits.
+                  if (id && vehicleId && vehicleCustomerId !== id) {
+                    setVehicleId('')
+                    setVehicleCustomerId(null)
+                  }
+                }}
+              />
+            </Row>
+            <Row
+              icon={Car}
+              label={isMarine ? t('vehicleMarine') : t('vehicle')}
+              optional={optionalLabel}
+            >
+              <VehicleCombobox
+                value={vehicleId}
+                initialVehicle={vehicleInitial}
+                customerId={customerId || undefined}
+                placeholder={isMarine ? t('selectVehicleMarine') : t('selectVehicle')}
+                searchPlaceholder={isMarine ? t('searchVehicleMarine') : t('searchVehicle')}
+                noneLabel={t('none')}
+                emptyLabel={isMarine ? t('noVehiclesMarine') : t('noVehicles')}
+                disabled={lockedWho}
+                onChange={(id, vehicle) => {
+                  setVehicleId(id)
+                  setVehicleCustomerId(vehicle?.customerId ?? null)
+                  if (vehicle?.customer) {
+                    setCustomerId(vehicle.customer.id)
+                    setCustomerInitial({ ...vehicle.customer, company: null })
+                  }
+                }}
+              />
+              {(lockedWho || isWorkOrder) && (
+                <p className="text-xs text-muted-foreground">
+                  {lockedWho
+                    ? isMarine
+                      ? t('whoLockedMarine')
+                      : t('whoLocked')
+                    : isMarine
+                      ? t('whoHintMarine')
+                      : t('whoHint')}
+                </p>
+              )}
+            </Row>
+            <Row icon={AlignLeft} label={t('description')}>
+              <RichTextEditor
+                content={description}
+                onChange={setDescription}
+                placeholder={t('descriptionPlaceholder')}
+                contentClassName="min-h-[8rem] max-h-[30vh]"
+              />
+            </Row>
+          </div>
+
+          <div className="space-y-4">
+            <Row icon={Bell} label={t('notifications')}>
+              <div className="space-y-2">
+                {rows.map((row) => (
+                  <NotificationEditor
+                    key={row.key}
+                    row={row}
+                    channels={channels}
+                    channelLabel={channelLabel}
+                    hasCustomer={hasCustomer}
+                    onChange={(next) =>
+                      setRows((all) => all.map((r) => (r.key === row.key ? next : r)))
+                    }
+                    onRemove={() => setRows((all) => all.filter((r) => r.key !== row.key))}
+                  />
+                ))}
+                {!customerRowsOk && (
+                  <p className="text-xs text-destructive">{t('customerChannelNeedsCustomer')}</p>
+                )}
+                {rows.length < MAX_NOTIFICATIONS && (
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    className="-ml-2 h-8 text-primary"
+                    onClick={() => setRows((all) => [...all, newRow()])}
+                  >
+                    <Plus className="mr-1.5 h-3.5 w-3.5" />
+                    {t('addNotification')}
+                  </Button>
+                )}
+              </div>
+            </Row>
+            {isWorkOrder && (
+              <Row icon={ListChecks} label={t('work')}>
+                <ConcernListEditor
+                  value={work}
+                  onChange={setWork}
+                  disabled={lockedWork}
+                  placeholder={isMarine ? t('addConcernMarine') : t('addConcern')}
+                />
+                {(lockedWork || work.trim() === '') && (
+                  <p className="px-2 text-xs text-muted-foreground">
+                    {lockedWork ? t('workLocked') : t('workEmptyHint')}
+                  </p>
+                )}
+              </Row>
+            )}
+            {isWorkOrder && video.length > 0 && (
+              <Row icon={Video} label={t('video')}>
+                {loaded?.meeting ? (
+                  <a
+                    href={loaded.meeting.url}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
+                  >
+                    {t('joinMeeting')}
+                    <ExternalLink className="h-3.5 w-3.5" />
+                  </a>
+                ) : meeting ? (
+                  <div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
+                    <span>
+                      {t('meetingAdded', {
+                        name: video.find((v) => v.connectorId === meeting)?.name ?? '',
+                      })}
+                    </span>
+                    <Button
+                      type="button"
+                      variant="ghost"
+                      size="icon"
+                      className="h-7 w-7"
+                      aria-label={t('removeMeeting')}
+                      onClick={() => setMeeting(null)}
+                    >
+                      <X className="h-4 w-4" />
+                    </Button>
+                  </div>
+                ) : (
+                  <div className="flex flex-wrap gap-2">
+                    {video.map((v) => (
+                      <Button
+                        key={v.connectorId}
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => setMeeting(v.connectorId)}
+                      >
+                        <Video className="mr-1.5 h-3.5 w-3.5" />
+                        {t('addMeeting', { name: v.name })}
+                      </Button>
+                    ))}
+                  </div>
+                )}
+              </Row>
+            )}
+          </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" />}
+          {editing ? tc('save') : converting ? t('convertSubmit') : t('submit')}
+        </Button>
+      </DialogFooter>
+    </form>
+  )
+}
+
+/** One line of the form: an icon in the gutter, the field beside it. */
+function Row({
+  icon: Icon,
+  label,
+  optional,
+  children,
+}: {
+  icon: React.ComponentType<{ className?: string }>
+  label: string
+  /** Said beside the label when the field can be left empty. */
+  optional?: string
+  children: React.ReactNode
+}) {
+  return (
+    <div className="flex gap-3">
+      <Icon className="mt-2.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
+      <div className="min-w-0 flex-1 space-y-1.5">
+        <p className="text-xs font-medium text-muted-foreground">
+          {label}
+          {optional && (
+            <span className="ml-1.5 font-normal text-muted-foreground/70">{optional}</span>
+          )}
+        </p>
+        {children}
+      </div>
+    </div>
+  )
+}
+
+function NotificationEditor({
+  row,
+  channels,
+  channelLabel,
+  hasCustomer,
+  onChange,
+  onRemove,
+}: {
+  row: NotificationRow
+  channels: NotificationChannel[]
+  channelLabel: Record<NotificationChannel, string>
+  hasCustomer: boolean
+  onChange: (row: NotificationRow) => void
+  onRemove: () => void
+}) {
+  const t = useTranslations('calendar.appointment')
+  const workshop = channels.filter((c) => !isCustomerChannel(c))
+  const customer = channels.filter((c) => isCustomerChannel(c))
+  return (
+    <div className="flex flex-wrap items-center gap-1.5">
+      <Select
+        value={row.channel}
+        onValueChange={(v) => onChange({ ...row, channel: v as NotificationChannel })}
+      >
+        <SelectTrigger className="h-9 w-[9.5rem]" aria-label={t('channelLabel')}>
+          <SelectValue />
+        </SelectTrigger>
+        <SelectContent>
+          <SelectGroup>
+            <SelectLabel>{t('channel.toWorkshop')}</SelectLabel>
+            {workshop.map((c) => (
+              <SelectItem key={c} value={c}>
+                {channelLabel[c]}
+              </SelectItem>
+            ))}
+          </SelectGroup>
+          {customer.length > 0 && (
+            <SelectGroup>
+              <SelectLabel>{t('channel.toCustomer')}</SelectLabel>
+              {customer.map((c) => (
+                <SelectItem key={c} value={c} disabled={!hasCustomer}>
+                  {channelLabel[c]}
+                </SelectItem>
+              ))}
+            </SelectGroup>
+          )}
+        </SelectContent>
+      </Select>
+      <Input
+        aria-label={t('amountLabel')}
+        type="number"
+        inputMode="numeric"
+        min={0}
+        value={row.amount}
+        onChange={(e) => onChange({ ...row, amount: e.target.value })}
+        className="h-9 w-14 px-1 text-center"
+      />
+      <Select
+        value={row.unit}
+        onValueChange={(v) => onChange({ ...row, unit: v as NotificationUnit })}
+      >
+        <SelectTrigger className="h-9 w-[8.5rem]" aria-label={t('unitLabel')}>
+          <SelectValue />
+        </SelectTrigger>
+        <SelectContent>
+          {NOTIFICATION_UNITS.map((u) => (
+            <SelectItem key={u} value={u}>
+              {t(`unit.${u}`, { count: Number(row.amount) || 0 })}
+            </SelectItem>
+          ))}
+        </SelectContent>
+      </Select>
+      {isDayUnit(row.unit) && (
+        <>
+          <span className="text-sm text-muted-foreground">{t('at')}</span>
+          <Input
+            aria-label={t('atLabel')}
+            type="time"
+            value={row.atTime}
+            onChange={(e) => onChange({ ...row, atTime: e.target.value })}
+            className="h-9 w-[6.5rem]"
+          />
+        </>
+      )}
+      <Button
+        type="button"
+        variant="ghost"
+        size="icon"
+        className="h-8 w-8 text-muted-foreground"
+        aria-label={t('removeNotification')}
+        onClick={onRemove}
+      >
+        <X className="h-4 w-4" />
+      </Button>
+    </div>
+  )
+}

+ 24 - 7
src/features/calendar/Components/CalendarClient.tsx

@@ -22,7 +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 { BookingDialog } from './BookingDialog'
 import type { CalendarEvent, CalendarEventType } from '../Actions/calendarActions'
 import {
   eachDay,
@@ -42,7 +42,7 @@ import { CalendarEventList } from './CalendarEventList'
 import { CalendarSidebar, type TypeFilters } from './CalendarSidebar'
 import { CalendarToolbar, VIEW_SHORTCUTS, type CreateKind } from './CalendarToolbar'
 import type { DayActions } from './DayContextMenu'
-import { EventPeekProvider } from './EventPeek'
+import { type BookingEdit, EventPeekProvider } from './EventPeek'
 import { MonthView } from './MonthView'
 import { ScheduleView } from './ScheduleView'
 import { TimeGridView } from './TimeGridView'
@@ -69,6 +69,7 @@ interface CalendarClientProps {
 
 const ALL_TYPES: CalendarEventType[] = [
   'service',
+  'appointment',
   'reminder',
   'quote',
   'message',
@@ -132,6 +133,7 @@ export default function CalendarClient({
     external: true,
     closure: true,
     absence: true,
+    appointment: true,
   })
 
   // Dialogs the calendar can open
@@ -139,6 +141,8 @@ export default function CalendarClient({
   const [showDateChoice, setShowDateChoice] = useState(false)
   const [workOrderQuery, setWorkOrderQuery] = useState<Record<string, string> | undefined>()
   const [showAppointmentDialog, setShowAppointmentDialog] = useState(false)
+  // An appointment or booked job opened from its peek or menu; null for a new booking.
+  const [bookingEdit, setBookingEdit] = useState<BookingEdit | null>(null)
   const [showReminderDialog, setShowReminderDialog] = useState(false)
   const [showQuoteDialog, setShowQuoteDialog] = useState(false)
   const [showMessageDialog, setShowMessageDialog] = useState(false)
@@ -202,6 +206,7 @@ export default function CalendarClient({
   const counts = useMemo(() => {
     const c: Record<CalendarEventType, number> = {
       service: 0,
+      appointment: 0,
       reminder: 0,
       quote: 0,
       message: 0,
@@ -524,6 +529,10 @@ export default function CalendarClient({
       currencyCode={currencyCode}
       onRefresh={refresh}
       onEditAbsence={timeOff ? editAbsence : undefined}
+      onEditBooking={(edit) => {
+        setBookingEdit(edit)
+        setShowAppointmentDialog(true)
+      }}
     >
       {/*
         A definite height, not flex-1: nothing above this in the layout has
@@ -575,7 +584,11 @@ export default function CalendarClient({
       </div>
 
       <Sheet open={daySheetOpen} onOpenChange={setDaySheetOpen}>
-        <SheetContent side="bottom" className="max-h-[80vh] overflow-y-auto">
+        <SheetContent
+          side="bottom"
+          className="max-h-[80vh] overflow-y-auto"
+          aria-describedby={undefined}
+        >
           <SheetHeader className="sr-only">
             <SheetTitle>{format.dateTime(date, { dateStyle: 'full' })}</SheetTitle>
           </SheetHeader>
@@ -596,7 +609,7 @@ export default function CalendarClient({
       </Sheet>
 
       <Dialog open={showDateChoice} onOpenChange={setShowDateChoice}>
-        <DialogContent className="max-w-sm">
+        <DialogContent className="max-w-sm" aria-describedby={undefined}>
           <DialogHeader>
             <DialogTitle>{t('dateChoice.title')}</DialogTitle>
           </DialogHeader>
@@ -624,12 +637,16 @@ export default function CalendarClient({
         </DialogContent>
       </Dialog>
 
-      <BookAppointmentDialog
+      <BookingDialog
         open={showAppointmentDialog}
-        onOpenChange={setShowAppointmentDialog}
+        onOpenChange={(open) => {
+          setShowAppointmentDialog(open)
+          if (!open) setBookingEdit(null)
+        }}
         defaultDate={menuDateStr}
         defaultTime={menuTime}
-        onBooked={refresh}
+        edit={bookingEdit}
+        onSaved={refresh}
       />
 
       <ReminderFormDialog

+ 139 - 0
src/features/calendar/Components/CalendarEventsCard.tsx

@@ -0,0 +1,139 @@
+'use client'
+
+import { useCallback, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { ArrowRight, CalendarDays, Plus } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { CardEmpty } from '@/components/card-empty'
+import { Button } from '@/components/ui/button'
+import { addDays, parseDateKey, toLocalDateStr } from '../Lib/calendar-range'
+import { BookingDialog } from './BookingDialog'
+import { compareEvents, isEventDone } from './calendar-utils'
+import { type BookingEdit, EventPeekProvider } from './EventPeek'
+import { ScheduleView } from './ScheduleView'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/**
+ * The dashboard's look at the calendar: what is on today and the days after
+ * it, drawn the way the calendar's own schedule view draws it, and opened
+ * the same way, with the peek card on a click and the event menu on a
+ * right-click. What is already done is left out; this is what is coming.
+ *
+ * The events arrive already cut to what this person may read: the calendar
+ * feed leaves out the kinds of record their role cannot open.
+ */
+export function CalendarEventsCard({
+  events,
+  todayKey,
+  days,
+  currencyCode,
+}: {
+  events: CalendarEvent[]
+  /** YYYY-MM-DD, today in the workshop's time zone. */
+  todayKey: string
+  /** How many days from today the events cover, today included. */
+  days: number
+  currencyCode: string
+}) {
+  const t = useTranslations('dashboard')
+  const tCalendar = useTranslations('calendar')
+  const router = useRouter()
+  const [booking, setBooking] = useState(false)
+  const [bookingEdit, setBookingEdit] = useState<BookingEdit | null>(null)
+  const refresh = useCallback(() => router.refresh(), [router])
+
+  const upcoming = useMemo(() => events.filter((event) => !isEventDone(event)), [events])
+
+  const eventsByDate = useMemo(() => {
+    const map = new Map<string, CalendarEvent[]>()
+    for (const event of upcoming) {
+      const list = map.get(event.date)
+      if (list) list.push(event)
+      else map.set(event.date, [event])
+    }
+    for (const list of map.values()) list.sort(compareEvents)
+    return map
+  }, [upcoming])
+
+  // Local-midnight dates built from the day keys, the same way on the server
+  // and in the browser, which is what ScheduleView formats.
+  const dayDates = useMemo(() => {
+    const first = parseDateKey(todayKey)
+    return first ? Array.from({ length: days }, (_, i) => addDays(first, i)) : []
+  }, [todayKey, days])
+
+  const bookLabel = tCalendar('contextMenu.bookAppointment')
+  const bookButton = (compact: boolean) => (
+    <Button
+      variant="outline"
+      size="sm"
+      className="h-7 gap-1.5 text-xs"
+      onClick={() => setBooking(true)}
+      aria-label={bookLabel}
+      title={bookLabel}
+    >
+      <Plus className="h-3.5 w-3.5" />
+      {/* In the header a phone keeps the card's title readable instead. */}
+      <span className={compact ? 'hidden sm:inline' : undefined}>{bookLabel}</span>
+    </Button>
+  )
+
+  return (
+    <EventPeekProvider
+      currencyCode={currencyCode}
+      onRefresh={refresh}
+      onEditBooking={(edit) => {
+        setBookingEdit(edit)
+        setBooking(true)
+      }}
+    >
+      <AppCard
+        icon={CalendarDays}
+        title={t('calendarEvents.title')}
+        description={t('calendarEvents.description', { days })}
+        badge={upcoming.length || undefined}
+        action={upcoming.length === 0 ? undefined : bookButton(true)}
+        contentClassName="p-0"
+        footer={
+          upcoming.length === 0 ? undefined : (
+            <button
+              type="button"
+              onClick={() => router.push('/calendar?view=schedule')}
+              className="flex w-full items-center justify-between font-medium transition-colors hover:text-foreground"
+            >
+              {t('viewAll')}
+              <ArrowRight className="h-3 w-3" />
+            </button>
+          )
+        }
+      >
+        {upcoming.length === 0 ? (
+          <CardEmpty
+            icon={CalendarDays}
+            title={t('calendarEvents.noData', { days })}
+            action={bookButton(false)}
+          />
+        ) : (
+          <ScheduleView
+            days={dayDates}
+            eventsByDate={eventsByDate}
+            todayStr={todayKey}
+            onSelectDate={(date) => router.push(`/calendar?view=day&date=${toLocalDateStr(date)}`)}
+            listClassName="mx-0 max-w-none"
+          />
+        )}
+      </AppCard>
+      <BookingDialog
+        open={booking}
+        onOpenChange={(open) => {
+          setBooking(open)
+          if (!open) setBookingEdit(null)
+        }}
+        defaultDate={todayKey}
+        edit={bookingEdit}
+        onSaved={refresh}
+      />
+    </EventPeekProvider>
+  )
+}

+ 2 - 0
src/features/calendar/Components/CalendarSidebar.tsx

@@ -11,6 +11,7 @@ export type TypeFilters = Record<CalendarEventType, boolean>
 
 const FILTER_ORDER: CalendarEventType[] = [
   'service',
+  'appointment',
   'reminder',
   'quote',
   'message',
@@ -30,6 +31,7 @@ const FILTER_LABEL: Record<CalendarEventType, string> = {
   external: 'external',
   closure: 'closures',
   absence: 'absences',
+  appointment: 'appointments',
 }
 
 interface CalendarSidebarProps {

+ 143 - 0
src/features/calendar/Components/ConcernListEditor.tsx

@@ -0,0 +1,143 @@
+'use client'
+
+import { useRef } from 'react'
+import { useTranslations } from 'next-intl'
+import { Plus, X } from 'lucide-react'
+
+/**
+ * What the customer wants done, one concern per row, the way a task list
+ * takes items: type, Enter for the next, Backspace on an empty row to take
+ * it away, and a pasted list becomes one row per line. Each row is one of
+ * the job's concerns, printed under work requested on the work order.
+ *
+ * The list is kept as text, one concern per line, which is what the booking
+ * actions take.
+ */
+export function ConcernListEditor({
+  value,
+  onChange,
+  disabled = false,
+  placeholder,
+}: {
+  value: string
+  onChange: (value: string) => void
+  disabled?: boolean
+  placeholder?: string
+}) {
+  const t = useTranslations('calendar.appointment')
+  const inputs = useRef<(HTMLInputElement | null)[]>([])
+  const addRef = useRef<HTMLInputElement | null>(null)
+  const items = value === '' ? [] : value.split('\n')
+
+  const write = (next: string[]) => onChange(next.join('\n'))
+  /** Focus a row once React has drawn it; one past the last is the add field. */
+  const focusRow = (index: number) =>
+    requestAnimationFrame(() => {
+      const el = inputs.current[index] ?? addRef.current
+      el?.focus()
+    })
+
+  const linesOf = (text: string) =>
+    text
+      .split(/\r?\n/)
+      .map((l) => l.trim())
+      .filter(Boolean)
+
+  const rowClass =
+    'group flex h-9 items-center gap-2.5 rounded-md px-2 transition-colors focus-within:bg-accent/60 hover:bg-accent/60'
+
+  return (
+    <div className="space-y-0.5">
+      {items.map((item, i) => (
+        // Rows are edited in place and have no id of their own; their
+        // position is what they are.
+        <div key={i} className={rowClass}>
+          <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-medium tabular-nums text-muted-foreground">
+            {i + 1}
+          </span>
+          <input
+            ref={(el) => {
+              inputs.current[i] = el
+            }}
+            aria-label={t('concernLabel', { number: i + 1 })}
+            value={item}
+            disabled={disabled}
+            maxLength={500}
+            onChange={(e) => {
+              const next = [...items]
+              next[i] = e.target.value
+              write(next)
+            }}
+            onKeyDown={(e) => {
+              if (e.key === 'Enter') {
+                e.preventDefault()
+                const next = [...items]
+                next.splice(i + 1, 0, '')
+                write(next)
+                focusRow(i + 1)
+              } else if (e.key === 'Backspace' && item === '') {
+                e.preventDefault()
+                write(items.filter((_, j) => j !== i))
+                focusRow(i > 0 ? i - 1 : items.length - 1)
+              }
+            }}
+            onBlur={() => {
+              // An emptied row goes when it is left, not while it is typed in.
+              if (item.trim() === '') write(items.filter((_, j) => j !== i))
+            }}
+            className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-70"
+          />
+          {!disabled && (
+            <button
+              type="button"
+              aria-label={t('removeConcern')}
+              onClick={() => write(items.filter((_, j) => j !== i))}
+              className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
+            >
+              <X className="h-3.5 w-3.5" />
+            </button>
+          )}
+        </div>
+      ))}
+      {!disabled && (
+        <div className={rowClass}>
+          <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed text-muted-foreground">
+            <Plus className="h-3 w-3" />
+          </span>
+          <input
+            ref={addRef}
+            aria-label={t('addConcern')}
+            placeholder={placeholder ?? t('addConcern')}
+            onKeyDown={(e) => {
+              const text = e.currentTarget.value.trim()
+              if (e.key === 'Enter') {
+                e.preventDefault()
+                if (!text) return
+                write([...items, text])
+                e.currentTarget.value = ''
+              } else if (e.key === 'Backspace' && !e.currentTarget.value && items.length > 0) {
+                e.preventDefault()
+                focusRow(items.length - 1)
+              }
+            }}
+            onBlur={(e) => {
+              // Typed and then clicked away from: still a concern.
+              const text = e.currentTarget.value.trim()
+              if (text) {
+                write([...items, text])
+                e.currentTarget.value = ''
+              }
+            }}
+            onPaste={(e) => {
+              const lines = linesOf(e.clipboardData.getData('text'))
+              if (lines.length < 2) return
+              e.preventDefault()
+              write([...items, ...lines])
+            }}
+            className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+          />
+        </div>
+      )}
+    </div>
+  )
+}

+ 16 - 2
src/features/calendar/Components/EventChip.tsx

@@ -123,6 +123,9 @@ export function EventChip({
         className={cn(
           'absolute flex flex-col overflow-hidden rounded-md border px-1.5 py-1 text-left text-xs leading-tight shadow-sm transition-[filter,box-shadow] hover:z-20 hover:shadow-md',
           palette.block,
+          // An edge in the page's own colour, the way Google draws it: blocks
+          // of one colour that overlap stay two blocks instead of one shape.
+          'border-background',
           done && 'opacity-70',
           compact && 'justify-center py-0',
           className
@@ -135,7 +138,12 @@ export function EventChip({
         {!compact && (
           <span className="truncate text-[11px] opacity-90">
             {range}
-            {event.vehicleLabel ? ` · ${event.vehicleLabel}` : ''}
+            {/* The title already names the car on a job; who it is for says more. */}
+            {event.customerName
+              ? ` · ${event.customerName}`
+              : event.vehicleLabel
+                ? ` · ${event.vehicleLabel}`
+                : ''}
           </span>
         )}
       </button>
@@ -152,11 +160,17 @@ export function EventChip({
           className
         )}
       >
-        <span className="w-[6.5rem] shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">
+        {/* A phone has no room for a time column: the time goes above the title. */}
+        <span className="hidden w-[6.5rem] shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground sm:block">
           {range}
         </span>
         <span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', palette.dot)} />
         <span className="min-w-0 flex-1">
+          {range && (
+            <span className="block text-xs tabular-nums text-muted-foreground sm:hidden">
+              {range}
+            </span>
+          )}
           <span className={cn('block truncate font-medium', done && 'line-through opacity-70')}>
             {event.title}
           </span>

+ 39 - 7
src/features/calendar/Components/EventContextMenu.tsx

@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
-import { Check, ExternalLink, Trash2, Undo2 } from 'lucide-react'
+import { Check, ExternalLink, Pencil, Trash2, Undo2, Wrench } from 'lucide-react'
 import {
   ContextMenu,
   ContextMenuContent,
@@ -17,7 +17,8 @@ import { useConfirm } from '@/components/confirm-dialog'
 import { deleteReminder, toggleReminder } from '@/features/vehicles/Actions/reminderActions'
 import { deleteScheduledMessage } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 import { getEventLink } from './calendar-utils'
-import { useEventPeek } from './EventPeek'
+import { deleteAppointment } from '../Actions/deleteAppointment'
+import { isEditableBooking, useEventPeek } from './EventPeek'
 import type { CalendarEvent } from '../Actions/calendarActions'
 
 /**
@@ -36,7 +37,7 @@ export function EventContextMenu({
   const t = useTranslations('calendar.eventMenu')
   const router = useRouter()
   const confirm = useConfirm()
-  const { refresh } = useEventPeek()
+  const { refresh, editBooking } = useEventPeek()
 
   const open = () => {
     if (event.type === 'external') {
@@ -75,7 +76,16 @@ export function EventContextMenu({
     if (ok) await run(() => deleteScheduledMessage(event.id), t('deleted'))
   }
 
+  const removeAppointment = async () => {
+    const ok = await confirm({
+      description: t('deleteAppointmentConfirm', { title: event.title }),
+      destructive: true,
+    })
+    if (ok) await run(() => deleteAppointment(event.id), t('deleted'))
+  }
+
   const reminderDone = event.type === 'reminder' && event.status === 'completed'
+  const canEdit = Boolean(editBooking) && isEditableBooking(event)
 
   return (
     <ContextMenu>
@@ -85,10 +95,32 @@ export function EventContextMenu({
           {event.title}
         </ContextMenuLabel>
         <ContextMenuSeparator />
-        <ContextMenuItem onClick={open}>
-          <ExternalLink className="mr-2 h-4 w-4" />
-          {t('open')}
-        </ContextMenuItem>
+        {canEdit && (
+          <ContextMenuItem onClick={() => editBooking?.({ mode: 'edit', event })}>
+            <Pencil className="mr-2 h-4 w-4" />
+            {t('edit')}
+          </ContextMenuItem>
+        )}
+        {event.type === 'appointment' ? (
+          editBooking && (
+            <>
+              <ContextMenuItem onClick={() => editBooking({ mode: 'convert', event })}>
+                <Wrench className="mr-2 h-4 w-4" />
+                {t('makeWorkOrder')}
+              </ContextMenuItem>
+              <ContextMenuSeparator />
+              <ContextMenuItem variant="destructive" onClick={removeAppointment}>
+                <Trash2 className="mr-2 h-4 w-4" />
+                {t('deleteAppointment')}
+              </ContextMenuItem>
+            </>
+          )
+        ) : (
+          <ContextMenuItem onClick={open}>
+            <ExternalLink className="mr-2 h-4 w-4" />
+            {t('open')}
+          </ContextMenuItem>
+        )}
         {event.type === 'reminder' && (
           <>
             <ContextMenuItem onClick={() => run(() => toggleReminder(event.id), t('updated'))}>

+ 101 - 13
src/features/calendar/Components/EventPeek.tsx

@@ -36,6 +36,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
 import { useDateSettings } from '@/components/date-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { formatClock } from '@/features/workboard/utils/clock'
+import { sanitizeHtml } from '@/lib/sanitize-html'
 import { timeToMinutes } from '../Lib/calendar-range'
 import { eventPalette, getEventLink, isEventDone, openEndedSide } from './calendar-utils'
 import { ExternalEventDialog } from './ExternalEventDialog'
@@ -50,6 +51,19 @@ import type { CalendarEvent } from '../Actions/calendarActions'
 interface PeekState {
   event: CalendarEvent
   anchor: HTMLElement
+  /**
+   * Beside a chip, below a row: a row in a list runs the full width, so a
+   * card beside it would land on top of the sidebar.
+   */
+  side: 'right' | 'bottom'
+}
+
+/** What the booking dialog opens on: changing a booking, or an appointment becoming a work order. */
+export type BookingEdit = { mode: 'edit' | 'convert'; event: CalendarEvent }
+
+/** A booking the calendar can change: an appointment, or a job whose car has not arrived. */
+export function isEditableBooking(event: Pick<CalendarEvent, 'type' | 'status'>): boolean {
+  return event.type === 'appointment' || (event.type === 'service' && event.status === 'scheduled')
 }
 
 interface PeekApi {
@@ -57,12 +71,16 @@ interface PeekApi {
   closePeek: () => void
   /** Reloads the events after something was deleted or ticked off in place. */
   refresh: () => void
+  /** Opens the booking dialog on an event. Absent where nothing can be booked. */
+  editBooking?: (edit: BookingEdit) => void
 }
 
 /** What the card can do beyond linking out, handed down by the calendar. */
 interface PeekActions {
   /** Open a technician's leave for editing. Absent when this person may not. */
   onEditAbsence?: (event: CalendarEvent) => void
+  /** Open the booking dialog on an appointment or a booked job. */
+  onEditBooking?: (edit: BookingEdit) => void
 }
 
 const PeekContext = createContext<PeekApi>({
@@ -79,11 +97,13 @@ export function EventPeekProvider({
   currencyCode,
   onRefresh,
   onEditAbsence,
+  onEditBooking,
   children,
 }: {
   currencyCode: string
   onRefresh?: () => void
   onEditAbsence?: PeekActions['onEditAbsence']
+  onEditBooking?: PeekActions['onEditBooking']
   children: ReactNode
 }) {
   const [peek, setPeek] = useState<PeekState | null>(null)
@@ -99,8 +119,11 @@ export function EventPeekProvider({
       setExternal(event)
       return
     }
+    const side = anchor.getBoundingClientRect().width > 320 ? 'bottom' : 'right'
     setPeek((current) =>
-      current && current.event === event && current.anchor === anchor ? null : { event, anchor }
+      current && current.event === event && current.anchor === anchor
+        ? null
+        : { event, anchor, side }
     )
   }, [])
   const closePeek = useCallback(() => setPeek(null), [])
@@ -108,7 +131,20 @@ export function EventPeekProvider({
     setPeek(null)
     onRefresh?.()
   }, [onRefresh])
-  const api = useMemo(() => ({ openPeek, closePeek, refresh }), [openPeek, closePeek, refresh])
+  const editBooking = useMemo(
+    () =>
+      onEditBooking
+        ? (edit: BookingEdit) => {
+            setPeek(null)
+            onEditBooking(edit)
+          }
+        : undefined,
+    [onEditBooking]
+  )
+  const api = useMemo(
+    () => ({ openPeek, closePeek, refresh, editBooking }),
+    [openPeek, closePeek, refresh, editBooking]
+  )
 
   return (
     <PeekContext.Provider value={api}>
@@ -116,7 +152,7 @@ export function EventPeekProvider({
       <Popover open={peek !== null} onOpenChange={(open) => !open && setPeek(null)}>
         <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} />
         <PopoverContent
-          side="right"
+          side={peek?.side ?? 'right'}
           align="start"
           collisionPadding={12}
           className="w-[22rem] max-w-[calc(100vw-1.5rem)] p-0 shadow-xl"
@@ -127,6 +163,7 @@ export function EventPeekProvider({
               event={peek.event}
               currencyCode={currencyCode}
               onClose={closePeek}
+              onEditBooking={editBooking}
               onEditAbsence={
                 onEditAbsence
                   ? (event) => {
@@ -158,6 +195,8 @@ function typeIcon(event: CalendarEvent) {
       return CalendarOff
     case 'absence':
       return UserX
+    case 'appointment':
+      return CalendarClock
     default:
       return CalendarClock
   }
@@ -188,6 +227,7 @@ function statusKey(event: CalendarEvent): string {
   if (event.type === 'external') return 'busy'
   if (event.type === 'closure') return 'closed'
   if (event.type === 'absence') return 'away'
+  if (event.type === 'appointment') return 'booked'
   return event.status === 'completed' ? 'done' : event.status === 'overdue' ? 'overdue' : 'upcoming'
 }
 
@@ -196,11 +236,13 @@ function PeekCard({
   currencyCode,
   onClose,
   onEditAbsence,
+  onEditBooking,
 }: {
   event: CalendarEvent
   currencyCode: string
   onClose: () => void
   onEditAbsence?: PeekActions['onEditAbsence']
+  onEditBooking?: PeekActions['onEditBooking']
 }) {
   const t = useTranslations('calendar')
   const tc = useTranslations('common')
@@ -297,25 +339,71 @@ function PeekCard({
               </dd>
             </div>
           )}
+          {(event.notificationCount ?? 0) > 0 && (
+            <div className="flex items-center gap-2">
+              <Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+              <dd className="text-muted-foreground">
+                {t('peek.notifications', { count: event.notificationCount ?? 0 })}
+              </dd>
+            </div>
+          )}
         </dl>
 
-        <div className="mt-4 flex items-center justify-end gap-2">
+        {event.description && (
+          <div
+            className="notes-content mt-3 max-h-40 overflow-y-auto border-t pt-3 text-sm"
+            dangerouslySetInnerHTML={{ __html: sanitizeHtml(event.description) }}
+          />
+        )}
+
+        <div className="mt-4 flex flex-wrap items-center justify-end gap-2">
           {event.type === 'absence' && onEditAbsence && (
             <Button size="sm" variant="outline" onClick={() => onEditAbsence(event)}>
               <Pencil className="mr-1.5 h-3.5 w-3.5" />
               {tc('buttons.edit')}
             </Button>
           )}
-          <Button asChild size="sm">
-            <Link href={getEventLink(event)}>
-              {t(`peek.open.${event.type}`)}
-              {event.type === 'external' ? (
-                <ExternalLink className="ml-1.5 h-3.5 w-3.5" />
-              ) : (
-                <ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
+          {event.type === 'appointment' ? (
+            onEditBooking && (
+              <>
+                <Button
+                  size="sm"
+                  variant="outline"
+                  onClick={() => onEditBooking({ mode: 'convert', event })}
+                >
+                  <Wrench className="mr-1.5 h-3.5 w-3.5" />
+                  {t('peek.makeWorkOrder')}
+                </Button>
+                <Button size="sm" onClick={() => onEditBooking({ mode: 'edit', event })}>
+                  <Pencil className="mr-1.5 h-3.5 w-3.5" />
+                  {tc('buttons.edit')}
+                </Button>
+              </>
+            )
+          ) : (
+            <>
+              {onEditBooking && isEditableBooking(event) && (
+                <Button
+                  size="sm"
+                  variant="outline"
+                  onClick={() => onEditBooking({ mode: 'edit', event })}
+                >
+                  <Pencil className="mr-1.5 h-3.5 w-3.5" />
+                  {tc('buttons.edit')}
+                </Button>
               )}
-            </Link>
-          </Button>
+              <Button asChild size="sm">
+                <Link href={getEventLink(event)}>
+                  {t(`peek.open.${event.type}`)}
+                  {event.type === 'external' ? (
+                    <ExternalLink className="ml-1.5 h-3.5 w-3.5" />
+                  ) : (
+                    <ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
+                  )}
+                </Link>
+              </Button>
+            </>
+          )}
         </div>
       </div>
     </div>

+ 16 - 6
src/features/calendar/Components/ScheduleView.tsx

@@ -14,6 +14,8 @@ interface ScheduleViewProps {
   eventsByDate: Map<string, CalendarEvent[]>
   todayStr: string
   onSelectDate: (date: Date) => void
+  /** Classes for the list itself; the calendar centres it, a card fills its width. */
+  listClassName?: string
 }
 
 /**
@@ -21,7 +23,13 @@ interface ScheduleViewProps {
  * Reads top to bottom like a schedule printout, which is what a service
  * desk wants when planning the coming weeks.
  */
-export function ScheduleView({ days, eventsByDate, todayStr, onSelectDate }: ScheduleViewProps) {
+export function ScheduleView({
+  days,
+  eventsByDate,
+  todayStr,
+  onSelectDate,
+  listClassName,
+}: ScheduleViewProps) {
   const t = useTranslations('calendar')
   const format = useDayFormatter()
   const busyDays = days.filter((d) => (eventsByDate.get(toLocalDateStr(d))?.length ?? 0) > 0)
@@ -38,17 +46,17 @@ export function ScheduleView({ days, eventsByDate, todayStr, onSelectDate }: Sch
 
   return (
     <div className="min-h-0 flex-1 overflow-y-auto">
-      <div className="mx-auto max-w-4xl divide-y divide-border/70">
+      <div className={cn('mx-auto max-w-4xl divide-y divide-border/70', listClassName)}>
         {busyDays.map((date) => {
           const dateStr = toLocalDateStr(date)
           const events = eventsByDate.get(dateStr) ?? []
           const isToday = dateStr === todayStr
           return (
-            <section key={dateStr} className="flex gap-4 px-4 py-3">
+            <section key={dateStr} className="flex gap-2 px-3 py-3 sm:gap-4 sm:px-4">
               <button
                 type="button"
                 onClick={() => onSelectDate(date)}
-                className="flex w-24 shrink-0 items-start gap-2 rounded-md text-left hover:bg-accent"
+                className="flex w-11 shrink-0 flex-col items-center gap-0.5 rounded-md text-left hover:bg-accent sm:w-24 sm:flex-row sm:items-start sm:gap-2"
               >
                 <span
                   className={cn(
@@ -58,11 +66,13 @@ export function ScheduleView({ days, eventsByDate, todayStr, onSelectDate }: Sch
                 >
                   {date.getDate()}
                 </span>
-                <span className="flex flex-col pt-0.5 text-[11px] uppercase leading-tight tracking-wide text-muted-foreground">
+                <span className="flex flex-col items-center pt-0.5 text-[11px] uppercase leading-tight tracking-wide text-muted-foreground sm:items-start">
                   <span className={cn(isToday && 'text-primary')}>
                     {format.dateTime(date, { weekday: 'short' })}
                   </span>
-                  <span>{format.dateTime(date, { month: 'short' })}</span>
+                  <span className="hidden sm:inline">
+                    {format.dateTime(date, { month: 'short' })}
+                  </span>
                 </span>
               </button>
               <div className="flex min-w-0 flex-1 flex-col gap-0.5">

+ 21 - 7
src/features/calendar/Components/TimeGridView.tsx

@@ -140,9 +140,12 @@ export function TimeGridView({
 
   return (
     <div className="flex min-h-0 flex-1 flex-col">
-      {/* Day headers */}
+      {/* Day headers. Every row keeps the space the hour grid's scrollbar
+          takes, so the day columns are as wide above it as in it and their
+          lines meet; without it each column drifted a little further left
+          in the grid than in the header. */}
       <div
-        className="grid shrink-0 border-b border-border/70"
+        className="grid shrink-0 overflow-y-hidden border-b border-border/70 [scrollbar-gutter:stable]"
         style={{ gridTemplateColumns: columnsTemplate }}
       >
         <div className="border-r border-border/70" />
@@ -186,7 +189,7 @@ export function TimeGridView({
 
       {/* All-day strip */}
       <div
-        className="grid shrink-0 border-b border-border/70"
+        className="grid shrink-0 overflow-y-hidden border-b border-border/70 [scrollbar-gutter:stable]"
         style={{ gridTemplateColumns: columnsTemplate }}
       >
         <div className="flex items-start justify-end border-r border-border/70 px-1.5 pt-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
@@ -244,7 +247,7 @@ export function TimeGridView({
       </div>
 
       {/* Hour grid */}
-      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
+      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
         <div
           className="relative grid"
           style={{ gridTemplateColumns: columnsTemplate, height: 24 * HOUR_PX }}
@@ -405,6 +408,14 @@ function DayColumn({
       ),
     [events]
   )
+  // Bottom to top: the earliest start first, and of two that start together
+  // the longer one underneath, so the shorter stays whole on top of it.
+  const stackOrder = useMemo(() => {
+    const order = [...positioned].sort(
+      (a, b) => a.startMins - b.startMins || b.endMins - a.endMins || a.column - b.column
+    )
+    return new Map(order.map((p, i) => [eventKey(p.item), i]))
+  }, [positioned])
 
   return (
     <DayContextMenu
@@ -462,7 +473,7 @@ function DayColumn({
           </div>
         ))}
 
-        {positioned.map(({ item, startMins, endMins, column, columns }) => {
+        {positioned.map(({ item, startMins, endMins, column, columns }, index) => {
           const top = (startMins / 60) * HOUR_PX
           const height = ((endMins - startMins) / 60) * HOUR_PX
           // Overlapping events step in from the left and each runs to the
@@ -482,7 +493,10 @@ function DayColumn({
                 height: Math.max(height - 2, 16),
                 left: `calc(${left}% + 2px)`,
                 width: `calc(${100 - left}% - 4px)`,
-                zIndex: 10 + column,
+                // Stacked by when they start, as Google does: a job that
+                // starts later is drawn over one already running, so its
+                // title is never hidden under a block that began earlier.
+                zIndex: 10 + Math.min(stackOrder.get(eventKey(item)) ?? index, 39),
               }}
             />
           )
@@ -492,7 +506,7 @@ function DayColumn({
           // Centred on the minute: the wrapper is as tall as its dot, so
           // without the shift the line itself drew six minutes late.
           <div
-            className="pointer-events-none absolute inset-x-0 z-30 flex -translate-y-1/2 items-center"
+            className="pointer-events-none absolute inset-x-0 z-[60] flex -translate-y-1/2 items-center"
             style={{ top: (nowMins / 60) * HOUR_PX }}
           >
             <span className="-ml-1.5 h-3 w-3 rounded-full bg-red-500 shadow" />

Некоторые файлы не были показаны из-за большого количества измененных файлов