4
0
Эх сурвалжийг харах

Send the work order's video call link to the customer from the panel, over the channels they tick (#333)

* Send the work order's video call link to the customer from the panel, over the channels they tick

* Fit the video call actions on one row and keep them usable when the invoice is locked
Bernt Christian Egeland 2 сар өмнө
parent
commit
da17fff744

+ 4 - 2
messages/de/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Verwendung eines Designs geändert",
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
-    "inspection_reminders_send": "HU-Erinnerungen gesendet"
+    "inspection_reminders_send": "HU-Erinnerungen gesendet",
+    "service_videoCall_send": "Link zum Videoanruf gesendet"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Hat {name} zum Standard-Dokumentdesign gemacht",
     "settings_setDocumentDesignRule": "Verwendung des Dokumentdesigns {name} geändert",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
-    "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet"
+    "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
+    "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet"
   }
 }

+ 28 - 1
messages/de/service.json

@@ -439,6 +439,33 @@
     "created": "Meeting erstellt. Der Link ist am Auftrag.",
     "removed": "Meeting entfernt",
     "failed": "Etwas ist schiefgelaufen",
-    "shareHint": "Der Kunde erhält diesen Link nur, wenn Sie ihn senden. Kopieren Sie ihn in eine Nachricht oder fügen Sie ihn einer Benachrichtigung hinzu."
+    "shareHint": "Der Kunde erhält diesen Link nur, wenn du ihn sendest.",
+    "shareHintNoChannel": "Der Kunde erhält diesen Link nur, wenn du ihn sendest. Es ist kein Nachrichtenkanal eingerichtet, kopiere ihn also selbst in eine Nachricht.",
+    "send": {
+      "button": "An Kunden senden",
+      "title": "Link zum Videoanruf senden",
+      "description": "Den Beitrittslink an {name} senden.",
+      "messageLabel": "Notiz (optional)",
+      "messagePlaceholder": "Was der Kunde vor dem Anruf wissen sollte...",
+      "linkAlwaysIncluded": "Der Beitrittslink wird immer unter deine Notiz gesetzt.",
+      "sms": "SMS",
+      "email": "E-Mail",
+      "telegram": "Telegram",
+      "noPhone": "(keine Telefonnummer)",
+      "noEmail": "(keine E-Mail)",
+      "noTelegram": "(kein Chat verknüpft)",
+      "notAvailable": "(nicht eingerichtet)",
+      "cancel": "Abbrechen",
+      "send": "Senden",
+      "sent": "Link zum Videoanruf gesendet",
+      "partlySent": "Gesendet per {sent}; nicht per {failed}",
+      "failed": "Link konnte nicht gesendet werden"
+    },
+    "notify": {
+      "invitation": "Hallo {name}, dein Videoanruf zum {vehicle} ist am {when}. Hier beitreten: {url}",
+      "invitationNoTime": "Hallo {name}, hier ist der Link zum Videoanruf zum {vehicle}: {url}",
+      "joinLine": "Hier dem Videoanruf beitreten: {url}",
+      "emailSubject": "Videoanruf zu deinem {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/en/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Changed When a Design Is Used",
     "settings_freezeInvoices": "Locked Older Invoices",
     "inspection_reminders_sent": "Sent inspection reminders",
-    "inspection_reminders_send": "Sent inspection reminders"
+    "inspection_reminders_send": "Sent inspection reminders",
+    "service_videoCall_send": "Sent video call link"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Made {name} the default document design",
     "settings_setDocumentDesignRule": "Changed when the document design {name} is used",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
-    "inspection_reminders_sent": "Sent {count} inspection reminders"
+    "inspection_reminders_sent": "Sent {count} inspection reminders",
+    "service_videoCall_send": "Sent the video call link via {channels}"
   }
 }

+ 28 - 1
messages/en/service.json

@@ -439,6 +439,33 @@
     "created": "Meeting created. The link is on the work order.",
     "removed": "Meeting removed",
     "failed": "Something went wrong",
-    "shareHint": "The customer only gets this link when you send it. Copy it into a message or add it to a notification."
+    "shareHint": "The customer only gets this link when you send it.",
+    "shareHintNoChannel": "The customer only gets this link when you send it. No messaging channel is set up, so copy it into a message yourself.",
+    "send": {
+      "button": "Send to customer",
+      "title": "Send the video call link",
+      "description": "Send the join link to {name}.",
+      "messageLabel": "Note (optional)",
+      "messagePlaceholder": "Anything the customer should know before the call...",
+      "linkAlwaysIncluded": "The join link is always added below your note.",
+      "sms": "SMS",
+      "email": "Email",
+      "telegram": "Telegram",
+      "noPhone": "(no phone)",
+      "noEmail": "(no email)",
+      "noTelegram": "(no chat linked)",
+      "notAvailable": "(not set up)",
+      "cancel": "Cancel",
+      "send": "Send",
+      "sent": "Video call link sent",
+      "partlySent": "Sent by {sent}; not by {failed}",
+      "failed": "Could not send the link"
+    },
+    "notify": {
+      "invitation": "Hi {name}, your video call about the {vehicle} is on {when}. Join here: {url}",
+      "invitationNoTime": "Hi {name}, here is the link for the video call about the {vehicle}: {url}",
+      "joinLine": "Join the video call here: {url}",
+      "emailSubject": "Video call about your {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/es/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Cambió cuándo se usa un diseño",
     "settings_freezeInvoices": "Facturas anteriores bloqueadas",
     "inspection_reminders_sent": "Envió recordatorios de inspección",
-    "inspection_reminders_send": "Envió recordatorios de inspección"
+    "inspection_reminders_send": "Envió recordatorios de inspección",
+    "service_videoCall_send": "Enlace de videollamada enviado"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Estableció {name} como diseño de documento predeterminado",
     "settings_setDocumentDesignRule": "Cambió cuándo se usa el diseño de documento {name}",
     "settings_freezeInvoices": "Bloqueó {count, plural, one {# factura} other {# facturas}} anteriores al bloqueo con el diseño actual",
-    "inspection_reminders_sent": "Envió {count} recordatorios de inspección"
+    "inspection_reminders_sent": "Envió {count} recordatorios de inspección",
+    "service_videoCall_send": "Envió el enlace de la videollamada por {channels}"
   }
 }

+ 28 - 1
messages/es/service.json

@@ -439,6 +439,33 @@
     "created": "Reunión creada. El enlace está en la orden de trabajo.",
     "removed": "Reunión eliminada",
     "failed": "Algo salió mal",
-    "shareHint": "El cliente solo recibe este enlace cuando usted se lo envía. Cópielo en un mensaje o añádalo a una notificación."
+    "shareHint": "El cliente solo recibe este enlace cuando lo envías.",
+    "shareHintNoChannel": "El cliente solo recibe este enlace cuando lo envías. No hay ningún canal de mensajes configurado, así que cópialo tú en un mensaje.",
+    "send": {
+      "button": "Enviar al cliente",
+      "title": "Enviar el enlace de la videollamada",
+      "description": "Enviar el enlace para unirse a {name}.",
+      "messageLabel": "Nota (opcional)",
+      "messagePlaceholder": "Lo que el cliente deba saber antes de la llamada...",
+      "linkAlwaysIncluded": "El enlace para unirse se añade siempre debajo de tu nota.",
+      "sms": "SMS",
+      "email": "Correo",
+      "telegram": "Telegram",
+      "noPhone": "(sin teléfono)",
+      "noEmail": "(sin correo)",
+      "noTelegram": "(sin chat vinculado)",
+      "notAvailable": "(no configurado)",
+      "cancel": "Cancelar",
+      "send": "Enviar",
+      "sent": "Enlace de la videollamada enviado",
+      "partlySent": "Enviado por {sent}; no por {failed}",
+      "failed": "No se pudo enviar el enlace"
+    },
+    "notify": {
+      "invitation": "Hola {name}, tu videollamada sobre el {vehicle} es el {when}. Únete aquí: {url}",
+      "invitationNoTime": "Hola {name}, aquí tienes el enlace de la videollamada sobre el {vehicle}: {url}",
+      "joinLine": "Únete a la videollamada aquí: {url}",
+      "emailSubject": "Videollamada sobre tu {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/fr/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "A modifié quand un design est utilisé",
     "settings_freezeInvoices": "Anciennes factures verrouillées",
     "inspection_reminders_sent": "Rappels de contrôle envoyés",
-    "inspection_reminders_send": "Rappels de contrôle envoyés"
+    "inspection_reminders_send": "Rappels de contrôle envoyés",
+    "service_videoCall_send": "Lien d'appel vidéo envoyé"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "A défini {name} comme design de document par défaut",
     "settings_setDocumentDesignRule": "A modifié quand le design de document {name} est utilisé",
     "settings_freezeInvoices": "A verrouillé {count, plural, one {# facture} other {# factures}} antérieures au verrouillage avec le design actuel",
-    "inspection_reminders_sent": "{count} rappels de contrôle envoyés"
+    "inspection_reminders_sent": "{count} rappels de contrôle envoyés",
+    "service_videoCall_send": "A envoyé le lien de l'appel vidéo par {channels}"
   }
 }

+ 28 - 1
messages/fr/service.json

@@ -439,6 +439,33 @@
     "created": "Réunion créée. Le lien est sur l'ordre de travail.",
     "removed": "Réunion supprimée",
     "failed": "Une erreur s'est produite",
-    "shareHint": "Le client ne reçoit ce lien que si vous l'envoyez. Copiez-le dans un message ou ajoutez-le à une notification."
+    "shareHint": "Le client ne reçoit ce lien que si vous l'envoyez.",
+    "shareHintNoChannel": "Le client ne reçoit ce lien que si vous l'envoyez. Aucun canal de messagerie n'est configuré : copiez-le vous-même dans un message.",
+    "send": {
+      "button": "Envoyer au client",
+      "title": "Envoyer le lien de l'appel vidéo",
+      "description": "Envoyer le lien de connexion à {name}.",
+      "messageLabel": "Note (facultatif)",
+      "messagePlaceholder": "Ce que le client doit savoir avant l'appel...",
+      "linkAlwaysIncluded": "Le lien de connexion est toujours ajouté sous votre note.",
+      "sms": "SMS",
+      "email": "E-mail",
+      "telegram": "Telegram",
+      "noPhone": "(pas de téléphone)",
+      "noEmail": "(pas d'e-mail)",
+      "noTelegram": "(pas de chat lié)",
+      "notAvailable": "(non configuré)",
+      "cancel": "Annuler",
+      "send": "Envoyer",
+      "sent": "Lien de l'appel vidéo envoyé",
+      "partlySent": "Envoyé par {sent} ; pas par {failed}",
+      "failed": "Impossible d'envoyer le lien"
+    },
+    "notify": {
+      "invitation": "Bonjour {name}, votre appel vidéo concernant le véhicule {vehicle} a lieu le {when}. Rejoignez-le ici : {url}",
+      "invitationNoTime": "Bonjour {name}, voici le lien de l'appel vidéo concernant le véhicule {vehicle} : {url}",
+      "joinLine": "Rejoignez l'appel vidéo ici : {url}",
+      "emailSubject": "Appel vidéo concernant votre {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/it/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Modificato quando viene usato un design",
     "settings_freezeInvoices": "Fatture precedenti bloccate",
     "inspection_reminders_sent": "Promemoria revisione inviati",
-    "inspection_reminders_send": "Promemoria revisione inviati"
+    "inspection_reminders_send": "Promemoria revisione inviati",
+    "service_videoCall_send": "Link videochiamata inviato"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Ha impostato {name} come design del documento predefinito",
     "settings_setDocumentDesignRule": "Modificato quando viene usato il design del documento {name}",
     "settings_freezeInvoices": "Ha bloccato {count, plural, one {# fattura} other {# fatture}} precedenti al blocco con il design attuale",
-    "inspection_reminders_sent": "Inviati {count} promemoria revisione"
+    "inspection_reminders_sent": "Inviati {count} promemoria revisione",
+    "service_videoCall_send": "Ha inviato il link della videochiamata via {channels}"
   }
 }

+ 28 - 1
messages/it/service.json

@@ -439,6 +439,33 @@
     "created": "Riunione creata. Il link è sull'ordine di lavoro.",
     "removed": "Riunione rimossa",
     "failed": "Qualcosa è andato storto",
-    "shareHint": "Il cliente riceve questo link solo quando glielo invii. Copialo in un messaggio o aggiungilo a una notifica."
+    "shareHint": "Il cliente riceve questo link solo quando lo invii.",
+    "shareHintNoChannel": "Il cliente riceve questo link solo quando lo invii. Nessun canale di messaggistica è configurato, quindi copialo tu in un messaggio.",
+    "send": {
+      "button": "Invia al cliente",
+      "title": "Invia il link della videochiamata",
+      "description": "Invia il link per partecipare a {name}.",
+      "messageLabel": "Nota (facoltativa)",
+      "messagePlaceholder": "Ciò che il cliente deve sapere prima della chiamata...",
+      "linkAlwaysIncluded": "Il link per partecipare viene sempre aggiunto sotto la tua nota.",
+      "sms": "SMS",
+      "email": "Email",
+      "telegram": "Telegram",
+      "noPhone": "(nessun telefono)",
+      "noEmail": "(nessuna email)",
+      "noTelegram": "(nessuna chat collegata)",
+      "notAvailable": "(non configurato)",
+      "cancel": "Annulla",
+      "send": "Invia",
+      "sent": "Link della videochiamata inviato",
+      "partlySent": "Inviato via {sent}; non via {failed}",
+      "failed": "Impossibile inviare il link"
+    },
+    "notify": {
+      "invitation": "Ciao {name}, la tua videochiamata per la {vehicle} è il {when}. Partecipa qui: {url}",
+      "invitationNoTime": "Ciao {name}, ecco il link della videochiamata per la {vehicle}: {url}",
+      "joinLine": "Partecipa alla videochiamata qui: {url}",
+      "emailSubject": "Videochiamata per la tua {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/lt/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dizainas",
     "settings_freezeInvoices": "Užrakintos senesnės sąskaitos",
     "inspection_reminders_sent": "Išsiųsti apžiūros priminimai",
-    "inspection_reminders_send": "Išsiųsti apžiūros priminimai"
+    "inspection_reminders_send": "Išsiųsti apžiūros priminimai",
+    "service_videoCall_send": "Išsiųsta vaizdo skambučio nuoroda"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Nustatė {name} kaip numatytąjį dokumento dizainą",
     "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dokumento dizainas {name}",
     "settings_freezeInvoices": "Užrakino {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}} iki užrakinimo su dabartiniu dizainu",
-    "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų"
+    "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų",
+    "service_videoCall_send": "Išsiuntė vaizdo skambučio nuorodą per {channels}"
   }
 }

+ 28 - 1
messages/lt/service.json

@@ -439,6 +439,33 @@
     "created": "Susitikimas sukurtas. Nuoroda yra darbo užsakyme.",
     "removed": "Susitikimas pašalintas",
     "failed": "Kažkas nutiko ne taip",
-    "shareHint": "Klientas šią nuorodą gauna tik tada, kai ją išsiunčiate. Nukopijuokite ją į žinutę arba pridėkite prie pranešimo."
+    "shareHint": "Klientas šią nuorodą gauna tik tada, kai ją išsiunčiate.",
+    "shareHintNoChannel": "Klientas šią nuorodą gauna tik tada, kai ją išsiunčiate. Pranešimų kanalas nenustatytas, todėl nukopijuokite ją į žinutę patys.",
+    "send": {
+      "button": "Siųsti klientui",
+      "title": "Siųsti vaizdo skambučio nuorodą",
+      "description": "Išsiųsti prisijungimo nuorodą klientui {name}.",
+      "messageLabel": "Pastaba (neprivaloma)",
+      "messagePlaceholder": "Ką klientas turėtų žinoti prieš skambutį...",
+      "linkAlwaysIncluded": "Prisijungimo nuoroda visada pridedama po jūsų pastaba.",
+      "sms": "SMS",
+      "email": "El. paštas",
+      "telegram": "Telegram",
+      "noPhone": "(nėra telefono)",
+      "noEmail": "(nėra el. pašto)",
+      "noTelegram": "(nesusietas pokalbis)",
+      "notAvailable": "(nenustatyta)",
+      "cancel": "Atšaukti",
+      "send": "Siųsti",
+      "sent": "Vaizdo skambučio nuoroda išsiųsta",
+      "partlySent": "Išsiųsta per {sent}; neišsiųsta per {failed}",
+      "failed": "Nepavyko išsiųsti nuorodos"
+    },
+    "notify": {
+      "invitation": "Sveiki, {name}, jūsų vaizdo skambutis dėl {vehicle} vyks {when}. Prisijunkite čia: {url}",
+      "invitationNoTime": "Sveiki, {name}, štai vaizdo skambučio dėl {vehicle} nuoroda: {url}",
+      "joinLine": "Prisijunkite prie vaizdo skambučio čia: {url}",
+      "emailSubject": "Vaizdo skambutis dėl jūsų {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/nb/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Endret når et design brukes",
     "settings_freezeInvoices": "Låste eldre fakturaer",
     "inspection_reminders_sent": "Sendte påminnelser om EU-kontroll",
-    "inspection_reminders_send": "Sendte påminnelser om EU-kontroll"
+    "inspection_reminders_send": "Sendte påminnelser om EU-kontroll",
+    "service_videoCall_send": "Sendte lenke til videosamtale"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Gjorde {name} til standard dokumentdesign",
     "settings_setDocumentDesignRule": "Endret når dokumentdesignet {name} brukes",
     "settings_freezeInvoices": "Låste {count, plural, one {# faktura} other {# fakturaer}} fra før låsing med gjeldende design",
-    "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll"
+    "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll",
+    "service_videoCall_send": "Sendte lenken til videosamtalen via {channels}"
   }
 }

+ 28 - 1
messages/nb/service.json

@@ -439,6 +439,33 @@
     "created": "Møte opprettet. Lenken ligger på arbeidsordren.",
     "removed": "Møte fjernet",
     "failed": "Noe gikk galt",
-    "shareHint": "Kunden får denne lenken bare når du sender den. Kopier den inn i en melding eller legg den ved et varsel."
+    "shareHint": "Kunden får denne lenken bare når du sender den.",
+    "shareHintNoChannel": "Kunden får denne lenken bare når du sender den. Ingen meldingskanal er satt opp, så kopier den inn i en melding selv.",
+    "send": {
+      "button": "Send til kunde",
+      "title": "Send lenken til videosamtalen",
+      "description": "Send lenken til {name}.",
+      "messageLabel": "Notat (valgfritt)",
+      "messagePlaceholder": "Noe kunden bør vite før samtalen...",
+      "linkAlwaysIncluded": "Lenken legges alltid til under notatet ditt.",
+      "sms": "SMS",
+      "email": "E-post",
+      "telegram": "Telegram",
+      "noPhone": "(ingen telefon)",
+      "noEmail": "(ingen e-post)",
+      "noTelegram": "(ingen chat koblet)",
+      "notAvailable": "(ikke satt opp)",
+      "cancel": "Avbryt",
+      "send": "Send",
+      "sent": "Lenken til videosamtalen er sendt",
+      "partlySent": "Sendt via {sent}; ikke via {failed}",
+      "failed": "Kunne ikke sende lenken"
+    },
+    "notify": {
+      "invitation": "Hei {name}, videosamtalen om {vehicle} er {when}. Bli med her: {url}",
+      "invitationNoTime": "Hei {name}, her er lenken til videosamtalen om {vehicle}: {url}",
+      "joinLine": "Bli med i videosamtalen her: {url}",
+      "emailSubject": "Videosamtale om din {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/nl/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Gewijzigd wanneer een ontwerp wordt gebruikt",
     "settings_freezeInvoices": "Oudere facturen vergrendeld",
     "inspection_reminders_sent": "Keuringsherinneringen verstuurd",
-    "inspection_reminders_send": "Keuringsherinneringen verstuurd"
+    "inspection_reminders_send": "Keuringsherinneringen verstuurd",
+    "service_videoCall_send": "Link videogesprek verstuurd"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Maakte {name} het standaard documentontwerp",
     "settings_setDocumentDesignRule": "Gewijzigd wanneer het documentontwerp {name} wordt gebruikt",
     "settings_freezeInvoices": "Vergrendelde {count, plural, one {# factuur} other {# facturen}} van vóór het vergrendelen met het huidige ontwerp",
-    "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd"
+    "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd",
+    "service_videoCall_send": "Heeft de link van het videogesprek verstuurd via {channels}"
   }
 }

+ 28 - 1
messages/nl/service.json

@@ -439,6 +439,33 @@
     "created": "Vergadering aangemaakt. De link staat op de werkorder.",
     "removed": "Vergadering verwijderd",
     "failed": "Er is iets misgegaan",
-    "shareHint": "De klant krijgt deze link alleen als u die verstuurt. Kopieer hem in een bericht of voeg hem toe aan een melding."
+    "shareHint": "De klant krijgt deze link alleen als je die verstuurt.",
+    "shareHintNoChannel": "De klant krijgt deze link alleen als je die verstuurt. Er is geen berichtenkanaal ingesteld, dus kopieer hem zelf in een bericht.",
+    "send": {
+      "button": "Naar klant sturen",
+      "title": "Link van het videogesprek sturen",
+      "description": "De deelnamelink naar {name} sturen.",
+      "messageLabel": "Notitie (optioneel)",
+      "messagePlaceholder": "Wat de klant vóór het gesprek moet weten...",
+      "linkAlwaysIncluded": "De deelnamelink komt altijd onder je notitie.",
+      "sms": "Sms",
+      "email": "E-mail",
+      "telegram": "Telegram",
+      "noPhone": "(geen telefoon)",
+      "noEmail": "(geen e-mail)",
+      "noTelegram": "(geen chat gekoppeld)",
+      "notAvailable": "(niet ingesteld)",
+      "cancel": "Annuleren",
+      "send": "Versturen",
+      "sent": "Link van het videogesprek verstuurd",
+      "partlySent": "Verstuurd via {sent}; niet via {failed}",
+      "failed": "Link kon niet worden verstuurd"
+    },
+    "notify": {
+      "invitation": "Hallo {name}, je videogesprek over de {vehicle} is op {when}. Neem hier deel: {url}",
+      "invitationNoTime": "Hallo {name}, hier is de link voor het videogesprek over de {vehicle}: {url}",
+      "joinLine": "Neem hier deel aan het videogesprek: {url}",
+      "emailSubject": "Videogesprek over je {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/pl/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt",
     "settings_freezeInvoices": "Zablokowano starsze faktury",
     "inspection_reminders_sent": "Wysłano przypomnienia o przeglądzie",
-    "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie"
+    "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie",
+    "service_videoCall_send": "Wysłano link do rozmowy wideo"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Ustawił {name} jako domyślny projekt dokumentu",
     "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt dokumentu {name}",
     "settings_freezeInvoices": "Zablokował {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}} sprzed blokady z bieżącym projektem",
-    "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie"
+    "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie",
+    "service_videoCall_send": "Wysłano link do rozmowy wideo przez {channels}"
   }
 }

+ 28 - 1
messages/pl/service.json

@@ -439,6 +439,33 @@
     "created": "Spotkanie utworzone. Link jest na zleceniu.",
     "removed": "Spotkanie usunięte",
     "failed": "Coś poszło nie tak",
-    "shareHint": "Klient dostaje ten link tylko wtedy, gdy go wyślesz. Skopiuj go do wiadomości lub dodaj do powiadomienia."
+    "shareHint": "Klient dostaje ten link tylko wtedy, gdy go wyślesz.",
+    "shareHintNoChannel": "Klient dostaje ten link tylko wtedy, gdy go wyślesz. Żaden kanał wiadomości nie jest skonfigurowany, więc skopiuj go do wiadomości samodzielnie.",
+    "send": {
+      "button": "Wyślij do klienta",
+      "title": "Wyślij link do rozmowy wideo",
+      "description": "Wyślij link dołączenia do {name}.",
+      "messageLabel": "Notatka (opcjonalnie)",
+      "messagePlaceholder": "Co klient powinien wiedzieć przed rozmową...",
+      "linkAlwaysIncluded": "Link dołączenia jest zawsze dodawany pod Twoją notatką.",
+      "sms": "SMS",
+      "email": "E-mail",
+      "telegram": "Telegram",
+      "noPhone": "(brak telefonu)",
+      "noEmail": "(brak e-maila)",
+      "noTelegram": "(brak powiązanego czatu)",
+      "notAvailable": "(nie skonfigurowano)",
+      "cancel": "Anuluj",
+      "send": "Wyślij",
+      "sent": "Link do rozmowy wideo wysłany",
+      "partlySent": "Wysłano przez {sent}; nie przez {failed}",
+      "failed": "Nie udało się wysłać linku"
+    },
+    "notify": {
+      "invitation": "Cześć {name}, Twoja rozmowa wideo dotycząca {vehicle} odbędzie się {when}. Dołącz tutaj: {url}",
+      "invitationNoTime": "Cześć {name}, oto link do rozmowy wideo dotyczącej {vehicle}: {url}",
+      "joinLine": "Dołącz do rozmowy wideo tutaj: {url}",
+      "emailSubject": "Rozmowa wideo dotycząca Twojego {vehicle}"
+    }
   }
 }

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

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Alterou quando um design é usado",
     "settings_freezeInvoices": "Faturas anteriores travadas",
     "inspection_reminders_sent": "Enviou lembretes de inspeção",
-    "inspection_reminders_send": "Enviou lembretes de inspeção"
+    "inspection_reminders_send": "Enviou lembretes de inspeção",
+    "service_videoCall_send": "Link da videochamada enviado"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Definiu {name} como design de documento padrão",
     "settings_setDocumentDesignRule": "Alterou quando o design de documento {name} é usado",
     "settings_freezeInvoices": "Travou {count, plural, one {# fatura} other {# faturas}} anteriores ao bloqueio com o design atual",
-    "inspection_reminders_sent": "Enviou {count} lembretes de inspeção"
+    "inspection_reminders_sent": "Enviou {count} lembretes de inspeção",
+    "service_videoCall_send": "Enviou o link da videochamada por {channels}"
   }
 }

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

@@ -439,6 +439,33 @@
     "created": "Reunião criada. O link está na ordem de serviço.",
     "removed": "Reunião removida",
     "failed": "Algo deu errado",
-    "shareHint": "O cliente só recebe este link quando você envia. Copie-o em uma mensagem ou adicione-o a uma notificação."
+    "shareHint": "O cliente só recebe este link quando você envia.",
+    "shareHintNoChannel": "O cliente só recebe este link quando você envia. Nenhum canal de mensagens está configurado, então copie-o você mesmo em uma mensagem.",
+    "send": {
+      "button": "Enviar ao cliente",
+      "title": "Enviar o link da videochamada",
+      "description": "Enviar o link de entrada para {name}.",
+      "messageLabel": "Observação (opcional)",
+      "messagePlaceholder": "O que o cliente deve saber antes da chamada...",
+      "linkAlwaysIncluded": "O link de entrada é sempre adicionado abaixo da sua observação.",
+      "sms": "SMS",
+      "email": "E-mail",
+      "telegram": "Telegram",
+      "noPhone": "(sem telefone)",
+      "noEmail": "(sem e-mail)",
+      "noTelegram": "(sem chat vinculado)",
+      "notAvailable": "(não configurado)",
+      "cancel": "Cancelar",
+      "send": "Enviar",
+      "sent": "Link da videochamada enviado",
+      "partlySent": "Enviado por {sent}; não por {failed}",
+      "failed": "Não foi possível enviar o link"
+    },
+    "notify": {
+      "invitation": "Olá {name}, sua videochamada sobre o {vehicle} é em {when}. Entre aqui: {url}",
+      "invitationNoTime": "Olá {name}, aqui está o link da videochamada sobre o {vehicle}: {url}",
+      "joinLine": "Entre na videochamada aqui: {url}",
+      "emailSubject": "Videochamada sobre o seu {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/ru/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Изменено, когда используется дизайн",
     "settings_freezeInvoices": "Зафиксированы старые счета",
     "inspection_reminders_sent": "Отправлены напоминания о техосмотре",
-    "inspection_reminders_send": "Отправлены напоминания о техосмотре"
+    "inspection_reminders_send": "Отправлены напоминания о техосмотре",
+    "service_videoCall_send": "Отправлена ссылка на видеозвонок"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "Сделал {name} дизайном документа по умолчанию",
     "settings_setDocumentDesignRule": "Изменено, когда используется дизайн документа {name}",
     "settings_freezeInvoices": "Зафиксировал {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}} до появления блокировки с текущим дизайном",
-    "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}"
+    "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}",
+    "service_videoCall_send": "Отправил(а) ссылку на видеозвонок через {channels}"
   }
 }

+ 28 - 1
messages/ru/service.json

@@ -439,6 +439,33 @@
     "created": "Встреча создана. Ссылка на заказ-наряде.",
     "removed": "Встреча удалена",
     "failed": "Что-то пошло не так",
-    "shareHint": "Клиент получает эту ссылку, только когда вы её отправите. Скопируйте её в сообщение или добавьте в уведомление."
+    "shareHint": "Клиент получает эту ссылку, только когда вы её отправите.",
+    "shareHintNoChannel": "Клиент получает эту ссылку, только когда вы её отправите. Канал сообщений не настроен, поэтому скопируйте её в сообщение сами.",
+    "send": {
+      "button": "Отправить клиенту",
+      "title": "Отправить ссылку на видеозвонок",
+      "description": "Отправить ссылку для подключения клиенту {name}.",
+      "messageLabel": "Примечание (необязательно)",
+      "messagePlaceholder": "Что клиенту стоит знать до звонка...",
+      "linkAlwaysIncluded": "Ссылка для подключения всегда добавляется под вашим примечанием.",
+      "sms": "SMS",
+      "email": "Эл. почта",
+      "telegram": "Telegram",
+      "noPhone": "(нет телефона)",
+      "noEmail": "(нет эл. почты)",
+      "noTelegram": "(чат не привязан)",
+      "notAvailable": "(не настроено)",
+      "cancel": "Отмена",
+      "send": "Отправить",
+      "sent": "Ссылка на видеозвонок отправлена",
+      "partlySent": "Отправлено через {sent}; не отправлено через {failed}",
+      "failed": "Не удалось отправить ссылку"
+    },
+    "notify": {
+      "invitation": "Здравствуйте, {name}! Видеозвонок по поводу {vehicle} состоится {when}. Подключайтесь здесь: {url}",
+      "invitationNoTime": "Здравствуйте, {name}! Вот ссылка на видеозвонок по поводу {vehicle}: {url}",
+      "joinLine": "Подключиться к видеозвонку: {url}",
+      "emailSubject": "Видеозвонок по поводу вашего {vehicle}"
+    }
   }
 }

+ 4 - 2
messages/tr/audit.json

@@ -163,7 +163,8 @@
     "settings_setDocumentDesignRule": "Bir tasarımın ne zaman kullanılacağı değiştirildi",
     "settings_freezeInvoices": "Eski faturalar kilitlendi",
     "inspection_reminders_sent": "Muayene hatırlatmaları gönderildi",
-    "inspection_reminders_send": "Muayene hatırlatmaları gönderildi"
+    "inspection_reminders_send": "Muayene hatırlatmaları gönderildi",
+    "service_videoCall_send": "Görüntülü görüşme bağlantısı gönderildi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -298,6 +299,7 @@
     "settings_applyDocumentDesign": "{name} tasarımını varsayılan belge tasarımı yaptı",
     "settings_setDocumentDesignRule": "{name} belge tasarımının ne zaman kullanılacağı değiştirildi",
     "settings_freezeInvoices": "Kilitlemeden önceki {count, plural, one {# faturayı} other {# faturayı}} mevcut tasarımla kilitledi",
-    "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi"
+    "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi",
+    "service_videoCall_send": "Görüntülü görüşme bağlantısını {channels} ile gönderdi"
   }
 }

+ 28 - 1
messages/tr/service.json

@@ -439,6 +439,33 @@
     "created": "Toplantı oluşturuldu. Bağlantı iş emrinde.",
     "removed": "Toplantı kaldırıldı",
     "failed": "Bir şeyler ters gitti",
-    "shareHint": "Müşteri bu bağlantıyı yalnızca siz gönderdiğinizde alır. Bir mesaja kopyalayın veya bir bildirime ekleyin."
+    "shareHint": "Müşteri bu bağlantıyı yalnızca siz gönderdiğinizde alır.",
+    "shareHintNoChannel": "Müşteri bu bağlantıyı yalnızca siz gönderdiğinizde alır. Hiçbir mesaj kanalı ayarlı değil; bağlantıyı bir mesaja kendiniz kopyalayın.",
+    "send": {
+      "button": "Müşteriye gönder",
+      "title": "Görüntülü görüşme bağlantısını gönder",
+      "description": "Katılım bağlantısını {name} adlı müşteriye gönder.",
+      "messageLabel": "Not (isteğe bağlı)",
+      "messagePlaceholder": "Müşterinin görüşmeden önce bilmesi gerekenler...",
+      "linkAlwaysIncluded": "Katılım bağlantısı her zaman notunuzun altına eklenir.",
+      "sms": "SMS",
+      "email": "E-posta",
+      "telegram": "Telegram",
+      "noPhone": "(telefon yok)",
+      "noEmail": "(e-posta yok)",
+      "noTelegram": "(bağlı sohbet yok)",
+      "notAvailable": "(ayarlı değil)",
+      "cancel": "İptal",
+      "send": "Gönder",
+      "sent": "Görüntülü görüşme bağlantısı gönderildi",
+      "partlySent": "{sent} ile gönderildi; {failed} ile gönderilemedi",
+      "failed": "Bağlantı gönderilemedi"
+    },
+    "notify": {
+      "invitation": "Merhaba {name}, {vehicle} ile ilgili görüntülü görüşmeniz {when} tarihinde. Buradan katılın: {url}",
+      "invitationNoTime": "Merhaba {name}, {vehicle} ile ilgili görüntülü görüşmenin bağlantısı: {url}",
+      "joinLine": "Görüntülü görüşmeye buradan katılın: {url}",
+      "emailSubject": "{vehicle} aracınızla ilgili görüntülü görüşme"
+    }
   }
 }

+ 21 - 0
src/__tests__/features/integrations/video-call.test.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+import { videoCallMessage } from '@/features/integrations/Lib/video-call'
+
+describe('videoCallMessage', () => {
+  const invitation = 'Hi Anna, your call is on Monday. Join here: https://z/1'
+  const joinLine = 'Join the video call here: https://z/1'
+
+  it('sends the stock invitation when nobody wrote a note', () => {
+    expect(videoCallMessage({ customMessage: undefined, invitation, joinLine })).toBe(invitation)
+    expect(videoCallMessage({ customMessage: '   ', invitation, joinLine })).toBe(invitation)
+  })
+
+  it('puts a note first and the join line after it, so the link cannot be lost', () => {
+    expect(videoCallMessage({ customMessage: ' Bring the key. ', invitation, joinLine })).toBe(
+      `Bring the key.\n\n${joinLine}`
+    )
+  })
+})

+ 155 - 0
src/features/integrations/Actions/videoCallActions.ts

@@ -0,0 +1,155 @@
+'use server'
+
+import { z } from 'zod'
+import { getLocale, getTranslations } from 'next-intl/server'
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { demoGuard } from '@/lib/demo'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { sendSmsToCustomer } from '@/features/sms/Actions/smsActions'
+import { sendNotificationEmail } from '@/features/email/Actions/emailActions'
+import { sendTelegramToCustomer } from '@/features/telegram/Actions/telegramActions'
+import { serviceVideoLink, videoCallMessage } from '../Lib/video-call'
+
+const sendVideoCallSchema = z.object({
+  serviceRecordId: z.string(),
+  channels: z.object({ sms: z.boolean(), email: z.boolean(), telegram: z.boolean() }),
+  customMessage: z.string().max(2000).optional(),
+})
+
+const customerSelect = {
+  id: true,
+  name: true,
+  email: true,
+  phone: true,
+  telegramChatId: true,
+} as const
+
+/**
+ * Send the work order's video call link to its customer, on the channels a
+ * person ticked. Nothing about a meeting reaches the customer any other way:
+ * creating one only puts the link on the work order, and this is the step
+ * that turns it into an invitation, done on purpose, with the text in view.
+ */
+export async function sendServiceVideoCall(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const data = sendVideoCallSchema.parse(input)
+      const record = await db.serviceRecord.findFirst({
+        where: { id: data.serviceRecordId, organizationId },
+        select: {
+          id: true,
+          title: true,
+          startDateTime: true,
+          customer: { select: customerSelect },
+          vehicle: {
+            select: {
+              year: true,
+              make: true,
+              model: true,
+              customer: { select: customerSelect },
+            },
+          },
+        },
+      })
+      if (!record) throw new Error('Work order not found')
+      const customer = record.customer ?? record.vehicle?.customer
+      if (!customer) throw new Error('No customer on this work order')
+      const link = await serviceVideoLink(organizationId, record.id)
+      if (!link) throw new Error('No video call on this work order')
+
+      const vehicle = record.vehicle
+        ? `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
+        : record.title
+      const t = await getTranslations('service.videoCall.notify')
+      let when: string | null = null
+      if (record.startDateTime) {
+        const [locale, timeZone] = await Promise.all([
+          getLocale(),
+          workshopTimeZone(organizationId),
+        ])
+        when = new Intl.DateTimeFormat(locale, {
+          dateStyle: 'full',
+          timeStyle: 'short',
+          timeZone,
+        }).format(record.startDateTime)
+      }
+      const body = videoCallMessage({
+        customMessage: data.customMessage,
+        invitation: when
+          ? t('invitation', { name: customer.name, vehicle, when, url: link.url })
+          : t('invitationNoTime', { name: customer.name, vehicle, url: link.url }),
+        joinLine: t('joinLine', { url: link.url }),
+      })
+
+      const sent: string[] = []
+      const failures: { channel: string; error: string }[] = []
+      // A ticked channel with nothing to send to is a failure the sender
+      // should hear about, not a quiet no-op.
+      const attempt = async (
+        channel: string,
+        address: string | null | undefined,
+        send: () => Promise<{ success: boolean; error?: string }>
+      ) => {
+        if (!address) {
+          failures.push({ channel, error: 'no address on file' })
+          return
+        }
+        const result = await send()
+        if (result.success) sent.push(channel)
+        else failures.push({ channel, error: result.error ?? 'send failed' })
+      }
+      if (data.channels.email) {
+        await attempt('email', customer.email, () =>
+          sendNotificationEmail({
+            recipientEmail: customer.email as string,
+            subject: t('emailSubject', { vehicle }),
+            body,
+          })
+        )
+      }
+      if (data.channels.sms) {
+        await attempt('sms', customer.phone, () =>
+          sendSmsToCustomer({
+            customerId: customer.id,
+            body,
+            relatedEntityType: 'service_record',
+            relatedEntityId: record.id,
+          })
+        )
+      }
+      if (data.channels.telegram) {
+        await attempt('telegram', customer.telegramChatId, () =>
+          sendTelegramToCustomer({
+            customerId: customer.id,
+            body,
+            relatedEntityType: 'service_record',
+            relatedEntityId: record.id,
+          })
+        )
+      }
+      if (sent.length === 0) {
+        throw new Error(
+          failures.map((f) => `${f.channel}: ${f.error}`).join('; ') || 'No channel to send on'
+        )
+      }
+      return { serviceRecordId: record.id, channels: sent, failures }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: 'service.videoCall.send',
+        entity: 'ServiceRecord',
+        entityId: result.serviceRecordId,
+        details: {
+          key: 'service_videoCall_send',
+          params: { channels: result.channels.join(', ') },
+        },
+      }),
+    }
+  )
+}

+ 40 - 0
src/features/integrations/Lib/video-call.ts

@@ -0,0 +1,40 @@
+import { db } from '@/lib/db'
+
+/** The video call link a connection attached to a work order, if any. */
+export async function serviceVideoLink(
+  organizationId: string,
+  serviceRecordId: string
+): Promise<{ url: string; provider: string } | null> {
+  const links = await db.integrationLink.findMany({
+    where: {
+      entityType: 'ServiceRecord',
+      entityId: serviceRecordId,
+      connection: { organizationId, status: { in: ['active', 'error'] } },
+    },
+    select: { metadata: true, connection: { select: { connectorId: true } } },
+  })
+  for (const l of links) {
+    const meta = (l.metadata as Record<string, unknown> | null) ?? {}
+    if (typeof meta.meetingUrl !== 'string') continue
+    return {
+      url: meta.meetingUrl,
+      provider: String(meta.meetingProvider ?? l.connection.connectorId),
+    }
+  }
+  return null
+}
+
+/**
+ * The text a customer receives for a video call. A note the sender wrote
+ * goes first, the join line always follows, so the link is never lost to an
+ * edit. Without a note, the stock invitation names the customer, the car
+ * and the time.
+ */
+export function videoCallMessage(input: {
+  customMessage: string | undefined
+  invitation: string
+  joinLine: string
+}): string {
+  const custom = input.customMessage?.trim()
+  return custom ? `${custom}\n\n${input.joinLine}` : input.invitation
+}

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

@@ -44,6 +44,9 @@ interface DetailsRightColumnProps {
     toNumber: string
   }[]
   videoCall?: ServiceVideoCall
+  smsEnabled?: boolean
+  emailEnabled?: boolean
+  telegramEnabled?: boolean
   designOptions?: DesignOption[]
   designFollowsName?: string | null
   designPinnedAt?: string | null
@@ -64,6 +67,9 @@ export function DetailsRightColumn({
   orgMembers,
   notificationHistory = [],
   videoCall,
+  smsEnabled = false,
+  emailEnabled = false,
+  telegramEnabled = false,
   designOptions = [],
   designFollowsName = null,
   designPinnedAt = null,
@@ -130,6 +136,10 @@ export function DetailsRightColumn({
           serviceRecordId={record.id}
           videoCall={videoCall}
           scheduled={Boolean(formState.initialData.startDateTime)}
+          customer={record.customer ?? record.vehicle?.customer ?? null}
+          smsEnabled={smsEnabled}
+          emailEnabled={emailEnabled}
+          telegramEnabled={telegramEnabled}
         />
       )}
       <TotalsSection

+ 205 - 0
src/features/vehicles/Components/service-page/SendVideoCallDialog.tsx

@@ -0,0 +1,205 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Loader2, Mail, MessageSquare, Send } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { sendServiceVideoCall } from '@/features/integrations/Actions/videoCallActions'
+
+export interface VideoCallRecipient {
+  name: string
+  email: string | null
+  phone: string | null
+  telegramChatId: string | null
+}
+
+/**
+ * Sending the video call link is a deliberate act: pick the channels, read
+ * what goes out, press Send. The link itself is always appended, so a note
+ * typed here cannot lose it.
+ */
+export function SendVideoCallDialog({
+  open,
+  onOpenChange,
+  serviceRecordId,
+  meetingUrl,
+  customer,
+  smsEnabled,
+  emailEnabled,
+  telegramEnabled,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  serviceRecordId: string
+  meetingUrl: string
+  customer: VideoCallRecipient
+  smsEnabled: boolean
+  emailEnabled: boolean
+  telegramEnabled: boolean
+}) {
+  const t = useTranslations('service.videoCall.send')
+  const [message, setMessage] = useState('')
+  const [sms, setSms] = useState(false)
+  const [email, setEmail] = useState(false)
+  const [telegram, setTelegram] = useState(false)
+  const [sending, setSending] = useState(false)
+
+  const canSms = smsEnabled && Boolean(customer.phone)
+  const canEmail = emailEnabled && Boolean(customer.email)
+  const canTelegram = telegramEnabled && Boolean(customer.telegramChatId)
+  const anyChosen = sms || email || telegram
+
+  const handleOpenChange = (next: boolean) => {
+    if (next) {
+      setMessage('')
+      // One channel the customer can actually be reached on starts ticked,
+      // so the common case is read, then Send.
+      setEmail(canEmail)
+      setSms(!canEmail && canSms)
+      setTelegram(!canEmail && !canSms && canTelegram)
+    }
+    onOpenChange(next)
+  }
+
+  const send = async () => {
+    if (!anyChosen) return
+    setSending(true)
+    try {
+      const res = await sendServiceVideoCall({
+        serviceRecordId,
+        channels: { sms, email, telegram },
+        customMessage: message.trim() || undefined,
+      })
+      if (!res.success) {
+        toast.error(res.error || t('failed'))
+        return
+      }
+      const failed = res.data?.failures ?? []
+      if (failed.length > 0) {
+        toast.warning(
+          t('partlySent', {
+            sent: (res.data?.channels ?? []).map((c) => t(c)).join(', '),
+            failed: failed.map((f) => t(f.channel)).join(', '),
+          })
+        )
+      } else {
+        toast.success(t('sent'))
+      }
+      onOpenChange(false)
+    } finally {
+      setSending(false)
+    }
+  }
+
+  const channelRow = (
+    id: string,
+    checked: boolean,
+    onChange: (v: boolean) => void,
+    enabled: boolean,
+    hasAddress: boolean,
+    icon: React.ReactNode,
+    label: string,
+    missing: string
+  ) => (
+    <div className="flex items-center gap-2">
+      <Checkbox
+        id={id}
+        checked={checked}
+        onCheckedChange={(v) => onChange(v === true)}
+        disabled={!enabled || !hasAddress}
+      />
+      <Label
+        htmlFor={id}
+        className={`flex items-center gap-1.5 text-sm ${!enabled || !hasAddress ? 'text-muted-foreground/50' : ''}`}
+      >
+        {icon}
+        {label}
+        {!hasAddress && <span className="text-xs">{missing}</span>}
+        {hasAddress && !enabled && <span className="text-xs">{t('notAvailable')}</span>}
+      </Label>
+    </div>
+  )
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description', { name: customer.name })}</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-4">
+          <div className="space-y-2">
+            {channelRow(
+              'video-call-email',
+              email,
+              setEmail,
+              emailEnabled,
+              Boolean(customer.email),
+              <Mail className="h-3.5 w-3.5" />,
+              t('email'),
+              t('noEmail')
+            )}
+            {channelRow(
+              'video-call-sms',
+              sms,
+              setSms,
+              smsEnabled,
+              Boolean(customer.phone),
+              <MessageSquare className="h-3.5 w-3.5" />,
+              t('sms'),
+              t('noPhone')
+            )}
+            {channelRow(
+              'video-call-telegram',
+              telegram,
+              setTelegram,
+              telegramEnabled,
+              Boolean(customer.telegramChatId),
+              <Send className="h-3.5 w-3.5" />,
+              t('telegram'),
+              t('noTelegram')
+            )}
+          </div>
+          <div className="space-y-1.5">
+            <Label htmlFor="video-call-message" className="text-sm">
+              {t('messageLabel')}
+            </Label>
+            <Textarea
+              id="video-call-message"
+              value={message}
+              onChange={(e) => setMessage(e.target.value)}
+              placeholder={t('messagePlaceholder')}
+              rows={3}
+            />
+            <p className="text-xs text-muted-foreground">{t('linkAlwaysIncluded')}</p>
+            <code className="block truncate rounded bg-muted px-2 py-1 text-xs">{meetingUrl}</code>
+          </div>
+          <div className="flex justify-end gap-2">
+            <Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
+              {t('cancel')}
+            </Button>
+            <Button onClick={send} disabled={!anyChosen || sending}>
+              {sending ? (
+                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+              ) : (
+                <Send className="mr-1 h-3.5 w-3.5" />
+              )}
+              {t('send')}
+            </Button>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

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

@@ -447,6 +447,9 @@ export function ServicePageClient({
                 rightColumn={
                   <DetailsRightColumn
                     videoCall={videoCall}
+                    smsEnabled={smsEnabled}
+                    emailEnabled={emailEnabled}
+                    telegramEnabled={telegramEnabled}
                     formState={formState}
                     actions={actions}
                     record={record}

+ 97 - 23
src/features/vehicles/Components/service-page/VideoCallSection.tsx

@@ -3,7 +3,7 @@
 import { useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
-import { Copy, ExternalLink, Loader2, Trash2, Video } from 'lucide-react'
+import { Copy, ExternalLink, Loader2, Send, Trash2, Video } from 'lucide-react'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { useConfirm } from '@/components/confirm-dialog'
@@ -12,6 +12,7 @@ import {
   removeServiceMeeting,
   type ServiceVideoCall,
 } from '@/features/integrations/Actions/integrationActions'
+import { SendVideoCallDialog, type VideoCallRecipient } from './SendVideoCallDialog'
 
 /**
  * The work order's video call, added on purpose.
@@ -20,24 +21,41 @@ import {
  * is created at the provider, and the link lands on the work order. The
  * customer never receives it unless someone sends it, and the panel says
  * so, because a link that quietly appeared read as an invitation already
- * gone out.
+ * gone out. Sending is its own button, which opens a dialog that shows the
+ * channels and the text before anything leaves.
+ *
+ * The actions on an existing link are anchors rather than buttons. The
+ * column sits inside the form's fieldset, which is disabled once an invoice
+ * is locked, and sending or copying a meeting link is not editing the
+ * invoice.
  */
 export function VideoCallSection({
   serviceRecordId,
   videoCall,
   scheduled,
+  customer,
+  smsEnabled = false,
+  emailEnabled = false,
+  telegramEnabled = false,
 }: {
   serviceRecordId: string
   videoCall: ServiceVideoCall
   /** Whether the work order has a start time; a meeting follows the schedule. */
   scheduled: boolean
+  /** Who the link would go to; null on a counter sale with nobody attached. */
+  customer?: VideoCallRecipient | null
+  smsEnabled?: boolean
+  emailEnabled?: boolean
+  telegramEnabled?: boolean
 }) {
   const t = useTranslations('service.videoCall')
   const tp = useTranslations('integrations.meeting')
   const router = useRouter()
   const confirm = useConfirm()
   const [busy, setBusy] = useState(false)
+  const [sendOpen, setSendOpen] = useState(false)
   const { link, providers } = videoCall
+  const canSend = Boolean(customer) && (smsEnabled || emailEnabled || telegramEnabled)
 
   if (!link && providers.length === 0) return null
 
@@ -59,7 +77,7 @@ export function VideoCallSection({
   }
 
   const remove = async () => {
-    if (!link) return
+    if (!link || busy) return
     const ok = await confirm({
       title: t('removeTitle'),
       description: t('removeDescription'),
@@ -91,6 +109,11 @@ export function VideoCallSection({
     }
   }
 
+  const act = (fn: () => void) => (e: React.MouseEvent) => {
+    e.preventDefault()
+    fn()
+  }
+
   return (
     <div className="rounded-lg border p-3 space-y-3">
       <div className="flex items-center gap-2">
@@ -109,35 +132,86 @@ export function VideoCallSection({
           <code className="block select-all truncate rounded bg-muted px-2 py-1 text-xs">
             {link.url}
           </code>
-          <div className="flex flex-wrap gap-2">
-            <Button size="sm" asChild>
-              <a href={link.url} target="_blank" rel="noopener noreferrer">
-                <ExternalLink className="mr-1 h-3.5 w-3.5" />
-                {t('join')}
+          {/* One labelled action, icon buttons beside it, so the row fits a narrow column. */}
+          <div className="flex items-center gap-1.5">
+            {canSend ? (
+              <Button size="sm" className="flex-1" asChild>
+                <a href="#" role="button" onClick={act(() => setSendOpen(true))}>
+                  <Send className="h-3.5 w-3.5" />
+                  {t('send.button')}
+                </a>
+              </Button>
+            ) : (
+              <Button size="sm" className="flex-1" asChild>
+                <a href={link.url} target="_blank" rel="noopener noreferrer">
+                  <ExternalLink className="h-3.5 w-3.5" />
+                  {t('join')}
+                </a>
+              </Button>
+            )}
+            {canSend && (
+              <Button size="icon-sm" variant="outline" asChild>
+                <a
+                  href={link.url}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  title={t('join')}
+                  aria-label={t('join')}
+                >
+                  <ExternalLink className="h-3.5 w-3.5" />
+                </a>
+              </Button>
+            )}
+            <Button size="icon-sm" variant="outline" asChild>
+              <a
+                href="#"
+                role="button"
+                title={t('copy')}
+                aria-label={t('copy')}
+                onClick={act(() => void copy())}
+              >
+                <Copy className="h-3.5 w-3.5" />
               </a>
             </Button>
-            <Button size="sm" variant="outline" onClick={copy}>
-              <Copy className="mr-1 h-3.5 w-3.5" />
-              {t('copy')}
-            </Button>
             {link.removable && (
               <Button
-                size="sm"
+                size="icon-sm"
                 variant="outline"
-                className="ml-auto text-destructive hover:text-destructive"
-                onClick={remove}
-                disabled={busy}
+                className="text-destructive hover:text-destructive"
+                asChild
               >
-                {busy ? (
-                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-                ) : (
-                  <Trash2 className="mr-1 h-3.5 w-3.5" />
-                )}
-                {t('remove')}
+                <a
+                  href="#"
+                  role="button"
+                  title={t('remove')}
+                  aria-label={t('remove')}
+                  aria-disabled={busy}
+                  onClick={act(() => void remove())}
+                >
+                  {busy ? (
+                    <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Trash2 className="h-3.5 w-3.5" />
+                  )}
+                </a>
               </Button>
             )}
           </div>
-          <p className="text-xs text-muted-foreground">{t('shareHint')}</p>
+          <p className="text-xs text-muted-foreground">
+            {canSend ? t('shareHint') : t('shareHintNoChannel')}
+          </p>
+          {customer && (
+            <SendVideoCallDialog
+              open={sendOpen}
+              onOpenChange={setSendOpen}
+              serviceRecordId={serviceRecordId}
+              meetingUrl={link.url}
+              customer={customer}
+              smsEnabled={smsEnabled}
+              emailEnabled={emailEnabled}
+              telegramEnabled={telegramEnabled}
+            />
+          )}
         </div>
       ) : (
         <div className="space-y-2">