Преглед изворни кода

rewamp the message page and allow recurring messages (#239)

Bernt Christian Egeland пре 1 месец
родитељ
комит
0b693c1c29
88 измењених фајлова са 3279 додато и 294 уклоњено
  1. 9 4
      messages/de/calendar.json
  2. 12 2
      messages/de/messages.json
  3. 0 1
      messages/de/navigation.json
  4. 76 0
      messages/de/scheduledMessages.json
  5. 2 0
      messages/de/settings.json
  6. 9 4
      messages/en/calendar.json
  7. 12 2
      messages/en/messages.json
  8. 0 1
      messages/en/navigation.json
  9. 76 0
      messages/en/scheduledMessages.json
  10. 2 0
      messages/en/settings.json
  11. 9 4
      messages/es/calendar.json
  12. 12 2
      messages/es/messages.json
  13. 0 1
      messages/es/navigation.json
  14. 76 0
      messages/es/scheduledMessages.json
  15. 2 0
      messages/es/settings.json
  16. 9 4
      messages/fr/calendar.json
  17. 12 2
      messages/fr/messages.json
  18. 0 1
      messages/fr/navigation.json
  19. 76 0
      messages/fr/scheduledMessages.json
  20. 2 0
      messages/fr/settings.json
  21. 9 4
      messages/it/calendar.json
  22. 12 2
      messages/it/messages.json
  23. 0 1
      messages/it/navigation.json
  24. 76 0
      messages/it/scheduledMessages.json
  25. 2 0
      messages/it/settings.json
  26. 9 4
      messages/lt/calendar.json
  27. 12 2
      messages/lt/messages.json
  28. 0 1
      messages/lt/navigation.json
  29. 76 0
      messages/lt/scheduledMessages.json
  30. 2 0
      messages/lt/settings.json
  31. 9 4
      messages/nb/calendar.json
  32. 12 2
      messages/nb/messages.json
  33. 0 1
      messages/nb/navigation.json
  34. 76 0
      messages/nb/scheduledMessages.json
  35. 2 0
      messages/nb/settings.json
  36. 9 4
      messages/nl/calendar.json
  37. 12 2
      messages/nl/messages.json
  38. 0 1
      messages/nl/navigation.json
  39. 76 0
      messages/nl/scheduledMessages.json
  40. 2 0
      messages/nl/settings.json
  41. 9 4
      messages/pl/calendar.json
  42. 12 2
      messages/pl/messages.json
  43. 0 1
      messages/pl/navigation.json
  44. 76 0
      messages/pl/scheduledMessages.json
  45. 2 0
      messages/pl/settings.json
  46. 9 4
      messages/pt-BR/calendar.json
  47. 12 2
      messages/pt-BR/messages.json
  48. 0 1
      messages/pt-BR/navigation.json
  49. 76 0
      messages/pt-BR/scheduledMessages.json
  50. 2 0
      messages/pt-BR/settings.json
  51. 9 4
      messages/ru/calendar.json
  52. 12 2
      messages/ru/messages.json
  53. 0 1
      messages/ru/navigation.json
  54. 76 0
      messages/ru/scheduledMessages.json
  55. 2 0
      messages/ru/settings.json
  56. 9 4
      messages/tr/calendar.json
  57. 12 2
      messages/tr/messages.json
  58. 0 1
      messages/tr/navigation.json
  59. 76 0
      messages/tr/scheduledMessages.json
  60. 2 0
      messages/tr/settings.json
  61. 66 0
      prisma/migrations/20260815210000_scheduled_messages/migration.sql
  62. 50 6
      prisma/schema.prisma
  63. 6 0
      src/app/(authenticated)/calendar/page.tsx
  64. 0 9
      src/app/(authenticated)/layout.tsx
  65. 25 37
      src/app/(authenticated)/messages/page.tsx
  66. 5 0
      src/app/(authenticated)/settings/data/data-settings.tsx
  67. 1 1
      src/app/(authenticated)/settings/data/page.tsx
  68. 15 1
      src/app/api/protected/backup/export/route.ts
  69. 34 0
      src/app/api/protected/backup/import/route.ts
  70. 1 3
      src/components/app-sidebar.tsx
  71. 1 0
      src/cronTasks.ts
  72. 40 2
      src/features/calendar/Actions/calendarActions.ts
  73. 33 1
      src/features/calendar/Components/CalendarClient.tsx
  74. 19 1
      src/features/calendar/Components/CalendarDayCell.tsx
  75. 15 0
      src/features/calendar/Components/CalendarEventList.tsx
  76. 2 1
      src/features/calendar/Components/calendar-utils.ts
  77. 330 0
      src/features/scheduled-messages/Actions/scheduledMessageActions.ts
  78. 314 0
      src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx
  79. 317 0
      src/features/scheduled-messages/Components/ScheduledMessageList.tsx
  80. 27 0
      src/features/scheduled-messages/Lib/availableChannels.ts
  81. 193 0
      src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts
  82. 62 0
      src/features/scheduled-messages/Schema/scheduledMessageSchema.ts
  83. 11 3
      src/features/settings/Actions/deleteContent.ts
  84. 36 0
      src/features/sms/Actions/smsActions.ts
  85. 374 145
      src/features/sms/Components/MessagesPageClient.tsx
  86. 2 0
      src/i18n/request.ts
  87. 2 0
      src/instrumentation.ts
  88. 110 0
      src/lib/cron/scheduled-messages.ts

+ 9 - 4
messages/de/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Aufträge",
     "services": "Aufträge",
     "reminders": "Erinnerungen",
     "reminders": "Erinnerungen",
-    "quotes": "Angebote"
+    "quotes": "Angebote",
+    "messages": "Nachrichten"
   },
   },
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Neue Erinnerung",
     "newReminder": "Neue Erinnerung",
     "newQuote": "Neues Angebot",
     "newQuote": "Neues Angebot",
     "openEvent": "Öffnen ({count})",
     "openEvent": "Öffnen ({count})",
-    "hint": "Tipp: Rechtsklick auf einen Tag zeigt Aktionen"
+    "hint": "Tipp: Rechtsklick auf einen Tag zeigt Aktionen",
+    "scheduleMessage": "Nachricht planen"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Entwurf",
       "draft": "Entwurf",
       "done": "Erledigt",
       "done": "Erledigt",
       "overdue": "Überfällig",
       "overdue": "Überfällig",
-      "upcoming": "Bevorstehend"
+      "upcoming": "Bevorstehend",
+      "failed": "Fehlgeschlagen",
+      "queued": "In Warteschlange"
     },
     },
     "type": {
     "type": {
       "service": "Service",
       "service": "Service",
       "reminder": "Erinnerung",
       "reminder": "Erinnerung",
-      "quote": "Angebot"
+      "quote": "Angebot",
+      "message": "Nachricht"
     },
     },
     "noEvents": "Keine Ereignisse",
     "noEvents": "Keine Ereignisse",
     "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag."
     "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag."

+ 12 - 2
messages/de/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS-Nachrichten",
     "featureName": "SMS-Nachrichten",
-    "featureDescription": "Senden und empfangen Sie SMS-Nachrichten mit Ihren Kunden direkt über Torqvoice."
+    "featureDescription": "Senden und empfangen Sie SMS-Nachrichten mit Ihren Kunden direkt über Torqvoice.",
+    "tabs": {
+      "inbox": "Posteingang",
+      "scheduled": "Geplant"
+    },
+    "smsNotConfiguredTitle": "SMS ist nicht eingerichtet",
+    "smsNotConfiguredHint": "Verbinden Sie einen SMS-Anbieter, um hier Nachrichten zu senden und zu empfangen. Geplante E-Mails funktionieren auch ohne.",
+    "goToSmsSettings": "Zu den SMS-Einstellungen"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS nicht konfiguriert",
     "title": "SMS nicht konfiguriert",
@@ -20,7 +27,10 @@
     "deleted": "Unterhaltung gelöscht",
     "deleted": "Unterhaltung gelöscht",
     "deleteError": "Unterhaltung konnte nicht gelöscht werden",
     "deleteError": "Unterhaltung konnte nicht gelöscht werden",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Unterhaltungen durchsuchen",
+    "noSearchResults": "Keine Unterhaltung passt zur Suche",
+    "now": "jetzt"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Keine Telefonnummer für {name} hinterlegt. Fügen Sie eine Telefonnummer hinzu, um Nachrichten zu senden.",
     "noPhone": "Keine Telefonnummer für {name} hinterlegt. Fügen Sie eine Telefonnummer hinzu, um Nachrichten zu senden.",

+ 0 - 1
messages/de/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Kunden",
     "clients": "Kunden",
     "customers": "Kunden",
     "customers": "Kunden",
     "messages": "Nachrichten",
     "messages": "Nachrichten",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Werkstatt",
     "workshop": "Werkstatt",
     "vehicles": "Fahrzeuge",
     "vehicles": "Fahrzeuge",

+ 76 - 0
messages/de/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Nachricht planen",
+    "editTitle": "Geplante Nachricht bearbeiten",
+    "channelLabel": "Senden per",
+    "channels": {
+      "email": "E-Mail",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Notiz in der App"
+    },
+    "repeatLabel": "Wiederholen",
+    "frequencies": {
+      "once": "Einmal",
+      "daily": "Täglich",
+      "weekly": "Wöchentlich",
+      "biweekly": "Alle 2 Wochen",
+      "monthly": "Monatlich",
+      "yearly": "Jährlich"
+    },
+    "customerLabel": "Kunde",
+    "selectCustomer": "Kunde auswählen",
+    "none": "Keiner",
+    "recipientEmail": "E-Mail-Adresse",
+    "recipientPhone": "Telefonnummer",
+    "recipientChat": "Telegram-Chat-ID",
+    "recipientOverride": "Stattdessen an eine andere Adresse",
+    "recipientHintCustomer": "Leer lassen, um die hinterlegten Daten zu nutzen.",
+    "recipientHintDirect": "Kunde wählen oder Ziel eintragen.",
+    "subjectLabel": "Betreff",
+    "subjectPlaceholder": "z. B. Ihr Auto ist fertig",
+    "messageLabel": "Nachricht",
+    "messagePlaceholder": "Was möchten Sie sagen?",
+    "characters": "{count} / {max} Zeichen",
+    "dateLabel": "Datum",
+    "timeLabel": "Uhrzeit",
+    "endDateLabel": "Wiederholen bis",
+    "endDateHint": "Leer lassen, um weiter zu wiederholen.",
+    "schedule": "Planen",
+    "scheduled": "Nachricht geplant",
+    "updated": "Geplante Nachricht aktualisiert",
+    "saveError": "Nachricht konnte nicht gespeichert werden"
+  },
+  "list": {
+    "scheduleMessage": "Nachricht planen",
+    "scheduleForCustomer": "Nachricht planen",
+    "filters": {
+      "upcoming": "Anstehend",
+      "sent": "Gesendet",
+      "failed": "Fehlgeschlagen",
+      "cancelled": "Abgebrochen",
+      "all": "Alle"
+    },
+    "status": {
+      "scheduled": "Geplant",
+      "sent": "Gesendet",
+      "failed": "Fehlgeschlagen",
+      "cancelled": "Abgebrochen"
+    },
+    "empty": "Nichts geplant",
+    "emptyHint": "Rechtsklick auf einen Tag im Kalender, oder Nachricht planen.",
+    "noRecipient": "Kein Empfänger",
+    "sentCount": "{count}x gesendet",
+    "rowMenu": "Nachrichtenmenü",
+    "sendNow": "Jetzt senden",
+    "cancel": "Senden abbrechen",
+    "sentNow": "Nachricht gesendet",
+    "sendError": "Nachricht konnte nicht gesendet werden",
+    "cancelled": "Senden abgebrochen",
+    "cancelError": "Nachricht konnte nicht abgebrochen werden",
+    "deleted": "Nachricht gelöscht",
+    "deleteError": "Nachricht konnte nicht gelöscht werden",
+    "deleteTitle": "Diese Nachricht löschen?",
+    "deleteDescription": "Die geplante Nachricht wird endgültig entfernt. Bereits Gesendetes bleibt in der Unterhaltung."
+  }
+}

+ 2 - 0
messages/de/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Aktivitäts- und Änderungsverlauf",
     "optAuditLogsDesc": "Aktivitäts- und Änderungsverlauf",
     "optSmsMessages": "SMS-Nachrichten",
     "optSmsMessages": "SMS-Nachrichten",
     "optSmsMessagesDesc": "Gesendete und empfangene SMS-Nachrichten",
     "optSmsMessagesDesc": "Gesendete und empfangene SMS-Nachrichten",
+    "optScheduledMessages": "Geplante Nachrichten",
+    "optScheduledMessagesDesc": "Wartende und wiederkehrende Nachrichten, die noch gesendet werden",
     "optNotifications": "Benachrichtigungen",
     "optNotifications": "Benachrichtigungen",
     "optNotificationsDesc": "Systembenachrichtigungen",
     "optNotificationsDesc": "Systembenachrichtigungen",
     "optCustomFields": "Benutzerdefinierte Felder",
     "optCustomFields": "Benutzerdefinierte Felder",

+ 9 - 4
messages/en/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Services",
     "services": "Services",
     "reminders": "Reminders",
     "reminders": "Reminders",
-    "quotes": "Quotes"
+    "quotes": "Quotes",
+    "messages": "Messages"
   },
   },
   "selectVehicle": "Select Vehicle for Work Order",
   "selectVehicle": "Select Vehicle for Work Order",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "New reminder",
     "newReminder": "New reminder",
     "newQuote": "New quote",
     "newQuote": "New quote",
     "openEvent": "Open ({count})",
     "openEvent": "Open ({count})",
-    "hint": "Tip: right-click a day for actions"
+    "hint": "Tip: right-click a day for actions",
+    "scheduleMessage": "Schedule message"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Draft",
       "draft": "Draft",
       "done": "Done",
       "done": "Done",
       "overdue": "Overdue",
       "overdue": "Overdue",
-      "upcoming": "Upcoming"
+      "upcoming": "Upcoming",
+      "failed": "Failed",
+      "queued": "Queued"
     },
     },
     "type": {
     "type": {
       "service": "Service",
       "service": "Service",
       "reminder": "Reminder",
       "reminder": "Reminder",
-      "quote": "Quote"
+      "quote": "Quote",
+      "message": "Message"
     },
     },
     "noEvents": "No events",
     "noEvents": "No events",
     "selectDay": "Select a day with events or create a new work order."
     "selectDay": "Select a day with events or create a new work order."

+ 12 - 2
messages/en/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS Messaging",
     "featureName": "SMS Messaging",
-    "featureDescription": "Send and receive SMS messages with your customers directly from Torqvoice."
+    "featureDescription": "Send and receive SMS messages with your customers directly from Torqvoice.",
+    "tabs": {
+      "inbox": "Inbox",
+      "scheduled": "Scheduled"
+    },
+    "smsNotConfiguredTitle": "SMS is not set up",
+    "smsNotConfiguredHint": "Connect an SMS provider to send and receive texts here. Scheduled email still works without it.",
+    "goToSmsSettings": "Go to SMS settings"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS Not Configured",
     "title": "SMS Not Configured",
@@ -20,7 +27,10 @@
     "deleted": "Conversation deleted",
     "deleted": "Conversation deleted",
     "deleteError": "Failed to delete conversation",
     "deleteError": "Failed to delete conversation",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Search conversations",
+    "noSearchResults": "No conversations match your search",
+    "now": "now"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "No phone number on file for {name}. Add a phone number to start messaging.",
     "noPhone": "No phone number on file for {name}. Add a phone number to start messaging.",

+ 0 - 1
messages/en/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Clients",
     "clients": "Clients",
     "customers": "Customers",
     "customers": "Customers",
     "messages": "Messages",
     "messages": "Messages",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Workshop",
     "workshop": "Workshop",
     "vehicles": "Vehicles",
     "vehicles": "Vehicles",

+ 76 - 0
messages/en/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Schedule a message",
+    "editTitle": "Edit scheduled message",
+    "channelLabel": "Send by",
+    "channels": {
+      "email": "Email",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "In-app note"
+    },
+    "repeatLabel": "Repeat",
+    "frequencies": {
+      "once": "Once",
+      "daily": "Daily",
+      "weekly": "Weekly",
+      "biweekly": "Every 2 weeks",
+      "monthly": "Monthly",
+      "yearly": "Yearly"
+    },
+    "customerLabel": "Customer",
+    "selectCustomer": "Select customer",
+    "none": "None",
+    "recipientEmail": "Email address",
+    "recipientPhone": "Phone number",
+    "recipientChat": "Telegram chat id",
+    "recipientOverride": "Send to another address instead",
+    "recipientHintCustomer": "Leave empty to use the details on file.",
+    "recipientHintDirect": "Pick a customer, or type where this goes.",
+    "subjectLabel": "Subject",
+    "subjectPlaceholder": "e.g. Your car is ready",
+    "messageLabel": "Message",
+    "messagePlaceholder": "What should we say?",
+    "characters": "{count} / {max} characters",
+    "dateLabel": "Date",
+    "timeLabel": "Time",
+    "endDateLabel": "Repeat until",
+    "endDateHint": "Leave empty to keep repeating.",
+    "schedule": "Schedule",
+    "scheduled": "Message scheduled",
+    "updated": "Scheduled message updated",
+    "saveError": "Could not save the message"
+  },
+  "list": {
+    "scheduleMessage": "Schedule message",
+    "scheduleForCustomer": "Schedule a message",
+    "filters": {
+      "upcoming": "Upcoming",
+      "sent": "Sent",
+      "failed": "Failed",
+      "cancelled": "Cancelled",
+      "all": "All"
+    },
+    "status": {
+      "scheduled": "Scheduled",
+      "sent": "Sent",
+      "failed": "Failed",
+      "cancelled": "Cancelled"
+    },
+    "empty": "Nothing scheduled",
+    "emptyHint": "Right-click a day in the calendar, or use Schedule message.",
+    "noRecipient": "No recipient",
+    "sentCount": "sent {count}x",
+    "rowMenu": "Message menu",
+    "sendNow": "Send now",
+    "cancel": "Cancel sending",
+    "sentNow": "Message sent",
+    "sendError": "Could not send the message",
+    "cancelled": "Sending cancelled",
+    "cancelError": "Could not cancel the message",
+    "deleted": "Message deleted",
+    "deleteError": "Could not delete the message",
+    "deleteTitle": "Delete this message?",
+    "deleteDescription": "The scheduled message is removed for good. Anything already sent stays in the conversation."
+  }
+}

+ 2 - 0
messages/en/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Activity and change history",
     "optAuditLogsDesc": "Activity and change history",
     "optSmsMessages": "SMS Messages",
     "optSmsMessages": "SMS Messages",
     "optSmsMessagesDesc": "Sent and received SMS messages",
     "optSmsMessagesDesc": "Sent and received SMS messages",
+    "optScheduledMessages": "Scheduled Messages",
+    "optScheduledMessagesDesc": "Queued and repeating messages waiting to go out",
     "optNotifications": "Notifications",
     "optNotifications": "Notifications",
     "optNotificationsDesc": "System notifications",
     "optNotificationsDesc": "System notifications",
     "optCustomFields": "Custom Fields",
     "optCustomFields": "Custom Fields",

+ 9 - 4
messages/es/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Servicios",
     "services": "Servicios",
     "reminders": "Recordatorios",
     "reminders": "Recordatorios",
-    "quotes": "Presupuestos"
+    "quotes": "Presupuestos",
+    "messages": "Mensajes"
   },
   },
   "selectVehicle": "Seleccionar vehículo para orden de trabajo",
   "selectVehicle": "Seleccionar vehículo para orden de trabajo",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Nuevo recordatorio",
     "newReminder": "Nuevo recordatorio",
     "newQuote": "Nuevo presupuesto",
     "newQuote": "Nuevo presupuesto",
     "openEvent": "Abrir ({count})",
     "openEvent": "Abrir ({count})",
-    "hint": "Consejo: haz clic derecho en un día para ver las acciones"
+    "hint": "Consejo: haz clic derecho en un día para ver las acciones",
+    "scheduleMessage": "Programar mensaje"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Borrador",
       "draft": "Borrador",
       "done": "Hecho",
       "done": "Hecho",
       "overdue": "Vencido",
       "overdue": "Vencido",
-      "upcoming": "Próximo"
+      "upcoming": "Próximo",
+      "failed": "Fallido",
+      "queued": "En cola"
     },
     },
     "type": {
     "type": {
       "service": "Servicio",
       "service": "Servicio",
       "reminder": "Recordatorio",
       "reminder": "Recordatorio",
-      "quote": "Presupuesto"
+      "quote": "Presupuesto",
+      "message": "Mensaje"
     },
     },
     "noEvents": "Sin eventos",
     "noEvents": "Sin eventos",
     "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo."
     "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo."

+ 12 - 2
messages/es/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "Mensajería SMS",
     "featureName": "Mensajería SMS",
-    "featureDescription": "Envíe y reciba mensajes SMS con sus clientes directamente desde Torqvoice."
+    "featureDescription": "Envíe y reciba mensajes SMS con sus clientes directamente desde Torqvoice.",
+    "tabs": {
+      "inbox": "Bandeja",
+      "scheduled": "Programados"
+    },
+    "smsNotConfiguredTitle": "SMS no está configurado",
+    "smsNotConfiguredHint": "Conecta un proveedor de SMS para enviar y recibir mensajes aquí. El correo programado funciona igual.",
+    "goToSmsSettings": "Ir a ajustes de SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS no configurado",
     "title": "SMS no configurado",
@@ -20,7 +27,10 @@
     "deleted": "Conversación eliminada",
     "deleted": "Conversación eliminada",
     "deleteError": "No se pudo eliminar la conversación",
     "deleteError": "No se pudo eliminar la conversación",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Buscar conversaciones",
+    "noSearchResults": "Ninguna conversación coincide con la búsqueda",
+    "now": "ahora"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "No hay número de teléfono registrado para {name}. Agregue un número de teléfono para comenzar a enviar mensajes.",
     "noPhone": "No hay número de teléfono registrado para {name}. Agregue un número de teléfono para comenzar a enviar mensajes.",

+ 0 - 1
messages/es/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Clientes",
     "clients": "Clientes",
     "customers": "Clientes",
     "customers": "Clientes",
     "messages": "Mensajes",
     "messages": "Mensajes",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Taller",
     "workshop": "Taller",
     "vehicles": "Vehículos",
     "vehicles": "Vehículos",

+ 76 - 0
messages/es/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Programar un mensaje",
+    "editTitle": "Editar mensaje programado",
+    "channelLabel": "Enviar por",
+    "channels": {
+      "email": "Correo",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Nota en la app"
+    },
+    "repeatLabel": "Repetir",
+    "frequencies": {
+      "once": "Una vez",
+      "daily": "Diario",
+      "weekly": "Semanal",
+      "biweekly": "Cada 2 semanas",
+      "monthly": "Mensual",
+      "yearly": "Anual"
+    },
+    "customerLabel": "Cliente",
+    "selectCustomer": "Seleccionar cliente",
+    "none": "Ninguno",
+    "recipientEmail": "Dirección de correo",
+    "recipientPhone": "Número de teléfono",
+    "recipientChat": "ID de chat de Telegram",
+    "recipientOverride": "Enviar a otra dirección",
+    "recipientHintCustomer": "Déjalo vacío para usar los datos guardados.",
+    "recipientHintDirect": "Elige un cliente o escribe el destino.",
+    "subjectLabel": "Asunto",
+    "subjectPlaceholder": "p. ej. Su coche está listo",
+    "messageLabel": "Mensaje",
+    "messagePlaceholder": "¿Qué quieres decir?",
+    "characters": "{count} / {max} caracteres",
+    "dateLabel": "Fecha",
+    "timeLabel": "Hora",
+    "endDateLabel": "Repetir hasta",
+    "endDateHint": "Déjalo vacío para repetir siempre.",
+    "schedule": "Programar",
+    "scheduled": "Mensaje programado",
+    "updated": "Mensaje programado actualizado",
+    "saveError": "No se pudo guardar el mensaje"
+  },
+  "list": {
+    "scheduleMessage": "Programar mensaje",
+    "scheduleForCustomer": "Programar un mensaje",
+    "filters": {
+      "upcoming": "Próximos",
+      "sent": "Enviados",
+      "failed": "Fallidos",
+      "cancelled": "Cancelados",
+      "all": "Todos"
+    },
+    "status": {
+      "scheduled": "Programado",
+      "sent": "Enviado",
+      "failed": "Fallido",
+      "cancelled": "Cancelado"
+    },
+    "empty": "Nada programado",
+    "emptyHint": "Haz clic derecho en un día del calendario o usa Programar mensaje.",
+    "noRecipient": "Sin destinatario",
+    "sentCount": "enviado {count}x",
+    "rowMenu": "Menú del mensaje",
+    "sendNow": "Enviar ahora",
+    "cancel": "Cancelar envío",
+    "sentNow": "Mensaje enviado",
+    "sendError": "No se pudo enviar el mensaje",
+    "cancelled": "Envío cancelado",
+    "cancelError": "No se pudo cancelar el mensaje",
+    "deleted": "Mensaje eliminado",
+    "deleteError": "No se pudo eliminar el mensaje",
+    "deleteTitle": "¿Eliminar este mensaje?",
+    "deleteDescription": "El mensaje programado se elimina definitivamente. Lo ya enviado permanece en la conversación."
+  }
+}

+ 2 - 0
messages/es/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Historial de actividad y cambios",
     "optAuditLogsDesc": "Historial de actividad y cambios",
     "optSmsMessages": "Mensajes SMS",
     "optSmsMessages": "Mensajes SMS",
     "optSmsMessagesDesc": "Mensajes SMS enviados y recibidos",
     "optSmsMessagesDesc": "Mensajes SMS enviados y recibidos",
+    "optScheduledMessages": "Mensajes programados",
+    "optScheduledMessagesDesc": "Mensajes en cola y recurrentes pendientes de envío",
     "optNotifications": "Notificaciones",
     "optNotifications": "Notificaciones",
     "optNotificationsDesc": "Notificaciones del sistema",
     "optNotificationsDesc": "Notificaciones del sistema",
     "optCustomFields": "Campos personalizados",
     "optCustomFields": "Campos personalizados",

+ 9 - 4
messages/fr/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Services",
     "services": "Services",
     "reminders": "Rappels",
     "reminders": "Rappels",
-    "quotes": "Devis"
+    "quotes": "Devis",
+    "messages": "Messages"
   },
   },
   "selectVehicle": "Sélectionner un véhicule pour l’ordre de travail",
   "selectVehicle": "Sélectionner un véhicule pour l’ordre de travail",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Nouveau rappel",
     "newReminder": "Nouveau rappel",
     "newQuote": "Nouveau devis",
     "newQuote": "Nouveau devis",
     "openEvent": "Ouvrir ({count})",
     "openEvent": "Ouvrir ({count})",
-    "hint": "Astuce : faites un clic droit sur un jour pour voir les actions"
+    "hint": "Astuce : faites un clic droit sur un jour pour voir les actions",
+    "scheduleMessage": "Programmer un message"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Brouillon",
       "draft": "Brouillon",
       "done": "Fait",
       "done": "Fait",
       "overdue": "En retard",
       "overdue": "En retard",
-      "upcoming": "À venir"
+      "upcoming": "À venir",
+      "failed": "Échec",
+      "queued": "En file"
     },
     },
     "type": {
     "type": {
       "service": "Service",
       "service": "Service",
       "reminder": "Rappel",
       "reminder": "Rappel",
-      "quote": "Devis"
+      "quote": "Devis",
+      "message": "Message"
     },
     },
     "noEvents": "Aucun événement",
     "noEvents": "Aucun événement",
     "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail."
     "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail."

+ 12 - 2
messages/fr/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "Messagerie SMS",
     "featureName": "Messagerie SMS",
-    "featureDescription": "Envoyez et recevez des SMS avec vos clients directement depuis Torqvoice."
+    "featureDescription": "Envoyez et recevez des SMS avec vos clients directement depuis Torqvoice.",
+    "tabs": {
+      "inbox": "Boîte de réception",
+      "scheduled": "Programmés"
+    },
+    "smsNotConfiguredTitle": "Le SMS n'est pas configuré",
+    "smsNotConfiguredHint": "Connectez un fournisseur SMS pour envoyer et recevoir des messages ici. Les e-mails programmés fonctionnent sans cela.",
+    "goToSmsSettings": "Aller aux réglages SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS non configuré",
     "title": "SMS non configuré",
@@ -20,7 +27,10 @@
     "deleted": "Conversation supprimée",
     "deleted": "Conversation supprimée",
     "deleteError": "Impossible de supprimer la conversation",
     "deleteError": "Impossible de supprimer la conversation",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Rechercher des conversations",
+    "noSearchResults": "Aucune conversation ne correspond à la recherche",
+    "now": "maintenant"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Aucun numéro de téléphone enregistré pour {name}. Ajoutez un numéro de téléphone pour commencer à envoyer des messages.",
     "noPhone": "Aucun numéro de téléphone enregistré pour {name}. Ajoutez un numéro de téléphone pour commencer à envoyer des messages.",

+ 0 - 1
messages/fr/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Clients",
     "clients": "Clients",
     "customers": "Clients",
     "customers": "Clients",
     "messages": "Messages",
     "messages": "Messages",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Atelier",
     "workshop": "Atelier",
     "vehicles": "Véhicules",
     "vehicles": "Véhicules",

+ 76 - 0
messages/fr/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Programmer un message",
+    "editTitle": "Modifier le message programmé",
+    "channelLabel": "Envoyer par",
+    "channels": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Note dans l'app"
+    },
+    "repeatLabel": "Répéter",
+    "frequencies": {
+      "once": "Une fois",
+      "daily": "Chaque jour",
+      "weekly": "Chaque semaine",
+      "biweekly": "Toutes les 2 semaines",
+      "monthly": "Chaque mois",
+      "yearly": "Chaque année"
+    },
+    "customerLabel": "Client",
+    "selectCustomer": "Sélectionner un client",
+    "none": "Aucun",
+    "recipientEmail": "Adresse e-mail",
+    "recipientPhone": "Numéro de téléphone",
+    "recipientChat": "Identifiant de chat Telegram",
+    "recipientOverride": "Envoyer à une autre adresse",
+    "recipientHintCustomer": "Laissez vide pour utiliser les coordonnées enregistrées.",
+    "recipientHintDirect": "Choisissez un client ou saisissez la destination.",
+    "subjectLabel": "Objet",
+    "subjectPlaceholder": "ex. Votre voiture est prête",
+    "messageLabel": "Message",
+    "messagePlaceholder": "Que voulez-vous dire ?",
+    "characters": "{count} / {max} caractères",
+    "dateLabel": "Date",
+    "timeLabel": "Heure",
+    "endDateLabel": "Répéter jusqu'au",
+    "endDateHint": "Laissez vide pour répéter sans fin.",
+    "schedule": "Programmer",
+    "scheduled": "Message programmé",
+    "updated": "Message programmé mis à jour",
+    "saveError": "Impossible d'enregistrer le message"
+  },
+  "list": {
+    "scheduleMessage": "Programmer un message",
+    "scheduleForCustomer": "Programmer un message",
+    "filters": {
+      "upcoming": "À venir",
+      "sent": "Envoyés",
+      "failed": "Échecs",
+      "cancelled": "Annulés",
+      "all": "Tous"
+    },
+    "status": {
+      "scheduled": "Programmé",
+      "sent": "Envoyé",
+      "failed": "Échec",
+      "cancelled": "Annulé"
+    },
+    "empty": "Rien de programmé",
+    "emptyHint": "Faites un clic droit sur un jour du calendrier, ou utilisez Programmer un message.",
+    "noRecipient": "Aucun destinataire",
+    "sentCount": "envoyé {count}x",
+    "rowMenu": "Menu du message",
+    "sendNow": "Envoyer maintenant",
+    "cancel": "Annuler l'envoi",
+    "sentNow": "Message envoyé",
+    "sendError": "Impossible d'envoyer le message",
+    "cancelled": "Envoi annulé",
+    "cancelError": "Impossible d'annuler le message",
+    "deleted": "Message supprimé",
+    "deleteError": "Impossible de supprimer le message",
+    "deleteTitle": "Supprimer ce message ?",
+    "deleteDescription": "Le message programmé est supprimé définitivement. Ce qui a déjà été envoyé reste dans la conversation."
+  }
+}

+ 2 - 0
messages/fr/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Historique des activités et des modifications",
     "optAuditLogsDesc": "Historique des activités et des modifications",
     "optSmsMessages": "Messages SMS",
     "optSmsMessages": "Messages SMS",
     "optSmsMessagesDesc": "Messages SMS envoyés et reçus",
     "optSmsMessagesDesc": "Messages SMS envoyés et reçus",
+    "optScheduledMessages": "Messages programmés",
+    "optScheduledMessagesDesc": "Messages en attente et récurrents qui doivent encore partir",
     "optNotifications": "Notifications",
     "optNotifications": "Notifications",
     "optNotificationsDesc": "Notifications système",
     "optNotificationsDesc": "Notifications système",
     "optCustomFields": "Champs personnalises",
     "optCustomFields": "Champs personnalises",

+ 9 - 4
messages/it/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Servizi",
     "services": "Servizi",
     "reminders": "Promemoria",
     "reminders": "Promemoria",
-    "quotes": "Preventivi"
+    "quotes": "Preventivi",
+    "messages": "Messaggi"
   },
   },
   "selectVehicle": "Seleziona veicolo per ordine di lavoro",
   "selectVehicle": "Seleziona veicolo per ordine di lavoro",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Nuovo promemoria",
     "newReminder": "Nuovo promemoria",
     "newQuote": "Nuovo preventivo",
     "newQuote": "Nuovo preventivo",
     "openEvent": "Apri ({count})",
     "openEvent": "Apri ({count})",
-    "hint": "Suggerimento: fai clic con il tasto destro su un giorno per le azioni"
+    "hint": "Suggerimento: fai clic con il tasto destro su un giorno per le azioni",
+    "scheduleMessage": "Pianifica messaggio"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Bozza",
       "draft": "Bozza",
       "done": "Fatto",
       "done": "Fatto",
       "overdue": "Scaduto",
       "overdue": "Scaduto",
-      "upcoming": "In arrivo"
+      "upcoming": "In arrivo",
+      "failed": "Non riuscito",
+      "queued": "In coda"
     },
     },
     "type": {
     "type": {
       "service": "Servizio",
       "service": "Servizio",
       "reminder": "Promemoria",
       "reminder": "Promemoria",
-      "quote": "Preventivo"
+      "quote": "Preventivo",
+      "message": "Messaggio"
     },
     },
     "noEvents": "Nessun evento",
     "noEvents": "Nessun evento",
     "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro."
     "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro."

+ 12 - 2
messages/it/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "Messaggi SMS",
     "featureName": "Messaggi SMS",
-    "featureDescription": "Invia e ricevi messaggi SMS con i tuoi clienti direttamente da Torqvoice."
+    "featureDescription": "Invia e ricevi messaggi SMS con i tuoi clienti direttamente da Torqvoice.",
+    "tabs": {
+      "inbox": "In arrivo",
+      "scheduled": "Pianificati"
+    },
+    "smsNotConfiguredTitle": "L'SMS non è configurato",
+    "smsNotConfiguredHint": "Collega un provider SMS per inviare e ricevere messaggi qui. Le email pianificate funzionano comunque.",
+    "goToSmsSettings": "Vai alle impostazioni SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS non configurato",
     "title": "SMS non configurato",
@@ -20,7 +27,10 @@
     "deleted": "Conversazione eliminata",
     "deleted": "Conversazione eliminata",
     "deleteError": "Impossibile eliminare la conversazione",
     "deleteError": "Impossibile eliminare la conversazione",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Cerca conversazioni",
+    "noSearchResults": "Nessuna conversazione corrisponde alla ricerca",
+    "now": "ora"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Nessun numero di telefono registrato per {name}. Aggiungi un numero di telefono per iniziare a inviare messaggi.",
     "noPhone": "Nessun numero di telefono registrato per {name}. Aggiungi un numero di telefono per iniziare a inviare messaggi.",

+ 0 - 1
messages/it/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Clienti",
     "clients": "Clienti",
     "customers": "Clienti",
     "customers": "Clienti",
     "messages": "Messaggi",
     "messages": "Messaggi",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Officina",
     "workshop": "Officina",
     "vehicles": "Veicoli",
     "vehicles": "Veicoli",

+ 76 - 0
messages/it/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Pianifica un messaggio",
+    "editTitle": "Modifica messaggio pianificato",
+    "channelLabel": "Invia tramite",
+    "channels": {
+      "email": "Email",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Nota nell'app"
+    },
+    "repeatLabel": "Ripeti",
+    "frequencies": {
+      "once": "Una volta",
+      "daily": "Ogni giorno",
+      "weekly": "Ogni settimana",
+      "biweekly": "Ogni 2 settimane",
+      "monthly": "Ogni mese",
+      "yearly": "Ogni anno"
+    },
+    "customerLabel": "Cliente",
+    "selectCustomer": "Seleziona cliente",
+    "none": "Nessuno",
+    "recipientEmail": "Indirizzo email",
+    "recipientPhone": "Numero di telefono",
+    "recipientChat": "ID chat Telegram",
+    "recipientOverride": "Invia a un altro indirizzo",
+    "recipientHintCustomer": "Lascia vuoto per usare i dati salvati.",
+    "recipientHintDirect": "Scegli un cliente o scrivi la destinazione.",
+    "subjectLabel": "Oggetto",
+    "subjectPlaceholder": "es. La tua auto è pronta",
+    "messageLabel": "Messaggio",
+    "messagePlaceholder": "Cosa vuoi dire?",
+    "characters": "{count} / {max} caratteri",
+    "dateLabel": "Data",
+    "timeLabel": "Ora",
+    "endDateLabel": "Ripeti fino al",
+    "endDateHint": "Lascia vuoto per ripetere sempre.",
+    "schedule": "Pianifica",
+    "scheduled": "Messaggio pianificato",
+    "updated": "Messaggio pianificato aggiornato",
+    "saveError": "Impossibile salvare il messaggio"
+  },
+  "list": {
+    "scheduleMessage": "Pianifica messaggio",
+    "scheduleForCustomer": "Pianifica un messaggio",
+    "filters": {
+      "upcoming": "In arrivo",
+      "sent": "Inviati",
+      "failed": "Non riusciti",
+      "cancelled": "Annullati",
+      "all": "Tutti"
+    },
+    "status": {
+      "scheduled": "Pianificato",
+      "sent": "Inviato",
+      "failed": "Non riuscito",
+      "cancelled": "Annullato"
+    },
+    "empty": "Niente in programma",
+    "emptyHint": "Fai clic destro su un giorno del calendario, o usa Pianifica messaggio.",
+    "noRecipient": "Nessun destinatario",
+    "sentCount": "inviato {count}x",
+    "rowMenu": "Menu messaggio",
+    "sendNow": "Invia ora",
+    "cancel": "Annulla invio",
+    "sentNow": "Messaggio inviato",
+    "sendError": "Impossibile inviare il messaggio",
+    "cancelled": "Invio annullato",
+    "cancelError": "Impossibile annullare il messaggio",
+    "deleted": "Messaggio eliminato",
+    "deleteError": "Impossibile eliminare il messaggio",
+    "deleteTitle": "Eliminare questo messaggio?",
+    "deleteDescription": "Il messaggio pianificato viene rimosso definitivamente. Quanto già inviato resta nella conversazione."
+  }
+}

+ 2 - 0
messages/it/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Cronologia delle attività e delle modifiche",
     "optAuditLogsDesc": "Cronologia delle attività e delle modifiche",
     "optSmsMessages": "Messaggi SMS",
     "optSmsMessages": "Messaggi SMS",
     "optSmsMessagesDesc": "Messaggi SMS inviati e ricevuti",
     "optSmsMessagesDesc": "Messaggi SMS inviati e ricevuti",
+    "optScheduledMessages": "Messaggi pianificati",
+    "optScheduledMessagesDesc": "Messaggi in coda e ricorrenti in attesa di essere inviati",
     "optNotifications": "Notifiche",
     "optNotifications": "Notifiche",
     "optNotificationsDesc": "Notifiche di sistema",
     "optNotificationsDesc": "Notifiche di sistema",
     "optCustomFields": "Campi personalizzati",
     "optCustomFields": "Campi personalizzati",

+ 9 - 4
messages/lt/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Paslaugos",
     "services": "Paslaugos",
     "reminders": "Priminimai",
     "reminders": "Priminimai",
-    "quotes": "Pasiūlymai"
+    "quotes": "Pasiūlymai",
+    "messages": "Žinutės"
   },
   },
   "selectVehicle": "Pasirinkite transporto priemonę darbo užsakymui",
   "selectVehicle": "Pasirinkite transporto priemonę darbo užsakymui",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Naujas priminimas",
     "newReminder": "Naujas priminimas",
     "newQuote": "Naujas pasiūlymas",
     "newQuote": "Naujas pasiūlymas",
     "openEvent": "Atidaryti ({count})",
     "openEvent": "Atidaryti ({count})",
-    "hint": "Patarimas: spustelėkite dieną dešiniuoju pelės mygtuku, kad pamatytumėte veiksmus"
+    "hint": "Patarimas: spustelėkite dieną dešiniuoju pelės mygtuku, kad pamatytumėte veiksmus",
+    "scheduleMessage": "Suplanuoti žinutę"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Juodraštis",
       "draft": "Juodraštis",
       "done": "Atlikta",
       "done": "Atlikta",
       "overdue": "Vėluoja",
       "overdue": "Vėluoja",
-      "upcoming": "Artėja"
+      "upcoming": "Artėja",
+      "failed": "Nepavyko",
+      "queued": "Eilėje"
     },
     },
     "type": {
     "type": {
       "service": "Paslauga",
       "service": "Paslauga",
       "reminder": "Priminimas",
       "reminder": "Priminimas",
-      "quote": "Pasiūlymas"
+      "quote": "Pasiūlymas",
+      "message": "Žinutė"
     },
     },
     "noEvents": "Įvykių nėra",
     "noEvents": "Įvykių nėra",
     "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą."
     "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą."

+ 12 - 2
messages/lt/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS žinutės",
     "featureName": "SMS žinutės",
-    "featureDescription": "Siųskite ir gaukite SMS žinutes su klientais tiesiogiai iš Torqvoice."
+    "featureDescription": "Siųskite ir gaukite SMS žinutes su klientais tiesiogiai iš Torqvoice.",
+    "tabs": {
+      "inbox": "Gauta",
+      "scheduled": "Suplanuota"
+    },
+    "smsNotConfiguredTitle": "SMS nesukonfigūruotas",
+    "smsNotConfiguredHint": "Prijunkite SMS tiekėją, kad galėtumėte siųsti ir gauti žinutes čia. Suplanuoti el. laiškai veikia ir be to.",
+    "goToSmsSettings": "Eiti į SMS nustatymus"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS nesukonfigūruotas",
     "title": "SMS nesukonfigūruotas",
@@ -20,7 +27,10 @@
     "deleted": "Pokalbis ištrintas",
     "deleted": "Pokalbis ištrintas",
     "deleteError": "Nepavyko ištrinti pokalbio",
     "deleteError": "Nepavyko ištrinti pokalbio",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Ieškoti pokalbių",
+    "noSearchResults": "Nėra pokalbių pagal paiešką",
+    "now": "dabar"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "{name} neturi telefono numerio. Pridėkite telefono numerį, kad pradėtumėte susirašinėti.",
     "noPhone": "{name} neturi telefono numerio. Pridėkite telefono numerį, kad pradėtumėte susirašinėti.",

+ 0 - 1
messages/lt/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Klientai",
     "clients": "Klientai",
     "customers": "Klientai",
     "customers": "Klientai",
     "messages": "Žinutės",
     "messages": "Žinutės",
-    "smsMessages": "SMS žinutės",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Dirbtuvės",
     "workshop": "Dirbtuvės",
     "vehicles": "Transporto priemonės",
     "vehicles": "Transporto priemonės",

+ 76 - 0
messages/lt/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Suplanuoti žinutę",
+    "editTitle": "Redaguoti suplanuotą žinutę",
+    "channelLabel": "Siųsti per",
+    "channels": {
+      "email": "El. paštas",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Pastaba programoje"
+    },
+    "repeatLabel": "Kartoti",
+    "frequencies": {
+      "once": "Vieną kartą",
+      "daily": "Kasdien",
+      "weekly": "Kas savaitę",
+      "biweekly": "Kas 2 savaites",
+      "monthly": "Kas mėnesį",
+      "yearly": "Kasmet"
+    },
+    "customerLabel": "Klientas",
+    "selectCustomer": "Pasirinkite klientą",
+    "none": "Nėra",
+    "recipientEmail": "El. pašto adresas",
+    "recipientPhone": "Telefono numeris",
+    "recipientChat": "Telegram pokalbio ID",
+    "recipientOverride": "Siųsti kitu adresu",
+    "recipientHintCustomer": "Palikite tuščią, kad būtų naudojami išsaugoti duomenys.",
+    "recipientHintDirect": "Pasirinkite klientą arba įrašykite gavėją.",
+    "subjectLabel": "Tema",
+    "subjectPlaceholder": "pvz. Jūsų automobilis paruoštas",
+    "messageLabel": "Žinutė",
+    "messagePlaceholder": "Ką norite pasakyti?",
+    "characters": "{count} / {max} simbolių",
+    "dateLabel": "Data",
+    "timeLabel": "Laikas",
+    "endDateLabel": "Kartoti iki",
+    "endDateHint": "Palikite tuščią, kad kartotųsi be pabaigos.",
+    "schedule": "Suplanuoti",
+    "scheduled": "Žinutė suplanuota",
+    "updated": "Suplanuota žinutė atnaujinta",
+    "saveError": "Nepavyko išsaugoti žinutės"
+  },
+  "list": {
+    "scheduleMessage": "Suplanuoti žinutę",
+    "scheduleForCustomer": "Suplanuoti žinutę",
+    "filters": {
+      "upcoming": "Būsimos",
+      "sent": "Išsiųstos",
+      "failed": "Nepavykusios",
+      "cancelled": "Atšauktos",
+      "all": "Visos"
+    },
+    "status": {
+      "scheduled": "Suplanuota",
+      "sent": "Išsiųsta",
+      "failed": "Nepavyko",
+      "cancelled": "Atšaukta"
+    },
+    "empty": "Nieko nesuplanuota",
+    "emptyHint": "Spustelėkite dieną kalendoriuje dešiniuoju mygtuku arba pasirinkite Suplanuoti žinutę.",
+    "noRecipient": "Nėra gavėjo",
+    "sentCount": "išsiųsta {count}x",
+    "rowMenu": "Žinutės meniu",
+    "sendNow": "Siųsti dabar",
+    "cancel": "Atšaukti siuntimą",
+    "sentNow": "Žinutė išsiųsta",
+    "sendError": "Nepavyko išsiųsti žinutės",
+    "cancelled": "Siuntimas atšauktas",
+    "cancelError": "Nepavyko atšaukti žinutės",
+    "deleted": "Žinutė ištrinta",
+    "deleteError": "Nepavyko ištrinti žinutės",
+    "deleteTitle": "Ištrinti šią žinutę?",
+    "deleteDescription": "Suplanuota žinutė pašalinama visam laikui. Kas jau išsiųsta, lieka pokalbyje."
+  }
+}

+ 2 - 0
messages/lt/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Veiklos ir pakeitimų istorija",
     "optAuditLogsDesc": "Veiklos ir pakeitimų istorija",
     "optSmsMessages": "SMS žinutės",
     "optSmsMessages": "SMS žinutės",
     "optSmsMessagesDesc": "Išsiųstos ir gautos SMS žinutės",
     "optSmsMessagesDesc": "Išsiųstos ir gautos SMS žinutės",
+    "optScheduledMessages": "Suplanuotos žinutės",
+    "optScheduledMessagesDesc": "Eilėje esančios ir pasikartojančios žinutės, laukiančios išsiuntimo",
     "optNotifications": "Pranešimai",
     "optNotifications": "Pranešimai",
     "optNotificationsDesc": "Sistemos pranešimai",
     "optNotificationsDesc": "Sistemos pranešimai",
     "optCustomFields": "Pasirinktiniai laukai",
     "optCustomFields": "Pasirinktiniai laukai",

+ 9 - 4
messages/nb/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Tjenester",
     "services": "Tjenester",
     "reminders": "Påminnelser",
     "reminders": "Påminnelser",
-    "quotes": "Tilbud"
+    "quotes": "Tilbud",
+    "messages": "Meldinger"
   },
   },
   "selectVehicle": "Velg kjøretøy for arbeidsordre",
   "selectVehicle": "Velg kjøretøy for arbeidsordre",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Ny påminnelse",
     "newReminder": "Ny påminnelse",
     "newQuote": "Nytt tilbud",
     "newQuote": "Nytt tilbud",
     "openEvent": "Åpne ({count})",
     "openEvent": "Åpne ({count})",
-    "hint": "Tips: høyreklikk en dag for handlinger"
+    "hint": "Tips: høyreklikk en dag for handlinger",
+    "scheduleMessage": "Planlegg melding"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Utkast",
       "draft": "Utkast",
       "done": "Ferdig",
       "done": "Ferdig",
       "overdue": "Forfalt",
       "overdue": "Forfalt",
-      "upcoming": "Kommende"
+      "upcoming": "Kommende",
+      "failed": "Mislyktes",
+      "queued": "I kø"
     },
     },
     "type": {
     "type": {
       "service": "Tjeneste",
       "service": "Tjeneste",
       "reminder": "Påminnelse",
       "reminder": "Påminnelse",
-      "quote": "Tilbud"
+      "quote": "Tilbud",
+      "message": "Melding"
     },
     },
     "noEvents": "Ingen hendelser",
     "noEvents": "Ingen hendelser",
     "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre."
     "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre."

+ 12 - 2
messages/nb/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS-meldinger",
     "featureName": "SMS-meldinger",
-    "featureDescription": "Send og motta SMS-meldinger med kundene dine direkte fra Torqvoice."
+    "featureDescription": "Send og motta SMS-meldinger med kundene dine direkte fra Torqvoice.",
+    "tabs": {
+      "inbox": "Innboks",
+      "scheduled": "Planlagt"
+    },
+    "smsNotConfiguredTitle": "SMS er ikke satt opp",
+    "smsNotConfiguredHint": "Koble til en SMS-leverandør for å sende og motta meldinger her. Planlagt e-post fungerer uansett.",
+    "goToSmsSettings": "Gå til SMS-innstillinger"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS ikke konfigurert",
     "title": "SMS ikke konfigurert",
@@ -20,7 +27,10 @@
     "deleted": "Samtale slettet",
     "deleted": "Samtale slettet",
     "deleteError": "Kunne ikke slette samtalen",
     "deleteError": "Kunne ikke slette samtalen",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Søk i samtaler",
+    "noSearchResults": "Ingen samtaler treffer søket",
+    "now": "nå"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Ingen telefonnummer registrert for {name}. Legg til et telefonnummer for å starte meldinger.",
     "noPhone": "Ingen telefonnummer registrert for {name}. Legg til et telefonnummer for å starte meldinger.",

+ 0 - 1
messages/nb/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Klienter",
     "clients": "Klienter",
     "customers": "Kunder",
     "customers": "Kunder",
     "messages": "Meldinger",
     "messages": "Meldinger",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Verksted",
     "workshop": "Verksted",
     "vehicles": "Kjøretøy",
     "vehicles": "Kjøretøy",

+ 76 - 0
messages/nb/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Planlegg en melding",
+    "editTitle": "Rediger planlagt melding",
+    "channelLabel": "Send med",
+    "channels": {
+      "email": "E-post",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Notat i appen"
+    },
+    "repeatLabel": "Gjenta",
+    "frequencies": {
+      "once": "Én gang",
+      "daily": "Daglig",
+      "weekly": "Ukentlig",
+      "biweekly": "Annenhver uke",
+      "monthly": "Månedlig",
+      "yearly": "Årlig"
+    },
+    "customerLabel": "Kunde",
+    "selectCustomer": "Velg kunde",
+    "none": "Ingen",
+    "recipientEmail": "E-postadresse",
+    "recipientPhone": "Telefonnummer",
+    "recipientChat": "Telegram chat-id",
+    "recipientOverride": "Send til en annen adresse",
+    "recipientHintCustomer": "La stå tom for å bruke det som er registrert.",
+    "recipientHintDirect": "Velg en kunde, eller skriv hvor den skal.",
+    "subjectLabel": "Emne",
+    "subjectPlaceholder": "f.eks. Bilen din er klar",
+    "messageLabel": "Melding",
+    "messagePlaceholder": "Hva skal vi si?",
+    "characters": "{count} / {max} tegn",
+    "dateLabel": "Dato",
+    "timeLabel": "Tid",
+    "endDateLabel": "Gjenta til",
+    "endDateHint": "La stå tom for å fortsette.",
+    "schedule": "Planlegg",
+    "scheduled": "Melding planlagt",
+    "updated": "Planlagt melding oppdatert",
+    "saveError": "Kunne ikke lagre meldingen"
+  },
+  "list": {
+    "scheduleMessage": "Planlegg melding",
+    "scheduleForCustomer": "Planlegg en melding",
+    "filters": {
+      "upcoming": "Kommende",
+      "sent": "Sendt",
+      "failed": "Mislyktes",
+      "cancelled": "Avbrutt",
+      "all": "Alle"
+    },
+    "status": {
+      "scheduled": "Planlagt",
+      "sent": "Sendt",
+      "failed": "Mislyktes",
+      "cancelled": "Avbrutt"
+    },
+    "empty": "Ingenting planlagt",
+    "emptyHint": "Høyreklikk en dag i kalenderen, eller bruk Planlegg melding.",
+    "noRecipient": "Ingen mottaker",
+    "sentCount": "sendt {count}x",
+    "rowMenu": "Meldingsmeny",
+    "sendNow": "Send nå",
+    "cancel": "Avbryt sending",
+    "sentNow": "Melding sendt",
+    "sendError": "Kunne ikke sende meldingen",
+    "cancelled": "Sending avbrutt",
+    "cancelError": "Kunne ikke avbryte meldingen",
+    "deleted": "Melding slettet",
+    "deleteError": "Kunne ikke slette meldingen",
+    "deleteTitle": "Slette denne meldingen?",
+    "deleteDescription": "Den planlagte meldingen fjernes for godt. Det som allerede er sendt blir liggende i samtalen."
+  }
+}

+ 2 - 0
messages/nb/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Aktivitets- og endringshistorikk",
     "optAuditLogsDesc": "Aktivitets- og endringshistorikk",
     "optSmsMessages": "SMS-meldinger",
     "optSmsMessages": "SMS-meldinger",
     "optSmsMessagesDesc": "Sendte og mottatte SMS-meldinger",
     "optSmsMessagesDesc": "Sendte og mottatte SMS-meldinger",
+    "optScheduledMessages": "Planlagte meldinger",
+    "optScheduledMessagesDesc": "Meldinger i kø og gjentakende meldinger som venter på å bli sendt",
     "optNotifications": "Varsler",
     "optNotifications": "Varsler",
     "optNotificationsDesc": "Systemvarsler",
     "optNotificationsDesc": "Systemvarsler",
     "optCustomFields": "Egendefinerte felter",
     "optCustomFields": "Egendefinerte felter",

+ 9 - 4
messages/nl/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Diensten",
     "services": "Diensten",
     "reminders": "Herinneringen",
     "reminders": "Herinneringen",
-    "quotes": "Offertes"
+    "quotes": "Offertes",
+    "messages": "Berichten"
   },
   },
   "selectVehicle": "Selecteer voertuig voor werkorder",
   "selectVehicle": "Selecteer voertuig voor werkorder",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Nieuwe herinnering",
     "newReminder": "Nieuwe herinnering",
     "newQuote": "Nieuwe offerte",
     "newQuote": "Nieuwe offerte",
     "openEvent": "Openen ({count})",
     "openEvent": "Openen ({count})",
-    "hint": "Tip: klik met de rechtermuisknop op een dag voor acties"
+    "hint": "Tip: klik met de rechtermuisknop op een dag voor acties",
+    "scheduleMessage": "Bericht plannen"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Concept",
       "draft": "Concept",
       "done": "Klaar",
       "done": "Klaar",
       "overdue": "Achterstallig",
       "overdue": "Achterstallig",
-      "upcoming": "Aanstaand"
+      "upcoming": "Aanstaand",
+      "failed": "Mislukt",
+      "queued": "In wachtrij"
     },
     },
     "type": {
     "type": {
       "service": "Dienst",
       "service": "Dienst",
       "reminder": "Herinnering",
       "reminder": "Herinnering",
-      "quote": "Offerte"
+      "quote": "Offerte",
+      "message": "Bericht"
     },
     },
     "noEvents": "Geen evenementen",
     "noEvents": "Geen evenementen",
     "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan."
     "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan."

+ 12 - 2
messages/nl/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS-berichten",
     "featureName": "SMS-berichten",
-    "featureDescription": "Verzend en ontvang SMS-berichten met uw klanten rechtstreeks vanuit Torqvoice."
+    "featureDescription": "Verzend en ontvang SMS-berichten met uw klanten rechtstreeks vanuit Torqvoice.",
+    "tabs": {
+      "inbox": "Postvak in",
+      "scheduled": "Gepland"
+    },
+    "smsNotConfiguredTitle": "Sms is niet ingesteld",
+    "smsNotConfiguredHint": "Koppel een sms-provider om hier berichten te versturen en ontvangen. Geplande e-mail werkt ook zonder.",
+    "goToSmsSettings": "Naar sms-instellingen"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS niet geconfigureerd",
     "title": "SMS niet geconfigureerd",
@@ -20,7 +27,10 @@
     "deleted": "Gesprek verwijderd",
     "deleted": "Gesprek verwijderd",
     "deleteError": "Gesprek kon niet worden verwijderd",
     "deleteError": "Gesprek kon niet worden verwijderd",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Gesprekken zoeken",
+    "noSearchResults": "Geen gesprekken gevonden",
+    "now": "nu"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Geen telefoonnummer geregistreerd voor {name}. Voeg een telefoonnummer toe om berichten te sturen.",
     "noPhone": "Geen telefoonnummer geregistreerd voor {name}. Voeg een telefoonnummer toe om berichten te sturen.",

+ 0 - 1
messages/nl/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Klanten",
     "clients": "Klanten",
     "customers": "Klanten",
     "customers": "Klanten",
     "messages": "Berichten",
     "messages": "Berichten",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Werkplaats",
     "workshop": "Werkplaats",
     "vehicles": "Voertuigen",
     "vehicles": "Voertuigen",

+ 76 - 0
messages/nl/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Bericht plannen",
+    "editTitle": "Gepland bericht bewerken",
+    "channelLabel": "Versturen via",
+    "channels": {
+      "email": "E-mail",
+      "sms": "Sms",
+      "telegram": "Telegram",
+      "in_app": "Notitie in de app"
+    },
+    "repeatLabel": "Herhalen",
+    "frequencies": {
+      "once": "Eenmalig",
+      "daily": "Dagelijks",
+      "weekly": "Wekelijks",
+      "biweekly": "Elke 2 weken",
+      "monthly": "Maandelijks",
+      "yearly": "Jaarlijks"
+    },
+    "customerLabel": "Klant",
+    "selectCustomer": "Klant selecteren",
+    "none": "Geen",
+    "recipientEmail": "E-mailadres",
+    "recipientPhone": "Telefoonnummer",
+    "recipientChat": "Telegram chat-id",
+    "recipientOverride": "Naar een ander adres sturen",
+    "recipientHintCustomer": "Leeg laten om de opgeslagen gegevens te gebruiken.",
+    "recipientHintDirect": "Kies een klant of typ waar dit heen gaat.",
+    "subjectLabel": "Onderwerp",
+    "subjectPlaceholder": "bijv. Uw auto staat klaar",
+    "messageLabel": "Bericht",
+    "messagePlaceholder": "Wat wilt u zeggen?",
+    "characters": "{count} / {max} tekens",
+    "dateLabel": "Datum",
+    "timeLabel": "Tijd",
+    "endDateLabel": "Herhalen tot",
+    "endDateHint": "Leeg laten om te blijven herhalen.",
+    "schedule": "Plannen",
+    "scheduled": "Bericht gepland",
+    "updated": "Gepland bericht bijgewerkt",
+    "saveError": "Kan het bericht niet opslaan"
+  },
+  "list": {
+    "scheduleMessage": "Bericht plannen",
+    "scheduleForCustomer": "Bericht plannen",
+    "filters": {
+      "upcoming": "Komend",
+      "sent": "Verzonden",
+      "failed": "Mislukt",
+      "cancelled": "Geannuleerd",
+      "all": "Alle"
+    },
+    "status": {
+      "scheduled": "Gepland",
+      "sent": "Verzonden",
+      "failed": "Mislukt",
+      "cancelled": "Geannuleerd"
+    },
+    "empty": "Niets gepland",
+    "emptyHint": "Klik met de rechtermuisknop op een dag in de agenda, of gebruik Bericht plannen.",
+    "noRecipient": "Geen ontvanger",
+    "sentCount": "{count}x verzonden",
+    "rowMenu": "Berichtmenu",
+    "sendNow": "Nu versturen",
+    "cancel": "Verzenden annuleren",
+    "sentNow": "Bericht verzonden",
+    "sendError": "Kan het bericht niet versturen",
+    "cancelled": "Verzenden geannuleerd",
+    "cancelError": "Kan het bericht niet annuleren",
+    "deleted": "Bericht verwijderd",
+    "deleteError": "Kan het bericht niet verwijderen",
+    "deleteTitle": "Dit bericht verwijderen?",
+    "deleteDescription": "Het geplande bericht wordt definitief verwijderd. Wat al verzonden is blijft in het gesprek staan."
+  }
+}

+ 2 - 0
messages/nl/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Activiteiten- en wijzigingsgeschiedenis",
     "optAuditLogsDesc": "Activiteiten- en wijzigingsgeschiedenis",
     "optSmsMessages": "SMS-berichten",
     "optSmsMessages": "SMS-berichten",
     "optSmsMessagesDesc": "Verzonden en ontvangen SMS-berichten",
     "optSmsMessagesDesc": "Verzonden en ontvangen SMS-berichten",
+    "optScheduledMessages": "Geplande berichten",
+    "optScheduledMessagesDesc": "Berichten in de wachtrij en herhalende berichten die nog verstuurd worden",
     "optNotifications": "Meldingen",
     "optNotifications": "Meldingen",
     "optNotificationsDesc": "Systeemmeldingen",
     "optNotificationsDesc": "Systeemmeldingen",
     "optCustomFields": "Aangepaste velden",
     "optCustomFields": "Aangepaste velden",

+ 9 - 4
messages/pl/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Usługi",
     "services": "Usługi",
     "reminders": "Przypomnienia",
     "reminders": "Przypomnienia",
-    "quotes": "Wyceny"
+    "quotes": "Wyceny",
+    "messages": "Wiadomości"
   },
   },
   "selectVehicle": "Wybierz pojazd do zlecenia",
   "selectVehicle": "Wybierz pojazd do zlecenia",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Nowe przypomnienie",
     "newReminder": "Nowe przypomnienie",
     "newQuote": "Nowa wycena",
     "newQuote": "Nowa wycena",
     "openEvent": "Otwórz ({count})",
     "openEvent": "Otwórz ({count})",
-    "hint": "Wskazówka: kliknij dzień prawym przyciskiem myszy, aby zobaczyć akcje"
+    "hint": "Wskazówka: kliknij dzień prawym przyciskiem myszy, aby zobaczyć akcje",
+    "scheduleMessage": "Zaplanuj wiadomość"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Szkic",
       "draft": "Szkic",
       "done": "Gotowe",
       "done": "Gotowe",
       "overdue": "Zaległe",
       "overdue": "Zaległe",
-      "upcoming": "Nadchodzące"
+      "upcoming": "Nadchodzące",
+      "failed": "Nieudane",
+      "queued": "W kolejce"
     },
     },
     "type": {
     "type": {
       "service": "Usługa",
       "service": "Usługa",
       "reminder": "Przypomnienie",
       "reminder": "Przypomnienie",
-      "quote": "Wycena"
+      "quote": "Wycena",
+      "message": "Wiadomość"
     },
     },
     "noEvents": "Brak wydarzeń",
     "noEvents": "Brak wydarzeń",
     "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie."
     "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie."

+ 12 - 2
messages/pl/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "Wiadomości SMS",
     "featureName": "Wiadomości SMS",
-    "featureDescription": "Wysyłaj i odbieraj wiadomości SMS z klientami bezpośrednio z Torqvoice."
+    "featureDescription": "Wysyłaj i odbieraj wiadomości SMS z klientami bezpośrednio z Torqvoice.",
+    "tabs": {
+      "inbox": "Odebrane",
+      "scheduled": "Zaplanowane"
+    },
+    "smsNotConfiguredTitle": "SMS nie jest skonfigurowany",
+    "smsNotConfiguredHint": "Podłącz dostawcę SMS, aby wysyłać i odbierać wiadomości tutaj. Zaplanowana poczta działa bez tego.",
+    "goToSmsSettings": "Przejdź do ustawień SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS nie skonfigurowany",
     "title": "SMS nie skonfigurowany",
@@ -20,7 +27,10 @@
     "deleted": "Rozmowa usunięta",
     "deleted": "Rozmowa usunięta",
     "deleteError": "Nie udało się usunąć rozmowy",
     "deleteError": "Nie udało się usunąć rozmowy",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Szukaj rozmów",
+    "noSearchResults": "Brak rozmów pasujących do wyszukiwania",
+    "now": "teraz"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Brak numeru telefonu dla {name}. Dodaj numer telefonu, aby rozpocząć wysyłanie wiadomości.",
     "noPhone": "Brak numeru telefonu dla {name}. Dodaj numer telefonu, aby rozpocząć wysyłanie wiadomości.",

+ 0 - 1
messages/pl/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Klienci",
     "clients": "Klienci",
     "customers": "Klienci",
     "customers": "Klienci",
     "messages": "Wiadomości",
     "messages": "Wiadomości",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Warsztat",
     "workshop": "Warsztat",
     "vehicles": "Pojazdy",
     "vehicles": "Pojazdy",

+ 76 - 0
messages/pl/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Zaplanuj wiadomość",
+    "editTitle": "Edytuj zaplanowaną wiadomość",
+    "channelLabel": "Wyślij przez",
+    "channels": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Notatka w aplikacji"
+    },
+    "repeatLabel": "Powtarzaj",
+    "frequencies": {
+      "once": "Raz",
+      "daily": "Codziennie",
+      "weekly": "Co tydzień",
+      "biweekly": "Co 2 tygodnie",
+      "monthly": "Co miesiąc",
+      "yearly": "Co rok"
+    },
+    "customerLabel": "Klient",
+    "selectCustomer": "Wybierz klienta",
+    "none": "Brak",
+    "recipientEmail": "Adres e-mail",
+    "recipientPhone": "Numer telefonu",
+    "recipientChat": "Identyfikator czatu Telegram",
+    "recipientOverride": "Wyślij na inny adres",
+    "recipientHintCustomer": "Zostaw puste, aby użyć zapisanych danych.",
+    "recipientHintDirect": "Wybierz klienta lub wpisz odbiorcę.",
+    "subjectLabel": "Temat",
+    "subjectPlaceholder": "np. Samochód jest gotowy",
+    "messageLabel": "Wiadomość",
+    "messagePlaceholder": "Co chcesz napisać?",
+    "characters": "{count} / {max} znaków",
+    "dateLabel": "Data",
+    "timeLabel": "Godzina",
+    "endDateLabel": "Powtarzaj do",
+    "endDateHint": "Zostaw puste, aby powtarzać bez końca.",
+    "schedule": "Zaplanuj",
+    "scheduled": "Wiadomość zaplanowana",
+    "updated": "Zaplanowana wiadomość zaktualizowana",
+    "saveError": "Nie udało się zapisać wiadomości"
+  },
+  "list": {
+    "scheduleMessage": "Zaplanuj wiadomość",
+    "scheduleForCustomer": "Zaplanuj wiadomość",
+    "filters": {
+      "upcoming": "Nadchodzące",
+      "sent": "Wysłane",
+      "failed": "Nieudane",
+      "cancelled": "Anulowane",
+      "all": "Wszystkie"
+    },
+    "status": {
+      "scheduled": "Zaplanowana",
+      "sent": "Wysłana",
+      "failed": "Nieudana",
+      "cancelled": "Anulowana"
+    },
+    "empty": "Nic nie zaplanowano",
+    "emptyHint": "Kliknij dzień w kalendarzu prawym przyciskiem myszy lub użyj Zaplanuj wiadomość.",
+    "noRecipient": "Brak odbiorcy",
+    "sentCount": "wysłano {count}x",
+    "rowMenu": "Menu wiadomości",
+    "sendNow": "Wyślij teraz",
+    "cancel": "Anuluj wysyłkę",
+    "sentNow": "Wiadomość wysłana",
+    "sendError": "Nie udało się wysłać wiadomości",
+    "cancelled": "Wysyłka anulowana",
+    "cancelError": "Nie udało się anulować wiadomości",
+    "deleted": "Wiadomość usunięta",
+    "deleteError": "Nie udało się usunąć wiadomości",
+    "deleteTitle": "Usunąć tę wiadomość?",
+    "deleteDescription": "Zaplanowana wiadomość zostanie trwale usunięta. To, co już wysłano, zostaje w rozmowie."
+  }
+}

+ 2 - 0
messages/pl/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Historia aktywności i zmian",
     "optAuditLogsDesc": "Historia aktywności i zmian",
     "optSmsMessages": "Wiadomości SMS",
     "optSmsMessages": "Wiadomości SMS",
     "optSmsMessagesDesc": "Wysłane i odebrane wiadomości SMS",
     "optSmsMessagesDesc": "Wysłane i odebrane wiadomości SMS",
+    "optScheduledMessages": "Zaplanowane wiadomości",
+    "optScheduledMessagesDesc": "Wiadomości w kolejce i cykliczne, czekające na wysyłkę",
     "optNotifications": "Powiadomienia",
     "optNotifications": "Powiadomienia",
     "optNotificationsDesc": "Powiadomienia systemowe",
     "optNotificationsDesc": "Powiadomienia systemowe",
     "optCustomFields": "Pola niestandardowe",
     "optCustomFields": "Pola niestandardowe",

+ 9 - 4
messages/pt-BR/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Serviços",
     "services": "Serviços",
     "reminders": "Lembretes",
     "reminders": "Lembretes",
-    "quotes": "Orçamentos"
+    "quotes": "Orçamentos",
+    "messages": "Mensagens"
   },
   },
   "selectVehicle": "Selecionar veículo para ordem de serviço",
   "selectVehicle": "Selecionar veículo para ordem de serviço",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Novo lembrete",
     "newReminder": "Novo lembrete",
     "newQuote": "Novo orçamento",
     "newQuote": "Novo orçamento",
     "openEvent": "Abrir ({count})",
     "openEvent": "Abrir ({count})",
-    "hint": "Dica: clique com o botão direito em um dia para ver as ações"
+    "hint": "Dica: clique com o botão direito em um dia para ver as ações",
+    "scheduleMessage": "Agendar mensagem"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Rascunho",
       "draft": "Rascunho",
       "done": "Feito",
       "done": "Feito",
       "overdue": "Atrasado",
       "overdue": "Atrasado",
-      "upcoming": "Próximo"
+      "upcoming": "Próximo",
+      "failed": "Falhou",
+      "queued": "Na fila"
     },
     },
     "type": {
     "type": {
       "service": "Serviço",
       "service": "Serviço",
       "reminder": "Lembrete",
       "reminder": "Lembrete",
-      "quote": "Orçamento"
+      "quote": "Orçamento",
+      "message": "Mensagem"
     },
     },
     "noEvents": "Sem eventos",
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço."
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço."

+ 12 - 2
messages/pt-BR/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "Mensagens SMS",
     "featureName": "Mensagens SMS",
-    "featureDescription": "Envie e receba mensagens SMS com seus clientes diretamente pelo Torqvoice."
+    "featureDescription": "Envie e receba mensagens SMS com seus clientes diretamente pelo Torqvoice.",
+    "tabs": {
+      "inbox": "Caixa de entrada",
+      "scheduled": "Agendadas"
+    },
+    "smsNotConfiguredTitle": "O SMS não está configurado",
+    "smsNotConfiguredHint": "Conecte um provedor de SMS para enviar e receber mensagens aqui. E-mails agendados funcionam sem isso.",
+    "goToSmsSettings": "Ir para configurações de SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS não configurado",
     "title": "SMS não configurado",
@@ -20,7 +27,10 @@
     "deleted": "Conversa excluída",
     "deleted": "Conversa excluída",
     "deleteError": "Falha ao excluir a conversa",
     "deleteError": "Falha ao excluir a conversa",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Buscar conversas",
+    "noSearchResults": "Nenhuma conversa corresponde à busca",
+    "now": "agora"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "Nenhum número de telefone registrado para {name}. Adicione um número de telefone para começar a enviar mensagens.",
     "noPhone": "Nenhum número de telefone registrado para {name}. Adicione um número de telefone para começar a enviar mensagens.",

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

@@ -77,7 +77,6 @@
     "clients": "Clientes",
     "clients": "Clientes",
     "customers": "Clientes",
     "customers": "Clientes",
     "messages": "Mensagens",
     "messages": "Mensagens",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Oficina",
     "workshop": "Oficina",
     "vehicles": "Veículos",
     "vehicles": "Veículos",

+ 76 - 0
messages/pt-BR/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Agendar uma mensagem",
+    "editTitle": "Editar mensagem agendada",
+    "channelLabel": "Enviar por",
+    "channels": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Nota no app"
+    },
+    "repeatLabel": "Repetir",
+    "frequencies": {
+      "once": "Uma vez",
+      "daily": "Diariamente",
+      "weekly": "Semanalmente",
+      "biweekly": "A cada 2 semanas",
+      "monthly": "Mensalmente",
+      "yearly": "Anualmente"
+    },
+    "customerLabel": "Cliente",
+    "selectCustomer": "Selecionar cliente",
+    "none": "Nenhum",
+    "recipientEmail": "Endereço de e-mail",
+    "recipientPhone": "Número de telefone",
+    "recipientChat": "ID do chat do Telegram",
+    "recipientOverride": "Enviar para outro endereço",
+    "recipientHintCustomer": "Deixe vazio para usar os dados cadastrados.",
+    "recipientHintDirect": "Escolha um cliente ou digite o destino.",
+    "subjectLabel": "Assunto",
+    "subjectPlaceholder": "ex. Seu carro está pronto",
+    "messageLabel": "Mensagem",
+    "messagePlaceholder": "O que você quer dizer?",
+    "characters": "{count} / {max} caracteres",
+    "dateLabel": "Data",
+    "timeLabel": "Hora",
+    "endDateLabel": "Repetir até",
+    "endDateHint": "Deixe vazio para repetir sempre.",
+    "schedule": "Agendar",
+    "scheduled": "Mensagem agendada",
+    "updated": "Mensagem agendada atualizada",
+    "saveError": "Não foi possível salvar a mensagem"
+  },
+  "list": {
+    "scheduleMessage": "Agendar mensagem",
+    "scheduleForCustomer": "Agendar uma mensagem",
+    "filters": {
+      "upcoming": "Próximas",
+      "sent": "Enviadas",
+      "failed": "Falhas",
+      "cancelled": "Canceladas",
+      "all": "Todas"
+    },
+    "status": {
+      "scheduled": "Agendada",
+      "sent": "Enviada",
+      "failed": "Falhou",
+      "cancelled": "Cancelada"
+    },
+    "empty": "Nada agendado",
+    "emptyHint": "Clique com o botão direito em um dia do calendário ou use Agendar mensagem.",
+    "noRecipient": "Sem destinatário",
+    "sentCount": "enviada {count}x",
+    "rowMenu": "Menu da mensagem",
+    "sendNow": "Enviar agora",
+    "cancel": "Cancelar envio",
+    "sentNow": "Mensagem enviada",
+    "sendError": "Não foi possível enviar a mensagem",
+    "cancelled": "Envio cancelado",
+    "cancelError": "Não foi possível cancelar a mensagem",
+    "deleted": "Mensagem excluída",
+    "deleteError": "Não foi possível excluir a mensagem",
+    "deleteTitle": "Excluir esta mensagem?",
+    "deleteDescription": "A mensagem agendada é removida definitivamente. O que já foi enviado permanece na conversa."
+  }
+}

+ 2 - 0
messages/pt-BR/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Histórico de atividades e alterações",
     "optAuditLogsDesc": "Histórico de atividades e alterações",
     "optSmsMessages": "Mensagens SMS",
     "optSmsMessages": "Mensagens SMS",
     "optSmsMessagesDesc": "Mensagens SMS enviadas e recebidas",
     "optSmsMessagesDesc": "Mensagens SMS enviadas e recebidas",
+    "optScheduledMessages": "Mensagens agendadas",
+    "optScheduledMessagesDesc": "Mensagens na fila e recorrentes aguardando envio",
     "optNotifications": "Notificações",
     "optNotifications": "Notificações",
     "optNotificationsDesc": "Notificações do sistema",
     "optNotificationsDesc": "Notificações do sistema",
     "optCustomFields": "Campos personalizados",
     "optCustomFields": "Campos personalizados",

+ 9 - 4
messages/ru/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Обслуживания",
     "services": "Обслуживания",
     "reminders": "Напоминания",
     "reminders": "Напоминания",
-    "quotes": "Предложения"
+    "quotes": "Предложения",
+    "messages": "Сообщения"
   },
   },
   "selectVehicle": "Выберите транспортное средство для заказ-наряда",
   "selectVehicle": "Выберите транспортное средство для заказ-наряда",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Новое напоминание",
     "newReminder": "Новое напоминание",
     "newQuote": "Новое предложение",
     "newQuote": "Новое предложение",
     "openEvent": "Открыть ({count})",
     "openEvent": "Открыть ({count})",
-    "hint": "Совет: щёлкните по дню правой кнопкой мыши, чтобы увидеть действия"
+    "hint": "Совет: щёлкните по дню правой кнопкой мыши, чтобы увидеть действия",
+    "scheduleMessage": "Запланировать сообщение"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Черновик",
       "draft": "Черновик",
       "done": "Готово",
       "done": "Готово",
       "overdue": "Просрочено",
       "overdue": "Просрочено",
-      "upcoming": "Предстоящее"
+      "upcoming": "Предстоящее",
+      "failed": "Ошибка",
+      "queued": "В очереди"
     },
     },
     "type": {
     "type": {
       "service": "Обслуживание",
       "service": "Обслуживание",
       "reminder": "Напоминание",
       "reminder": "Напоминание",
-      "quote": "Предложение"
+      "quote": "Предложение",
+      "message": "Сообщение"
     },
     },
     "noEvents": "Нет событий",
     "noEvents": "Нет событий",
     "selectDay": "Выберите день с событиями или создайте новый заказ-наряд."
     "selectDay": "Выберите день с событиями или создайте новый заказ-наряд."

+ 12 - 2
messages/ru/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS-сообщения",
     "featureName": "SMS-сообщения",
-    "featureDescription": "Отправляйте и получайте SMS-сообщения с клиентами прямо из Torqvoice."
+    "featureDescription": "Отправляйте и получайте SMS-сообщения с клиентами прямо из Torqvoice.",
+    "tabs": {
+      "inbox": "Входящие",
+      "scheduled": "Запланированные"
+    },
+    "smsNotConfiguredTitle": "SMS не настроен",
+    "smsNotConfiguredHint": "Подключите SMS-провайдера, чтобы отправлять и получать сообщения здесь. Запланированная почта работает и без этого.",
+    "goToSmsSettings": "Перейти к настройкам SMS"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS не настроены",
     "title": "SMS не настроены",
@@ -20,7 +27,10 @@
     "deleted": "Беседа удалена",
     "deleted": "Беседа удалена",
     "deleteError": "Не удалось удалить беседу",
     "deleteError": "Не удалось удалить беседу",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Поиск по перепискам",
+    "noSearchResults": "Нет переписок по запросу",
+    "now": "сейчас"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "У {name} не указан номер телефона. Добавьте номер для начала переписки.",
     "noPhone": "У {name} не указан номер телефона. Добавьте номер для начала переписки.",

+ 0 - 1
messages/ru/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Клиенты",
     "clients": "Клиенты",
     "customers": "Клиенты",
     "customers": "Клиенты",
     "messages": "Сообщения",
     "messages": "Сообщения",
-    "smsMessages": "SMS сообщения",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Мастерская",
     "workshop": "Мастерская",
     "vehicles": "Транспортные средства",
     "vehicles": "Транспортные средства",

+ 76 - 0
messages/ru/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Запланировать сообщение",
+    "editTitle": "Изменить запланированное сообщение",
+    "channelLabel": "Отправить через",
+    "channels": {
+      "email": "Эл. почта",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Заметка в приложении"
+    },
+    "repeatLabel": "Повтор",
+    "frequencies": {
+      "once": "Один раз",
+      "daily": "Ежедневно",
+      "weekly": "Еженедельно",
+      "biweekly": "Раз в 2 недели",
+      "monthly": "Ежемесячно",
+      "yearly": "Ежегодно"
+    },
+    "customerLabel": "Клиент",
+    "selectCustomer": "Выберите клиента",
+    "none": "Нет",
+    "recipientEmail": "Адрес эл. почты",
+    "recipientPhone": "Номер телефона",
+    "recipientChat": "ID чата Telegram",
+    "recipientOverride": "Отправить на другой адрес",
+    "recipientHintCustomer": "Оставьте пустым, чтобы использовать сохранённые данные.",
+    "recipientHintDirect": "Выберите клиента или укажите получателя.",
+    "subjectLabel": "Тема",
+    "subjectPlaceholder": "напр. Ваш автомобиль готов",
+    "messageLabel": "Сообщение",
+    "messagePlaceholder": "Что нужно сказать?",
+    "characters": "{count} / {max} символов",
+    "dateLabel": "Дата",
+    "timeLabel": "Время",
+    "endDateLabel": "Повторять до",
+    "endDateHint": "Оставьте пустым, чтобы повторять без конца.",
+    "schedule": "Запланировать",
+    "scheduled": "Сообщение запланировано",
+    "updated": "Запланированное сообщение обновлено",
+    "saveError": "Не удалось сохранить сообщение"
+  },
+  "list": {
+    "scheduleMessage": "Запланировать сообщение",
+    "scheduleForCustomer": "Запланировать сообщение",
+    "filters": {
+      "upcoming": "Предстоящие",
+      "sent": "Отправленные",
+      "failed": "С ошибкой",
+      "cancelled": "Отменённые",
+      "all": "Все"
+    },
+    "status": {
+      "scheduled": "Запланировано",
+      "sent": "Отправлено",
+      "failed": "Ошибка",
+      "cancelled": "Отменено"
+    },
+    "empty": "Ничего не запланировано",
+    "emptyHint": "Щёлкните день в календаре правой кнопкой мыши или нажмите Запланировать сообщение.",
+    "noRecipient": "Нет получателя",
+    "sentCount": "отправлено {count}x",
+    "rowMenu": "Меню сообщения",
+    "sendNow": "Отправить сейчас",
+    "cancel": "Отменить отправку",
+    "sentNow": "Сообщение отправлено",
+    "sendError": "Не удалось отправить сообщение",
+    "cancelled": "Отправка отменена",
+    "cancelError": "Не удалось отменить сообщение",
+    "deleted": "Сообщение удалено",
+    "deleteError": "Не удалось удалить сообщение",
+    "deleteTitle": "Удалить это сообщение?",
+    "deleteDescription": "Запланированное сообщение удаляется навсегда. Уже отправленное остаётся в переписке."
+  }
+}

+ 2 - 0
messages/ru/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "История активности и изменений",
     "optAuditLogsDesc": "История активности и изменений",
     "optSmsMessages": "SMS-сообщения",
     "optSmsMessages": "SMS-сообщения",
     "optSmsMessagesDesc": "Отправленные и полученные SMS-сообщения",
     "optSmsMessagesDesc": "Отправленные и полученные SMS-сообщения",
+    "optScheduledMessages": "Запланированные сообщения",
+    "optScheduledMessagesDesc": "Сообщения в очереди и повторяющиеся, ожидающие отправки",
     "optNotifications": "Уведомления",
     "optNotifications": "Уведомления",
     "optNotificationsDesc": "Системные уведомления",
     "optNotificationsDesc": "Системные уведомления",
     "optCustomFields": "Пользовательские поля",
     "optCustomFields": "Пользовательские поля",

+ 9 - 4
messages/tr/calendar.json

@@ -33,7 +33,8 @@
   "filters": {
   "filters": {
     "services": "Servisler",
     "services": "Servisler",
     "reminders": "Hatırlatıcılar",
     "reminders": "Hatırlatıcılar",
-    "quotes": "Teklifler"
+    "quotes": "Teklifler",
+    "messages": "Mesajlar"
   },
   },
   "selectVehicle": "İş emri için araç seçin",
   "selectVehicle": "İş emri için araç seçin",
   "dateChoice": {
   "dateChoice": {
@@ -49,7 +50,8 @@
     "newReminder": "Yeni hatırlatıcı",
     "newReminder": "Yeni hatırlatıcı",
     "newQuote": "Yeni teklif",
     "newQuote": "Yeni teklif",
     "openEvent": "Aç ({count})",
     "openEvent": "Aç ({count})",
-    "hint": "İpucu: işlemler için bir güne sağ tıklayın"
+    "hint": "İpucu: işlemler için bir güne sağ tıklayın",
+    "scheduleMessage": "Mesaj planla"
   },
   },
   "events": {
   "events": {
     "status": {
     "status": {
@@ -62,12 +64,15 @@
       "draft": "Taslak",
       "draft": "Taslak",
       "done": "Bitti",
       "done": "Bitti",
       "overdue": "Gecikmiş",
       "overdue": "Gecikmiş",
-      "upcoming": "Yaklaşan"
+      "upcoming": "Yaklaşan",
+      "failed": "Başarısız",
+      "queued": "Sırada"
     },
     },
     "type": {
     "type": {
       "service": "Servis",
       "service": "Servis",
       "reminder": "Hatırlatıcı",
       "reminder": "Hatırlatıcı",
-      "quote": "Teklif"
+      "quote": "Teklif",
+      "message": "Mesaj"
     },
     },
     "noEvents": "Etkinlik yok",
     "noEvents": "Etkinlik yok",
     "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun."
     "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun."

+ 12 - 2
messages/tr/messages.json

@@ -1,7 +1,14 @@
 {
 {
   "page": {
   "page": {
     "featureName": "SMS Mesajları",
     "featureName": "SMS Mesajları",
-    "featureDescription": "Müşterilerinizle doğrudan Torqvoice üzerinden SMS mesajları gönderin ve alın."
+    "featureDescription": "Müşterilerinizle doğrudan Torqvoice üzerinden SMS mesajları gönderin ve alın.",
+    "tabs": {
+      "inbox": "Gelen kutusu",
+      "scheduled": "Planlanmış"
+    },
+    "smsNotConfiguredTitle": "SMS kurulmadı",
+    "smsNotConfiguredHint": "Burada mesaj gönderip almak için bir SMS sağlayıcısı bağlayın. Planlanmış e-posta bunsuz da çalışır.",
+    "goToSmsSettings": "SMS ayarlarına git"
   },
   },
   "notConfigured": {
   "notConfigured": {
     "title": "SMS yapılandırılmadı",
     "title": "SMS yapılandırılmadı",
@@ -20,7 +27,10 @@
     "deleted": "Görüşme silindi",
     "deleted": "Görüşme silindi",
     "deleteError": "Görüşme silinemedi",
     "deleteError": "Görüşme silinemedi",
     "back": "Back",
     "back": "Back",
-    "conversationMenu": "Conversation menu"
+    "conversationMenu": "Conversation menu",
+    "searchPlaceholder": "Sohbetlerde ara",
+    "noSearchResults": "Aramayla eşleşen sohbet yok",
+    "now": "şimdi"
   },
   },
   "conversation": {
   "conversation": {
     "noPhone": "{name} için kayıtlı telefon numarası yok. Mesaj göndermeye başlamak için bir telefon numarası ekleyin.",
     "noPhone": "{name} için kayıtlı telefon numarası yok. Mesaj göndermeye başlamak için bir telefon numarası ekleyin.",

+ 0 - 1
messages/tr/navigation.json

@@ -77,7 +77,6 @@
     "clients": "Müşteriler",
     "clients": "Müşteriler",
     "customers": "Müşteriler",
     "customers": "Müşteriler",
     "messages": "Mesajlar",
     "messages": "Mesajlar",
-    "smsMessages": "SMS Messages",
     "telegram": "Telegram",
     "telegram": "Telegram",
     "workshop": "Atölye",
     "workshop": "Atölye",
     "vehicles": "Araçlar",
     "vehicles": "Araçlar",

+ 76 - 0
messages/tr/scheduledMessages.json

@@ -0,0 +1,76 @@
+{
+  "dialog": {
+    "title": "Mesaj planla",
+    "editTitle": "Planlanmış mesajı düzenle",
+    "channelLabel": "Gönderim yolu",
+    "channels": {
+      "email": "E-posta",
+      "sms": "SMS",
+      "telegram": "Telegram",
+      "in_app": "Uygulama içi not"
+    },
+    "repeatLabel": "Tekrar",
+    "frequencies": {
+      "once": "Bir kez",
+      "daily": "Her gün",
+      "weekly": "Her hafta",
+      "biweekly": "2 haftada bir",
+      "monthly": "Her ay",
+      "yearly": "Her yıl"
+    },
+    "customerLabel": "Müşteri",
+    "selectCustomer": "Müşteri seçin",
+    "none": "Yok",
+    "recipientEmail": "E-posta adresi",
+    "recipientPhone": "Telefon numarası",
+    "recipientChat": "Telegram sohbet kimliği",
+    "recipientOverride": "Bunun yerine başka adrese gönder",
+    "recipientHintCustomer": "Kayıtlı bilgileri kullanmak için boş bırakın.",
+    "recipientHintDirect": "Bir müşteri seçin veya alıcıyı yazın.",
+    "subjectLabel": "Konu",
+    "subjectPlaceholder": "örn. Aracınız hazır",
+    "messageLabel": "Mesaj",
+    "messagePlaceholder": "Ne söylemek istiyorsunuz?",
+    "characters": "{count} / {max} karakter",
+    "dateLabel": "Tarih",
+    "timeLabel": "Saat",
+    "endDateLabel": "Şu tarihe kadar tekrarla",
+    "endDateHint": "Sürekli tekrar için boş bırakın.",
+    "schedule": "Planla",
+    "scheduled": "Mesaj planlandı",
+    "updated": "Planlanmış mesaj güncellendi",
+    "saveError": "Mesaj kaydedilemedi"
+  },
+  "list": {
+    "scheduleMessage": "Mesaj planla",
+    "scheduleForCustomer": "Mesaj planla",
+    "filters": {
+      "upcoming": "Yaklaşan",
+      "sent": "Gönderilen",
+      "failed": "Başarısız",
+      "cancelled": "İptal edilen",
+      "all": "Tümü"
+    },
+    "status": {
+      "scheduled": "Planlandı",
+      "sent": "Gönderildi",
+      "failed": "Başarısız",
+      "cancelled": "İptal edildi"
+    },
+    "empty": "Planlanmış bir şey yok",
+    "emptyHint": "Takvimde bir güne sağ tıklayın veya Mesaj planla'yı kullanın.",
+    "noRecipient": "Alıcı yok",
+    "sentCount": "{count}x gönderildi",
+    "rowMenu": "Mesaj menüsü",
+    "sendNow": "Şimdi gönder",
+    "cancel": "Göndermeyi iptal et",
+    "sentNow": "Mesaj gönderildi",
+    "sendError": "Mesaj gönderilemedi",
+    "cancelled": "Gönderim iptal edildi",
+    "cancelError": "Mesaj iptal edilemedi",
+    "deleted": "Mesaj silindi",
+    "deleteError": "Mesaj silinemedi",
+    "deleteTitle": "Bu mesaj silinsin mi?",
+    "deleteDescription": "Planlanmış mesaj kalıcı olarak kaldırılır. Zaten gönderilenler sohbette kalır."
+  }
+}

+ 2 - 0
messages/tr/settings.json

@@ -901,6 +901,8 @@
     "optAuditLogsDesc": "Etkinlik ve değişiklik geçmişi",
     "optAuditLogsDesc": "Etkinlik ve değişiklik geçmişi",
     "optSmsMessages": "SMS Mesajları",
     "optSmsMessages": "SMS Mesajları",
     "optSmsMessagesDesc": "Gönderilen ve alınan SMS mesajları",
     "optSmsMessagesDesc": "Gönderilen ve alınan SMS mesajları",
+    "optScheduledMessages": "Planlanmış mesajlar",
+    "optScheduledMessagesDesc": "Sırada bekleyen ve tekrar eden, henüz gönderilmemiş mesajlar",
     "optNotifications": "Bildirimler",
     "optNotifications": "Bildirimler",
     "optNotificationsDesc": "Sistem bildirimleri",
     "optNotificationsDesc": "Sistem bildirimleri",
     "optCustomFields": "Özel Alanlar",
     "optCustomFields": "Özel Alanlar",

+ 66 - 0
prisma/migrations/20260815210000_scheduled_messages/migration.sql

@@ -0,0 +1,66 @@
+-- Messages the workshop lines up to go out on a chosen day and time.
+--
+-- One row per scheduled send, through whichever channel fits the customer
+-- (email, SMS, Telegram, or an in-app note for the workshop itself). A
+-- frequency other than 'once' reschedules itself after each send, following
+-- the same shape report_schedules already uses. Idempotent; safe to re-run.
+
+CREATE TABLE IF NOT EXISTS "scheduled_messages" (
+  "id"             TEXT NOT NULL,
+  "channel"        TEXT NOT NULL,
+  "subject"        TEXT,
+  "body"           TEXT NOT NULL,
+  "recipient"      TEXT,
+  "status"         TEXT NOT NULL DEFAULT 'scheduled',
+  "sendAt"         TIMESTAMP(3) NOT NULL,
+  "frequency"      TEXT NOT NULL DEFAULT 'once',
+  "endDate"        TIMESTAMP(3),
+  "lastRunAt"      TIMESTAMP(3),
+  "sentAt"         TIMESTAMP(3),
+  "runCount"       INTEGER NOT NULL DEFAULT 0,
+  "errorMessage"   TEXT,
+  "createdAt"      TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  "updatedAt"      TIMESTAMP(3) NOT NULL,
+  "organizationId" TEXT NOT NULL,
+  "customerId"     TEXT,
+  "vehicleId"      TEXT,
+  "createdById"    TEXT NOT NULL,
+
+  CONSTRAINT "scheduled_messages_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX IF NOT EXISTS "scheduled_messages_organizationId_sendAt_idx"
+  ON "scheduled_messages"("organizationId", "sendAt");
+
+-- The cron poller's query: everything still queued whose time has come.
+CREATE INDEX IF NOT EXISTS "scheduled_messages_status_sendAt_idx"
+  ON "scheduled_messages"("status", "sendAt");
+
+DO $$
+BEGIN
+  ALTER TABLE "scheduled_messages"
+    ADD CONSTRAINT "scheduled_messages_organizationId_fkey"
+    FOREIGN KEY ("organizationId") REFERENCES "organizations"("id")
+    ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL;
+END $$;
+
+-- A deleted customer or vehicle leaves the message in place with its history
+-- intact; the send then falls back to the typed recipient, or fails loudly.
+DO $$
+BEGIN
+  ALTER TABLE "scheduled_messages"
+    ADD CONSTRAINT "scheduled_messages_customerId_fkey"
+    FOREIGN KEY ("customerId") REFERENCES "customers"("id")
+    ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL;
+END $$;
+
+DO $$
+BEGIN
+  ALTER TABLE "scheduled_messages"
+    ADD CONSTRAINT "scheduled_messages_vehicleId_fkey"
+    FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id")
+    ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL;
+END $$;

+ 50 - 6
prisma/schema.prisma

@@ -180,6 +180,7 @@ model Vehicle {
   inspections       Inspection[]
   inspections       Inspection[]
   serviceRequests   ServiceRequest[]
   serviceRequests   ServiceRequest[]
   findings          VehicleFinding[]
   findings          VehicleFinding[]
+  scheduledMessages ScheduledMessage[]
 
 
   @@index([organizationId])
   @@index([organizationId])
   @@map("vehicles")
   @@map("vehicles")
@@ -387,12 +388,13 @@ model Customer {
   organizationId String?
   organizationId String?
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
 
-  vehicles         Vehicle[]
-  quotes           Quote[]
-  smsMessages      SmsMessage[]
-  telegramMessages TelegramMessage[]
-  customerSessions CustomerSession[]
-  serviceRequests  ServiceRequest[]
+  vehicles          Vehicle[]
+  quotes            Quote[]
+  smsMessages       SmsMessage[]
+  telegramMessages  TelegramMessage[]
+  customerSessions  CustomerSession[]
+  scheduledMessages ScheduledMessage[]
+  serviceRequests   ServiceRequest[]
 
 
   serviceRecords ServiceRecord[]
   serviceRecords ServiceRecord[]
   reminders      Reminder[]
   reminders      Reminder[]
@@ -808,6 +810,7 @@ model Organization {
   auditLogs              AuditLog[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
   reportSchedules        ReportSchedule[]
+  scheduledMessages      ScheduledMessage[]
   webhooks               Webhook[]
   webhooks               Webhook[]
   stockMovements         StockMovement[]
   stockMovements         StockMovement[]
 
 
@@ -1444,6 +1447,47 @@ model ReportSchedule {
   @@map("report_schedules")
   @@map("report_schedules")
 }
 }
 
 
+/// A message the workshop lines up to go out on a given day and time, through
+/// whichever channel suits the customer. One-off by default; a frequency other
+/// than "once" reschedules itself after each send, the way ReportSchedule does.
+model ScheduledMessage {
+  id      String  @id @default(cuid())
+  channel String // "email" | "sms" | "telegram" | "in_app"
+  subject String? // email only
+  body    String
+
+  /// Where it goes when no customer is attached, or when the workshop typed an
+  /// address by hand. Falls back to the customer's email/phone/chat id.
+  recipient String?
+
+  status       String    @default("scheduled") // scheduled | sent | failed | cancelled
+  sendAt       DateTime
+  frequency    String    @default("once") // once | daily | weekly | biweekly | monthly | yearly
+  endDate      DateTime?
+  lastRunAt    DateTime?
+  sentAt       DateTime?
+  runCount     Int       @default(0)
+  errorMessage String?
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
+
+  createdById String
+
+  @@index([organizationId, sendAt])
+  @@index([status, sendAt])
+  @@map("scheduled_messages")
+}
+
 model Webhook {
 model Webhook {
   id              String    @id @default(cuid())
   id              String    @id @default(cuid())
   name            String
   name            String

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

@@ -4,6 +4,8 @@ import { getCustomersList } from "@/features/customers/Actions/customerActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { PageHeader } from "@/components/page-header";
 import { PageHeader } from "@/components/page-header";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { getAvailableChannels } from "@/features/scheduled-messages/Lib/availableChannels";
 import CalendarClient from "@/features/calendar/Components/CalendarClient";
 import CalendarClient from "@/features/calendar/Components/CalendarClient";
 
 
 function toLocalDateStr(d: Date): string {
 function toLocalDateStr(d: Date): string {
@@ -18,6 +20,9 @@ export default async function CalendarPage() {
   const start = new Date(now.getFullYear(), now.getMonth(), 1);
   const start = new Date(now.getFullYear(), now.getMonth(), 1);
   const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
   const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
 
 
+  const ctx = await getAuthContext();
+  const messageChannels = ctx ? await getAvailableChannels(ctx.organizationId) : [];
+
   const [eventsResult, settingsResult, vehiclesResult, customersResult] = await Promise.all([
   const [eventsResult, settingsResult, vehiclesResult, customersResult] = await Promise.all([
     getCalendarEvents({
     getCalendarEvents({
       start: toLocalDateStr(start),
       start: toLocalDateStr(start),
@@ -58,6 +63,7 @@ export default async function CalendarPage() {
           vehicles={vehicles}
           vehicles={vehicles}
           customers={customers}
           customers={customers}
           currencyCode={currencyCode}
           currencyCode={currencyCode}
+          messageChannels={messageChannels}
         />
         />
       </div>
       </div>
     </>
     </>

+ 0 - 9
src/app/(authenticated)/layout.tsx

@@ -56,15 +56,7 @@ export default async function DashboardLayout({
   const supportEnabled = await isSupportEnabled();
   const supportEnabled = await isSupportEnabled();
 
 
   // Check user-level messaging enabled settings
   // Check user-level messaging enabled settings
-  let smsEnabled = false;
   let telegramEnabled = false;
   let telegramEnabled = false;
-  if (features.sms) {
-    const smsSetting = await db.appSetting.findUnique({
-      where: { organizationId_key: { organizationId: data.organizationId, key: "sms.provider" } },
-      select: { value: true },
-    });
-    smsEnabled = !!smsSetting?.value;
-  }
   if (features.telegram) {
   if (features.telegram) {
     const tgSetting = await db.appSetting.findUnique({
     const tgSetting = await db.appSetting.findUnique({
       where: { organizationId_key: { organizationId: data.organizationId, key: "telegram.enabled" } },
       where: { organizationId_key: { organizationId: data.organizationId, key: "telegram.enabled" } },
@@ -153,7 +145,6 @@ export default async function DashboardLayout({
           activeOrgId={data.organizationId}
           activeOrgId={data.organizationId}
           isSuperAdmin={data.isSuperAdmin}
           isSuperAdmin={data.isSuperAdmin}
           features={features}
           features={features}
-          smsEnabled={smsEnabled}
           telegramEnabled={telegramEnabled}
           telegramEnabled={telegramEnabled}
           visibleSubjects={visibleSubjects}
           visibleSubjects={visibleSubjects}
           isAdminOrOwner={isOwnerOrAdmin}
           isAdminOrOwner={isOwnerOrAdmin}

+ 25 - 37
src/app/(authenticated)/messages/page.tsx

@@ -1,14 +1,13 @@
 import { Suspense } from "react";
 import { Suspense } from "react";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { getAuthContext } from "@/lib/get-auth-context";
-import { getFeatures, isCloudMode } from "@/lib/features";
 import { redirect } from "next/navigation";
 import { redirect } from "next/navigation";
-import { getTranslations } from "next-intl/server";
 import { getRecentSmsThreads } from "@/features/sms/Actions/smsActions";
 import { getRecentSmsThreads } from "@/features/sms/Actions/smsActions";
 import { getSmsSettings } from "@/features/sms/Actions/smsSettingsActions";
 import { getSmsSettings } from "@/features/sms/Actions/smsSettingsActions";
+import { getFeatures } from "@/lib/features";
+import { getScheduledMessages } from "@/features/scheduled-messages/Actions/scheduledMessageActions";
+import { getAvailableChannels } from "@/features/scheduled-messages/Lib/availableChannels";
 import { MessagesPageClient } from "@/features/sms/Components/MessagesPageClient";
 import { MessagesPageClient } from "@/features/sms/Components/MessagesPageClient";
 import { PageHeader } from "@/components/page-header";
 import { PageHeader } from "@/components/page-header";
-import { FeatureLockedMessage } from "../settings/feature-locked-message";
-import { SmsNotConfiguredMessage } from "./sms-not-configured-message";
 
 
 export default async function MessagesPage() {
 export default async function MessagesPage() {
   const ctx = await getAuthContext();
   const ctx = await getAuthContext();
@@ -16,49 +15,38 @@ export default async function MessagesPage() {
 
 
   const features = await getFeatures(ctx.organizationId);
   const features = await getFeatures(ctx.organizationId);
 
 
-  if (!features.sms) {
-    const t = await getTranslations("messages.page");
-    return (
-      <>
-        <PageHeader />
-        <div className="flex flex-1 flex-col p-4 pt-0">
-          <FeatureLockedMessage
-            feature={t("featureName")}
-            description={t("featureDescription")}
-            isCloud={isCloudMode()}
-          />
-        </div>
-      </>
-    );
-  }
-
-  const settingsResult = await getSmsSettings();
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const hasProvider = !!settings["sms.provider"];
+  // The inbox needs SMS on the plan and a provider set up; scheduled messages
+  // only need email, so the page itself stays open either way and the inbox
+  // tab explains what is missing.
+  const settingsResult = features.sms ? await getSmsSettings() : null;
+  const settings = settingsResult?.success && settingsResult.data ? settingsResult.data : {};
+  const smsConfigured = !!features.sms && !!settings["sms.provider"];
 
 
-  if (!hasProvider) {
-    return (
-      <>
-        <PageHeader />
-        <div className="flex flex-1 flex-col p-4 pt-0">
-          <SmsNotConfiguredMessage />
-        </div>
-      </>
-    );
-  }
+  const [threadsResult, scheduledResult, messageChannels] = await Promise.all([
+    smsConfigured ? getRecentSmsThreads() : Promise.resolve(null),
+    getScheduledMessages(),
+    getAvailableChannels(ctx.organizationId),
+  ]);
 
 
-  const threadsResult = await getRecentSmsThreads();
   const threads =
   const threads =
-    threadsResult.success && threadsResult.data ? threadsResult.data.threads : [];
+    threadsResult?.success && threadsResult.data ? threadsResult.data.threads : [];
   const hasMore =
   const hasMore =
-    threadsResult.success && threadsResult.data ? threadsResult.data.hasMore : false;
+    threadsResult?.success && threadsResult.data ? threadsResult.data.hasMore : false;
+  const scheduled =
+    scheduledResult.success && scheduledResult.data ? scheduledResult.data : [];
 
 
   return (
   return (
     <>
     <>
       <PageHeader />
       <PageHeader />
       <div className="flex flex-1 flex-col p-4 pt-0">
       <div className="flex flex-1 flex-col p-4 pt-0">
         <Suspense>
         <Suspense>
-          <MessagesPageClient initialThreads={threads} initialHasMore={hasMore} />
+          <MessagesPageClient
+            initialThreads={threads}
+            initialHasMore={hasMore}
+            initialScheduled={scheduled}
+            availableChannels={messageChannels}
+            smsConfigured={smsConfigured}
+          />
         </Suspense>
         </Suspense>
       </div>
       </div>
     </>
     </>

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

@@ -33,6 +33,7 @@ interface ContentCounts {
   inspectionTemplates: number
   inspectionTemplates: number
   notifications: number
   notifications: number
   smsMessages: number
   smsMessages: number
+  scheduledMessages: number
   customFields: number
   customFields: number
 }
 }
 
 
@@ -47,6 +48,7 @@ interface ExportOptions {
   inspections: boolean
   inspections: boolean
   auditLogs: boolean
   auditLogs: boolean
   smsMessages: boolean
   smsMessages: boolean
+  scheduledMessages: boolean
   notifications: boolean
   notifications: boolean
   files: boolean
   files: boolean
 }
 }
@@ -61,6 +63,7 @@ const OPTION_META: { key: keyof ExportOptions; labelKey: string; descKey: string
   { key: 'inspections', labelKey: 'optInspections', descKey: 'optInspectionsDesc' },
   { key: 'inspections', labelKey: 'optInspections', descKey: 'optInspectionsDesc' },
   { key: 'auditLogs', labelKey: 'optAuditLogs', descKey: 'optAuditLogsDesc' },
   { key: 'auditLogs', labelKey: 'optAuditLogs', descKey: 'optAuditLogsDesc' },
   { key: 'smsMessages', labelKey: 'optSmsMessages', descKey: 'optSmsMessagesDesc' },
   { key: 'smsMessages', labelKey: 'optSmsMessages', descKey: 'optSmsMessagesDesc' },
+  { key: 'scheduledMessages', labelKey: 'optScheduledMessages', descKey: 'optScheduledMessagesDesc' },
   { key: 'notifications', labelKey: 'optNotifications', descKey: 'optNotificationsDesc' },
   { key: 'notifications', labelKey: 'optNotifications', descKey: 'optNotificationsDesc' },
   { key: 'customFields', labelKey: 'optCustomFields', descKey: 'optCustomFieldsDesc' },
   { key: 'customFields', labelKey: 'optCustomFields', descKey: 'optCustomFieldsDesc' },
   { key: 'files', labelKey: 'optFiles', descKey: 'optFilesDesc' },
   { key: 'files', labelKey: 'optFiles', descKey: 'optFilesDesc' },
@@ -77,6 +80,7 @@ const ALL_TRUE: ExportOptions = {
   inspections: true,
   inspections: true,
   auditLogs: true,
   auditLogs: true,
   smsMessages: true,
   smsMessages: true,
+  scheduledMessages: true,
   notifications: true,
   notifications: true,
   files: true,
   files: true,
 }
 }
@@ -120,6 +124,7 @@ export function DataSettings({
         inspections: false,
         inspections: false,
         auditLogs: false,
         auditLogs: false,
         smsMessages: false,
         smsMessages: false,
+        scheduledMessages: false,
         notifications: false,
         notifications: false,
         files: false,
         files: false,
       })
       })

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

@@ -23,7 +23,7 @@ export default async function DataSettingsPage() {
     : {
     : {
         vehicles: 0, customers: 0, quotes: 0, inventory: 0,
         vehicles: 0, customers: 0, quotes: 0, inventory: 0,
         inspections: 0, technicians: 0, inspectionTemplates: 0,
         inspections: 0, technicians: 0, inspectionTemplates: 0,
-        notifications: 0, smsMessages: 0, customFields: 0,
+        notifications: 0, smsMessages: 0, scheduledMessages: 0, customFields: 0,
       };
       };
 
 
   return (
   return (

+ 15 - 1
src/app/api/protected/backup/export/route.ts

@@ -20,6 +20,7 @@ interface ExportOptions {
   inspections: boolean;
   inspections: boolean;
   auditLogs: boolean;
   auditLogs: boolean;
   smsMessages: boolean;
   smsMessages: boolean;
+  scheduledMessages: boolean;
   notifications: boolean;
   notifications: boolean;
 }
 }
 
 
@@ -35,6 +36,7 @@ const DEFAULT_OPTIONS: ExportOptions = {
   inspections: true,
   inspections: true,
   auditLogs: true,
   auditLogs: true,
   smsMessages: true,
   smsMessages: true,
+  scheduledMessages: true,
   notifications: true,
   notifications: true,
 };
 };
 
 
@@ -239,6 +241,16 @@ export async function POST(request: NextRequest) {
     );
     );
   }
   }
 
 
+  if (options.scheduledMessages) {
+    queries.push(
+      db.scheduledMessage
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.scheduledMessages = result;
+        })
+    );
+  }
+
   if (options.notifications) {
   if (options.notifications) {
     queries.push(
     queries.push(
       db.notification
       db.notification
@@ -315,7 +327,7 @@ export async function GET() {
 
 
   const [
   const [
     settings, customers, customFieldDefinitions, inventoryParts, vehicles, orgReminders, counterSales, quotes,
     settings, customers, customFieldDefinitions, inventoryParts, vehicles, orgReminders, counterSales, quotes,
-    technicians, inspectionTemplates, inspections, auditLogs, smsMessages, notifications,
+    technicians, inspectionTemplates, inspections, auditLogs, smsMessages, scheduledMessages, notifications,
   ] = await Promise.all([
   ] = await Promise.all([
       db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
       db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
       db.customer.findMany({ where: { organizationId: ctx.organizationId } }),
       db.customer.findMany({ where: { organizationId: ctx.organizationId } }),
@@ -370,6 +382,7 @@ export async function GET() {
       }),
       }),
       db.auditLog.findMany({ where: { organizationId: ctx.organizationId } }),
       db.auditLog.findMany({ where: { organizationId: ctx.organizationId } }),
       db.smsMessage.findMany({ where: { organizationId: ctx.organizationId } }),
       db.smsMessage.findMany({ where: { organizationId: ctx.organizationId } }),
+      db.scheduledMessage.findMany({ where: { organizationId: ctx.organizationId } }),
       db.notification.findMany({ where: { organizationId: ctx.organizationId } }),
       db.notification.findMany({ where: { organizationId: ctx.organizationId } }),
     ]);
     ]);
 
 
@@ -390,6 +403,7 @@ export async function GET() {
       inspections,
       inspections,
       auditLogs,
       auditLogs,
       smsMessages,
       smsMessages,
+      scheduledMessages,
       notifications,
       notifications,
     },
     },
   };
   };

+ 34 - 0
src/app/api/protected/backup/import/route.ts

@@ -850,6 +850,40 @@ export async function POST(request: NextRequest) {
           ),
           ),
         });
         });
       }
       }
+
+      // 14. Insert scheduled messages. Last, because they point at customers
+      // and vehicles, which are already in by this stage.
+      if (data.scheduledMessages?.length) {
+        await tx.scheduledMessage.createMany({
+          // A message with no readable send time has nothing to act on, so it
+          // is dropped rather than restored to an invented moment
+          data: (data.scheduledMessages as Record<string, unknown>[])
+            .filter((msg: Record<string, unknown>) => !!toSafeDate(msg.sendAt as string))
+            .map((msg: Record<string, unknown>) => ({
+              id: msg.id as string,
+              channel: msg.channel as string,
+              subject: (msg.subject as string) || null,
+              body: msg.body as string,
+              recipient: (msg.recipient as string) || null,
+              status: (msg.status as string) || "scheduled",
+              sendAt: toSafeDate(msg.sendAt as string)!,
+              frequency: (msg.frequency as string) || "once",
+              endDate: msg.endDate ? toSafeDate(msg.endDate as string) : null,
+              lastRunAt: msg.lastRunAt ? toSafeDate(msg.lastRunAt as string) : null,
+              sentAt: msg.sentAt ? toSafeDate(msg.sentAt as string) : null,
+              runCount: (msg.runCount as number) || 0,
+              errorMessage: (msg.errorMessage as string) || null,
+              createdAt: toSafeDate(msg.createdAt as string),
+              updatedAt: toSafeDate(msg.updatedAt as string),
+              organizationId: ctx.organizationId,
+              customerId: (msg.customerId as string) || null,
+              vehicleId: (msg.vehicleId as string) || null,
+              // The author of the backup may not exist here; the importing
+              // user owns the restored rows, as everywhere else in this file
+              createdById: ctx.userId,
+            })),
+        });
+      }
     });
     });
 
 
     // Restore files after successful DB transaction
     // Restore files after successful DB transaction

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

@@ -85,7 +85,6 @@ export function AppSidebar({
   activeOrgId,
   activeOrgId,
   isSuperAdmin,
   isSuperAdmin,
   features,
   features,
-  smsEnabled = false,
   telegramEnabled = false,
   telegramEnabled = false,
   isAdminOrOwner = false,
   isAdminOrOwner = false,
   visibleSubjects,
   visibleSubjects,
@@ -96,7 +95,6 @@ export function AppSidebar({
   activeOrgId?: string
   activeOrgId?: string
   isSuperAdmin?: boolean
   isSuperAdmin?: boolean
   features?: PlanFeatures
   features?: PlanFeatures
-  smsEnabled?: boolean
   telegramEnabled?: boolean
   telegramEnabled?: boolean
   isAdminOrOwner?: boolean
   isAdminOrOwner?: boolean
   visibleSubjects?: string[]
   visibleSubjects?: string[]
@@ -119,7 +117,7 @@ export function AppSidebar({
 
 
   const clientItems = [
   const clientItems = [
     { titleKey: 'sidebar.customers' as const, url: '/customers', icon: Users, subject: 'customers' },
     { titleKey: 'sidebar.customers' as const, url: '/customers', icon: Users, subject: 'customers' },
-    ...(smsEnabled ? [{ titleKey: 'sidebar.smsMessages' as const, url: '/messages', icon: MessageSquare, subject: 'customers' }] : []),
+    { titleKey: 'sidebar.messages' as const, url: '/messages', icon: MessageSquare, subject: 'customers' },
     ...(telegramEnabled ? [{ titleKey: 'sidebar.telegram' as const, url: '/telegram', icon: Send, subject: 'customers' }] : []),
     ...(telegramEnabled ? [{ titleKey: 'sidebar.telegram' as const, url: '/telegram', icon: Send, subject: 'customers' }] : []),
   ].filter((item) => canAccess(item.subject))
   ].filter((item) => canAccess(item.subject))
 
 

+ 1 - 0
src/cronTasks.ts

@@ -8,3 +8,4 @@ export { startDemoResetCron } from './lib/cron/demo-reset'
 export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'
 export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'
 export { checkLowStock } from './lib/cron/low-stock-alerts'
 export { checkLowStock } from './lib/cron/low-stock-alerts'
 export { checkDueReminders } from './lib/cron/reminder-alerts'
 export { checkDueReminders } from './lib/cron/reminder-alerts'
+export { processScheduledMessages } from './lib/cron/scheduled-messages'

+ 40 - 2
src/features/calendar/Actions/calendarActions.ts

@@ -9,8 +9,10 @@ export type CalendarEvent = {
   title: string;
   title: string;
   date: string; // YYYY-MM-DD local date
   date: string; // YYYY-MM-DD local date
   time: string | null; // HH:MM or null
   time: string | null; // HH:MM or null
-  type: "service" | "reminder" | "quote";
+  type: "service" | "reminder" | "quote" | "message";
   status: string;
   status: string;
+  /** Only on scheduled-message events: email | sms | telegram | in_app */
+  channel?: string;
   vehicleId: string | null;
   vehicleId: string | null;
   vehicleLabel: string;
   vehicleLabel: string;
   customerName: string | null;
   customerName: string | null;
@@ -39,7 +41,7 @@ export async function getCalendarEvents(params: {
     const end = new Date(params.end);
     const end = new Date(params.end);
     end.setHours(23, 59, 59, 999);
     end.setHours(23, 59, 59, 999);
 
 
-    const [services, reminders, quotes] = await Promise.all([
+    const [services, reminders, quotes, scheduledMessages] = await Promise.all([
       db.serviceRecord.findMany({
       db.serviceRecord.findMany({
         where: {
         where: {
           organizationId,
           organizationId,
@@ -116,6 +118,26 @@ export async function getCalendarEvents(params: {
         },
         },
         orderBy: { validUntil: "asc" },
         orderBy: { validUntil: "asc" },
       }),
       }),
+      db.scheduledMessage.findMany({
+        where: {
+          organizationId,
+          status: { not: "cancelled" },
+          sendAt: { gte: start, lte: end },
+        },
+        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" },
+      }),
     ]);
     ]);
 
 
     const events: CalendarEvent[] = [
     const events: CalendarEvent[] = [
@@ -162,6 +184,22 @@ export async function getCalendarEvents(params: {
           invoiceNumber: q.quoteNumber,
           invoiceNumber: q.quoteNumber,
           amount: q.totalAmount > 0 ? q.totalAmount : null,
           amount: q.totalAmount > 0 ? q.totalAmount : null,
         })),
         })),
+      ...scheduledMessages.map((m) => ({
+        id: m.id,
+        // A subject reads better in a day cell than the first line of the body
+        title: m.subject?.trim() || m.body.slice(0, 60),
+        date: toLocalDateStr(m.sendAt),
+        time: toTimeStr(m.sendAt),
+        type: "message" as const,
+        // "scheduled" | "sent" | "failed", carrying the channel for the icon
+        status: m.status,
+        channel: m.channel,
+        vehicleId: m.vehicleId,
+        vehicleLabel: m.vehicle ? `${m.vehicle.year} ${m.vehicle.make} ${m.vehicle.model}` : "",
+        customerName: m.customer?.name ?? m.recipient ?? null,
+        invoiceNumber: null,
+        amount: null,
+      })),
     ];
     ];
 
 
     return events;
     return events;

+ 33 - 1
src/features/calendar/Components/CalendarClient.tsx

@@ -17,6 +17,8 @@ import { CalendarDayCell } from "./CalendarDayCell";
 import { CalendarEventList } from "./CalendarEventList";
 import { CalendarEventList } from "./CalendarEventList";
 import { ReminderFormDialog } from "@/features/vehicles/Components/ReminderFormDialog";
 import { ReminderFormDialog } from "@/features/vehicles/Components/ReminderFormDialog";
 import { NewQuoteDialog } from "@/features/quotes/Components/NewQuoteDialog";
 import { NewQuoteDialog } from "@/features/quotes/Components/NewQuoteDialog";
+import { ScheduleMessageDialog } from "@/features/scheduled-messages/Components/ScheduleMessageDialog";
+import type { MessageChannel } from "@/features/scheduled-messages/Schema/scheduledMessageSchema";
 import { toLocalDateStr } from "./calendar-utils";
 import { toLocalDateStr } from "./calendar-utils";
 import { getCalendarEvents } from "../Actions/calendarActions";
 import { getCalendarEvents } from "../Actions/calendarActions";
 import type { CalendarEvent } from "../Actions/calendarActions";
 import type { CalendarEvent } from "../Actions/calendarActions";
@@ -53,6 +55,8 @@ interface CalendarClientProps {
   vehicles: Vehicle[];
   vehicles: Vehicle[];
   customers: Customer[];
   customers: Customer[];
   currencyCode: string;
   currencyCode: string;
+  /** Channels the workshop can actually send on, resolved on the server */
+  messageChannels: MessageChannel[];
 }
 }
 
 
 function getMonthDays(year: number, month: number, weekStartDay: number) {
 function getMonthDays(year: number, month: number, weekStartDay: number) {
@@ -92,6 +96,7 @@ export default function CalendarClient({
   vehicles,
   vehicles,
   customers,
   customers,
   currencyCode,
   currencyCode,
+  messageChannels,
 }: CalendarClientProps) {
 }: CalendarClientProps) {
   const t = useTranslations('calendar');
   const t = useTranslations('calendar');
   const { weekStartDay } = useDateSettings();
   const { weekStartDay } = useDateSettings();
@@ -111,6 +116,7 @@ export default function CalendarClient({
   const [showServices, setShowServices] = useState(true);
   const [showServices, setShowServices] = useState(true);
   const [showReminders, setShowReminders] = useState(true);
   const [showReminders, setShowReminders] = useState(true);
   const [showQuotes, setShowQuotes] = useState(true);
   const [showQuotes, setShowQuotes] = useState(true);
+  const [showMessages, setShowMessages] = useState(true);
 
 
   // Vehicle picker
   // Vehicle picker
   const [showPicker, setShowPicker] = useState(false);
   const [showPicker, setShowPicker] = useState(false);
@@ -120,6 +126,7 @@ export default function CalendarClient({
   // Right-click menu targets: the day the menu was opened on
   // Right-click menu targets: the day the menu was opened on
   const [showReminderDialog, setShowReminderDialog] = useState(false);
   const [showReminderDialog, setShowReminderDialog] = useState(false);
   const [showQuoteDialog, setShowQuoteDialog] = useState(false);
   const [showQuoteDialog, setShowQuoteDialog] = useState(false);
+  const [showMessageDialog, setShowMessageDialog] = useState(false);
   const [menuDateStr, setMenuDateStr] = useState<string | undefined>(undefined);
   const [menuDateStr, setMenuDateStr] = useState<string | undefined>(undefined);
 
 
   const selectedDateStr = toLocalDateStr(selectedDate);
   const selectedDateStr = toLocalDateStr(selectedDate);
@@ -144,9 +151,10 @@ export default function CalendarClient({
       if (e.type === "service" && !showServices) return false;
       if (e.type === "service" && !showServices) return false;
       if (e.type === "reminder" && !showReminders) return false;
       if (e.type === "reminder" && !showReminders) return false;
       if (e.type === "quote" && !showQuotes) return false;
       if (e.type === "quote" && !showQuotes) return false;
+      if (e.type === "message" && !showMessages) return false;
       return true;
       return true;
     });
     });
-  }, [events, showServices, showReminders, showQuotes]);
+  }, [events, showServices, showReminders, showQuotes, showMessages]);
 
 
   // What the displayed month holds, counted before the filters hide anything
   // What the displayed month holds, counted before the filters hide anything
   const counts = useMemo(
   const counts = useMemo(
@@ -154,6 +162,7 @@ export default function CalendarClient({
       services: events.filter((e) => e.type === "service").length,
       services: events.filter((e) => e.type === "service").length,
       reminders: events.filter((e) => e.type === "reminder").length,
       reminders: events.filter((e) => e.type === "reminder").length,
       quotes: events.filter((e) => e.type === "quote").length,
       quotes: events.filter((e) => e.type === "quote").length,
+      messages: events.filter((e) => e.type === "message").length,
       total: events.length,
       total: events.length,
     }),
     }),
     [events]
     [events]
@@ -213,6 +222,11 @@ export default function CalendarClient({
     setShowQuoteDialog(true);
     setShowQuoteDialog(true);
   }, []);
   }, []);
 
 
+  const handleMenuMessage = useCallback((dateStr: string) => {
+    setMenuDateStr(dateStr);
+    setShowMessageDialog(true);
+  }, []);
+
   const refreshMonth = useCallback(() => {
   const refreshMonth = useCallback(() => {
     fetchEvents(year, month);
     fetchEvents(year, month);
   }, [fetchEvents, year, month]);
   }, [fetchEvents, year, month]);
@@ -310,6 +324,15 @@ export default function CalendarClient({
                   <span className="text-muted-foreground">{t('filters.quotes')}</span>
                   <span className="text-muted-foreground">{t('filters.quotes')}</span>
                   <span className="font-medium tabular-nums">{counts.quotes}</span>
                   <span className="font-medium tabular-nums">{counts.quotes}</span>
                 </label>
                 </label>
+                <label className="flex items-center gap-1.5 cursor-pointer">
+                  <Checkbox
+                    checked={showMessages}
+                    onCheckedChange={(v) => setShowMessages(!!v)}
+                  />
+                  <div className="h-2 w-2 rounded-full bg-sky-500" />
+                  <span className="text-muted-foreground">{t('filters.messages')}</span>
+                  <span className="font-medium tabular-nums">{counts.messages}</span>
+                </label>
                 <span className="text-xs text-muted-foreground ml-auto">
                 <span className="text-xs text-muted-foreground ml-auto">
                   {t('monthTotal', { count: counts.total })}
                   {t('monthTotal', { count: counts.total })}
                 </span>
                 </span>
@@ -340,6 +363,7 @@ export default function CalendarClient({
                       onNewWorkOrder={() => handleMenuWorkOrder(dateStr)}
                       onNewWorkOrder={() => handleMenuWorkOrder(dateStr)}
                       onNewReminder={() => handleMenuReminder(dateStr)}
                       onNewReminder={() => handleMenuReminder(dateStr)}
                       onNewQuote={() => handleMenuQuote(dateStr)}
                       onNewQuote={() => handleMenuQuote(dateStr)}
+                      onScheduleMessage={() => handleMenuMessage(dateStr)}
                     />
                     />
                   );
                   );
                 })}
                 })}
@@ -406,6 +430,14 @@ export default function CalendarClient({
         defaultValidUntil={menuDateStr}
         defaultValidUntil={menuDateStr}
       />
       />
 
 
+      <ScheduleMessageDialog
+        open={showMessageDialog}
+        onOpenChange={setShowMessageDialog}
+        availableChannels={messageChannels}
+        defaultDate={menuDateStr}
+        onSaved={refreshMonth}
+      />
+
       <VehiclePickerDialog
       <VehiclePickerDialog
         open={showPicker}
         open={showPicker}
         onOpenChange={setShowPicker}
         onOpenChange={setShowPicker}

+ 19 - 1
src/features/calendar/Components/CalendarDayCell.tsx

@@ -2,7 +2,7 @@
 
 
 import { useRouter } from "next/navigation";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
-import { Bell, ExternalLink, FileText, Wrench } from "lucide-react";
+import { Bell, ExternalLink, FileText, Send, Wrench } from "lucide-react";
 import {
 import {
   ContextMenu,
   ContextMenu,
   ContextMenuContent,
   ContextMenuContent,
@@ -30,6 +30,13 @@ function getEventDotColor(event: CalendarEvent) {
   if (event.type === "quote") {
   if (event.type === "quote") {
     return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
     return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
   }
   }
+  if (event.type === "message") {
+    switch (event.status) {
+      case "sent": return "bg-teal-500";
+      case "failed": return "bg-red-500";
+      default: return "bg-sky-500";
+    }
+  }
   // reminder
   // reminder
   switch (event.status) {
   switch (event.status) {
     case "completed": return "bg-emerald-500";
     case "completed": return "bg-emerald-500";
@@ -51,6 +58,7 @@ interface CalendarDayCellProps {
   onNewWorkOrder: () => void;
   onNewWorkOrder: () => void;
   onNewReminder: () => void;
   onNewReminder: () => void;
   onNewQuote: () => void;
   onNewQuote: () => void;
+  onScheduleMessage: () => void;
 }
 }
 
 
 export function CalendarDayCell({
 export function CalendarDayCell({
@@ -63,6 +71,7 @@ export function CalendarDayCell({
   onNewWorkOrder,
   onNewWorkOrder,
   onNewReminder,
   onNewReminder,
   onNewQuote,
   onNewQuote,
+  onScheduleMessage,
 }: CalendarDayCellProps) {
 }: CalendarDayCellProps) {
   const t = useTranslations('calendar');
   const t = useTranslations('calendar');
   const router = useRouter();
   const router = useRouter();
@@ -143,6 +152,10 @@ export function CalendarDayCell({
           <FileText className="mr-2 h-4 w-4" />
           <FileText className="mr-2 h-4 w-4" />
           {t('contextMenu.newQuote')}
           {t('contextMenu.newQuote')}
         </ContextMenuItem>
         </ContextMenuItem>
+        <ContextMenuItem onClick={onScheduleMessage}>
+          <Send className="mr-2 h-4 w-4" />
+          {t('contextMenu.scheduleMessage')}
+        </ContextMenuItem>
         {dayEvents.length > 0 && (
         {dayEvents.length > 0 && (
           <>
           <>
             <ContextMenuSeparator />
             <ContextMenuSeparator />
@@ -187,6 +200,11 @@ function getEventBgColor(event: CalendarEvent) {
   if (event.type === "quote") {
   if (event.type === "quote") {
     return "bg-violet-500/10 text-violet-700 dark:text-violet-400";
     return "bg-violet-500/10 text-violet-700 dark:text-violet-400";
   }
   }
+  if (event.type === "message") {
+    return event.status === "failed"
+      ? "bg-red-500/10 text-red-700 dark:text-red-400"
+      : "bg-sky-500/10 text-sky-700 dark:text-sky-400";
+  }
   switch (event.status) {
   switch (event.status) {
     case "overdue": return "bg-red-500/10 text-red-700 dark:text-red-400";
     case "overdue": return "bg-red-500/10 text-red-700 dark:text-red-400";
     default: return "bg-slate-500/10 text-slate-600 dark:text-slate-400";
     default: return "bg-slate-500/10 text-slate-600 dark:text-slate-400";

+ 15 - 0
src/features/calendar/Components/CalendarEventList.tsx

@@ -19,6 +19,13 @@ function getStatusColor(event: CalendarEvent) {
   if (event.type === "quote") {
   if (event.type === "quote") {
     return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
     return event.status === "sent" ? "bg-violet-500" : "bg-violet-300";
   }
   }
+  if (event.type === "message") {
+    switch (event.status) {
+      case "sent": return "bg-teal-500";
+      case "failed": return "bg-red-500";
+      default: return "bg-sky-500";
+    }
+  }
   switch (event.status) {
   switch (event.status) {
     case "completed": return "bg-emerald-500";
     case "completed": return "bg-emerald-500";
     case "overdue": return "bg-red-500";
     case "overdue": return "bg-red-500";
@@ -35,6 +42,13 @@ function getStatusBadge(event: CalendarEvent, t: (key: string) => string) {
       default: return <Badge variant="outline" className="text-amber-600 border-amber-300 text-[10px] px-1.5 py-0">{t('events.status.pending')}</Badge>;
       default: return <Badge variant="outline" className="text-amber-600 border-amber-300 text-[10px] px-1.5 py-0">{t('events.status.pending')}</Badge>;
     }
     }
   }
   }
+  if (event.type === "message") {
+    switch (event.status) {
+      case "sent": return <Badge variant="outline" className="text-teal-600 border-teal-300 text-[10px] px-1.5 py-0">{t('events.status.sent')}</Badge>;
+      case "failed": return <Badge variant="destructive" className="text-[10px] px-1.5 py-0">{t('events.status.failed')}</Badge>;
+      default: return <Badge variant="outline" className="text-sky-600 border-sky-300 text-[10px] px-1.5 py-0">{t('events.status.queued')}</Badge>;
+    }
+  }
   if (event.type === "quote") {
   if (event.type === "quote") {
     switch (event.status) {
     switch (event.status) {
       case "sent": return <Badge variant="outline" className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0">{t('events.status.sent')}</Badge>;
       case "sent": return <Badge variant="outline" className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0">{t('events.status.sent')}</Badge>;
@@ -54,6 +68,7 @@ function getTypeBadge(type: CalendarEvent["type"], t: (key: string) => string) {
     case "service": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-700 dark:text-blue-400">{t('events.type.service')}</Badge>;
     case "service": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-700 dark:text-blue-400">{t('events.type.service')}</Badge>;
     case "reminder": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-amber-500/10 text-amber-700 dark:text-amber-400">{t('events.type.reminder')}</Badge>;
     case "reminder": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-amber-500/10 text-amber-700 dark:text-amber-400">{t('events.type.reminder')}</Badge>;
     case "quote": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-violet-500/10 text-violet-700 dark:text-violet-400">{t('events.type.quote')}</Badge>;
     case "quote": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-violet-500/10 text-violet-700 dark:text-violet-400">{t('events.type.quote')}</Badge>;
+    case "message": return <Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0 bg-sky-500/10 text-sky-700 dark:text-sky-400">{t('events.type.message')}</Badge>;
   }
   }
 }
 }
 
 

+ 2 - 1
src/features/calendar/Components/calendar-utils.ts

@@ -9,10 +9,11 @@ export function toLocalDateStr(d: Date): string {
 /** Where an event points: the quote itself, otherwise the vehicle it belongs to */
 /** Where an event points: the quote itself, otherwise the vehicle it belongs to */
 export function getEventLink(event: {
 export function getEventLink(event: {
   id: string;
   id: string;
-  type: "service" | "reminder" | "quote";
+  type: "service" | "reminder" | "quote" | "message";
   vehicleId: string | null;
   vehicleId: string | null;
 }): string {
 }): string {
   if (event.type === "quote") return `/quotes/${event.id}`;
   if (event.type === "quote") return `/quotes/${event.id}`;
+  if (event.type === "message") return "/messages?tab=scheduled";
   if (!event.vehicleId) return event.type === "reminder" ? "/reminders" : `/sales/${event.id}`;
   if (!event.vehicleId) return event.type === "reminder" ? "/reminders" : `/sales/${event.id}`;
   return `/vehicles/${event.vehicleId}`;
   return `/vehicles/${event.vehicleId}`;
 }
 }

+ 330 - 0
src/features/scheduled-messages/Actions/scheduledMessageActions.ts

@@ -0,0 +1,330 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { demoGuard } from "@/lib/demo";
+import {
+  createScheduledMessageSchema,
+  updateScheduledMessageSchema,
+} from "../Schema/scheduledMessageSchema";
+import {
+  dispatchScheduledMessage,
+  nextSendAt,
+} from "../Lib/dispatchScheduledMessage";
+
+export type ScheduledMessageListItem = {
+  id: string;
+  channel: string;
+  subject: string | null;
+  body: string;
+  recipient: string | null;
+  status: string;
+  sendAt: Date;
+  frequency: string;
+  endDate: Date | null;
+  sentAt: Date | null;
+  runCount: number;
+  errorMessage: string | null;
+  customer: { id: string; name: string } | null;
+  vehicle: { id: string; make: string; model: string; year: number } | null;
+};
+
+const listSelect = {
+  id: true,
+  channel: true,
+  subject: true,
+  body: true,
+  recipient: true,
+  status: true,
+  sendAt: true,
+  frequency: true,
+  endDate: true,
+  sentAt: true,
+  runCount: true,
+  errorMessage: true,
+  customer: { select: { id: true, name: true } },
+  vehicle: { select: { id: true, make: true, model: true, year: true } },
+} as const;
+
+/**
+ * Parse the local wall-clock the workshop typed ("2026-08-20T09:00") as local
+ * time, never UTC, so the message goes out at the hour they see on screen.
+ */
+function parseLocalDateTime(value: string): Date {
+  const normalized = /\d{2}:\d{2}$/.test(value) ? `${value}:00` : value;
+  const date = new Date(normalized);
+  if (Number.isNaN(date.getTime())) throw new Error("Invalid send time");
+  return date;
+}
+
+export async function getScheduledMessages(params?: { status?: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const messages = await db.scheduledMessage.findMany({
+        where: {
+          organizationId,
+          ...(params?.status ? { status: params.status } : {}),
+        },
+        select: listSelect,
+        orderBy: [{ status: "asc" }, { sendAt: "asc" }],
+        take: 500,
+      });
+      return messages as ScheduledMessageListItem[];
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] },
+  );
+}
+
+/** Everything queued or already sent inside a date window, for the calendar. */
+export async function getScheduledMessagesInRange(params: { start: string; end: string }) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = new Date(params.start);
+      const end = new Date(params.end);
+      end.setHours(23, 59, 59, 999);
+
+      return db.scheduledMessage.findMany({
+        where: {
+          organizationId,
+          status: { not: "cancelled" },
+          sendAt: { gte: start, lte: end },
+        },
+        select: listSelect,
+        orderBy: { sendAt: "asc" },
+      });
+    },
+    { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] },
+  );
+}
+
+export async function createScheduledMessage(input: unknown) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      demoGuard();
+      const data = createScheduledMessageSchema.parse(input);
+      const sendAt = parseLocalDateTime(data.sendAt);
+
+      if (data.customerId) {
+        const customer = await db.customer.findFirst({
+          where: { id: data.customerId, organizationId },
+          select: { id: true },
+        });
+        if (!customer) throw new Error("Customer not found");
+      }
+      if (data.vehicleId) {
+        const vehicle = await db.vehicle.findFirst({
+          where: { id: data.vehicleId, organizationId },
+          select: { id: true },
+        });
+        if (!vehicle) throw new Error("Vehicle not found");
+      }
+
+      const message = await db.scheduledMessage.create({
+        data: {
+          channel: data.channel,
+          subject: data.subject?.trim() || null,
+          body: data.body,
+          recipient: data.recipient?.trim() || null,
+          customerId: data.customerId || null,
+          vehicleId: data.vehicleId || null,
+          sendAt,
+          frequency: data.frequency,
+          endDate: data.endDate ? parseLocalDateTime(data.endDate) : null,
+          organizationId,
+          createdById: userId,
+        },
+      });
+
+      revalidatePath("/messages");
+      revalidatePath("/calendar");
+      return message;
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }],
+      audit: ({ result }) => ({
+        action: "scheduled_message.create",
+        entity: "ScheduledMessage",
+        entityId: result.id,
+        message: `Scheduled a ${result.channel} message for ${result.sendAt.toISOString()}`,
+        metadata: { channel: result.channel, frequency: result.frequency },
+      }),
+    },
+  );
+}
+
+export async function updateScheduledMessage(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard();
+      const data = updateScheduledMessageSchema.parse(input);
+
+      const existing = await db.scheduledMessage.findFirst({
+        where: { id: data.id, organizationId },
+        select: { id: true },
+      });
+      if (!existing) throw new Error("Scheduled message not found");
+
+      const message = await db.scheduledMessage.update({
+        where: { id: data.id },
+        data: {
+          ...(data.channel ? { channel: data.channel } : {}),
+          ...(data.subject !== undefined ? { subject: data.subject?.trim() || null } : {}),
+          ...(data.body ? { body: data.body } : {}),
+          ...(data.recipient !== undefined ? { recipient: data.recipient?.trim() || null } : {}),
+          ...(data.customerId !== undefined ? { customerId: data.customerId || null } : {}),
+          ...(data.vehicleId !== undefined ? { vehicleId: data.vehicleId || null } : {}),
+          ...(data.sendAt ? { sendAt: parseLocalDateTime(data.sendAt) } : {}),
+          ...(data.frequency ? { frequency: data.frequency } : {}),
+          ...(data.endDate !== undefined
+            ? { endDate: data.endDate ? parseLocalDateTime(data.endDate) : null }
+            : {}),
+          ...(data.status ? { status: data.status } : {}),
+          // Rescheduling clears the last failure, so a fixed address doesn't
+          // keep showing the error that has already been dealt with
+          ...(data.sendAt || data.status === "scheduled" ? { errorMessage: null } : {}),
+        },
+      });
+
+      revalidatePath("/messages");
+      revalidatePath("/calendar");
+      return message;
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
+      audit: ({ result }) => ({
+        action: "scheduled_message.update",
+        entity: "ScheduledMessage",
+        entityId: result.id,
+        message: `Updated a scheduled ${result.channel} message`,
+      }),
+    },
+  );
+}
+
+/** Stops a message without losing the record of it. */
+export async function cancelScheduledMessage(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard();
+      const existing = await db.scheduledMessage.findFirst({
+        where: { id, organizationId },
+        select: { id: true },
+      });
+      if (!existing) throw new Error("Scheduled message not found");
+
+      const message = await db.scheduledMessage.update({
+        where: { id },
+        data: { status: "cancelled" },
+      });
+
+      revalidatePath("/messages");
+      revalidatePath("/calendar");
+      return message;
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
+      audit: ({ result }) => ({
+        action: "scheduled_message.cancel",
+        entity: "ScheduledMessage",
+        entityId: result.id,
+        message: "Cancelled a scheduled message",
+      }),
+    },
+  );
+}
+
+export async function deleteScheduledMessage(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard();
+      const existing = await db.scheduledMessage.findFirst({
+        where: { id, organizationId },
+        select: { id: true },
+      });
+      if (!existing) throw new Error("Scheduled message not found");
+
+      await db.scheduledMessage.delete({ where: { id } });
+
+      revalidatePath("/messages");
+      revalidatePath("/calendar");
+      return { id };
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS }],
+      audit: ({ result }) => ({
+        action: "scheduled_message.delete",
+        entity: "ScheduledMessage",
+        entityId: result.id,
+        message: "Deleted a scheduled message",
+      }),
+    },
+  );
+}
+
+/** Sends now, ahead of schedule. A repeating message keeps its next slot. */
+export async function sendScheduledMessageNow(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard();
+      const message = await db.scheduledMessage.findFirst({
+        where: { id, organizationId },
+        select: {
+          id: true,
+          channel: true,
+          subject: true,
+          body: true,
+          recipient: true,
+          organizationId: true,
+          customerId: true,
+          vehicleId: true,
+          sendAt: true,
+          frequency: true,
+          endDate: true,
+          runCount: true,
+        },
+      });
+      if (!message) throw new Error("Scheduled message not found");
+
+      const now = new Date();
+      const following = nextSendAt(message.sendAt, message.frequency, message.endDate);
+
+      try {
+        await dispatchScheduledMessage(message);
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : "Unknown error";
+        await db.scheduledMessage.update({
+          where: { id },
+          data: { lastRunAt: now, errorMessage },
+        });
+        throw error;
+      }
+
+      const updated = await db.scheduledMessage.update({
+        where: { id },
+        data: {
+          status: following ? "scheduled" : "sent",
+          sendAt: following ?? message.sendAt,
+          sentAt: now,
+          lastRunAt: now,
+          runCount: message.runCount + 1,
+          errorMessage: null,
+        },
+      });
+
+      revalidatePath("/messages");
+      revalidatePath("/calendar");
+      return updated;
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
+      audit: ({ result }) => ({
+        action: "scheduled_message.send_now",
+        entity: "ScheduledMessage",
+        entityId: result.id,
+        message: `Sent a scheduled ${result.channel} message ahead of time`,
+      }),
+    },
+  );
+}

+ 314 - 0
src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx

@@ -0,0 +1,314 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { Bell, Loader2, Mail, MessageSquare, Send } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { DateInput } from "@/components/ui/date-input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { CustomerCombobox } from "@/features/quotes/Components/CustomerCombobox";
+import {
+  createScheduledMessage,
+  updateScheduledMessage,
+} from "../Actions/scheduledMessageActions";
+import {
+  MESSAGE_FREQUENCIES,
+  type MessageChannel,
+  type MessageFrequency,
+} from "../Schema/scheduledMessageSchema";
+
+/** Roughly one SMS segment times ten; the provider splits anything longer. */
+const SMS_MAX = 1600;
+
+export const CHANNEL_ICONS = {
+  email: Mail,
+  sms: MessageSquare,
+  telegram: Send,
+  in_app: Bell,
+} as const;
+
+export interface ScheduleMessageValues {
+  id: string;
+  channel: string;
+  subject: string | null;
+  body: string;
+  recipient: string | null;
+  sendAt: Date;
+  frequency: string;
+  endDate: Date | null;
+  customer: { id: string; name: string } | null;
+}
+
+interface ScheduleMessageDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  /** Channels the workshop can actually send on right now */
+  availableChannels: MessageChannel[];
+  /** Set to edit an existing message; left out, the dialog schedules a new one */
+  message?: ScheduleMessageValues;
+  /** YYYY-MM-DD the message goes out on, e.g. the calendar day that was right-clicked */
+  defaultDate?: string;
+  defaultCustomer?: { id: string; name: string; company: string | null } | null;
+  onSaved?: () => void;
+}
+
+function toLocalDateStr(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${day}`;
+}
+
+function toTimeStr(d: Date): string {
+  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+
+export function ScheduleMessageDialog({
+  open,
+  onOpenChange,
+  availableChannels,
+  message,
+  defaultDate,
+  defaultCustomer = null,
+  onSaved,
+}: ScheduleMessageDialogProps) {
+  const t = useTranslations("scheduledMessages.dialog");
+  const tc = useTranslations("common.buttons");
+  const isEdit = !!message;
+
+  const [channel, setChannel] = useState<MessageChannel>(availableChannels[0] ?? "email");
+  const [customerId, setCustomerId] = useState("");
+  const [recipient, setRecipient] = useState("");
+  const [subject, setSubject] = useState("");
+  const [body, setBody] = useState("");
+  const [date, setDate] = useState("");
+  const [time, setTime] = useState("09:00");
+  const [frequency, setFrequency] = useState<MessageFrequency>("once");
+  const [endDate, setEndDate] = useState("");
+  const [saving, setSaving] = useState(false);
+
+  // Re-seed on every open so the day just picked wins over the last one
+  useEffect(() => {
+    if (!open) return;
+    if (message) {
+      setChannel(message.channel as MessageChannel);
+      setCustomerId(message.customer?.id ?? "");
+      setRecipient(message.recipient ?? "");
+      setSubject(message.subject ?? "");
+      setBody(message.body);
+      setDate(toLocalDateStr(message.sendAt));
+      setTime(toTimeStr(message.sendAt));
+      setFrequency(message.frequency as MessageFrequency);
+      setEndDate(message.endDate ? toLocalDateStr(message.endDate) : "");
+    } else {
+      setChannel(availableChannels[0] ?? "email");
+      setCustomerId(defaultCustomer?.id ?? "");
+      setRecipient("");
+      setSubject("");
+      setBody("");
+      setDate(defaultDate ?? toLocalDateStr(new Date()));
+      setTime("09:00");
+      setFrequency("once");
+      setEndDate("");
+    }
+  }, [open, message, defaultDate, defaultCustomer?.id, availableChannels]);
+
+  const needsRecipient = channel !== "in_app" && !customerId;
+  const recipientLabel = useMemo(() => {
+    if (channel === "sms") return t("recipientPhone");
+    if (channel === "telegram") return t("recipientChat");
+    return t("recipientEmail");
+  }, [channel, t]);
+
+  const canSave =
+    !!body.trim() &&
+    !!date &&
+    !!time &&
+    (channel !== "email" || !!subject.trim()) &&
+    (!needsRecipient || !!recipient.trim());
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canSave) return;
+    setSaving(true);
+
+    const payload = {
+      channel,
+      subject: subject.trim() || undefined,
+      body: body.trim(),
+      recipient: recipient.trim() || undefined,
+      customerId: customerId || null,
+      sendAt: `${date}T${time}`,
+      frequency,
+      endDate: frequency === "once" || !endDate ? undefined : `${endDate}T${time}`,
+    };
+
+    const result = isEdit
+      ? await updateScheduledMessage({ ...payload, id: message.id, endDate: payload.endDate ?? null })
+      : await createScheduledMessage(payload);
+
+    if (result.success) {
+      toast.success(isEdit ? t("updated") : t("scheduled"));
+      onOpenChange(false);
+      onSaved?.();
+    } else {
+      toast.error(result.error || t("saveError"));
+    }
+    setSaving(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
+        <DialogHeader>
+          <DialogTitle>{isEdit ? t("editTitle") : t("title")}</DialogTitle>
+        </DialogHeader>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label>{t("channelLabel")}</Label>
+              <Select value={channel} onValueChange={(v) => setChannel(v as MessageChannel)}>
+                <SelectTrigger className="w-full">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {availableChannels.map((c) => {
+                    const Icon = CHANNEL_ICONS[c];
+                    return (
+                      <SelectItem key={c} value={c}>
+                        <span className="flex items-center gap-2">
+                          <Icon className="h-3.5 w-3.5" />
+                          {t(`channels.${c}`)}
+                        </span>
+                      </SelectItem>
+                    );
+                  })}
+                </SelectContent>
+              </Select>
+            </div>
+
+            <div className="space-y-2">
+              <Label>{t("repeatLabel")}</Label>
+              <Select value={frequency} onValueChange={(v) => setFrequency(v as MessageFrequency)}>
+                <SelectTrigger className="w-full">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {MESSAGE_FREQUENCIES.map((f) => (
+                    <SelectItem key={f} value={f}>
+                      {t(`frequencies.${f}`)}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+          </div>
+
+          {channel !== "in_app" && (
+            <div className="space-y-2">
+              <Label>{t("customerLabel")}</Label>
+              <CustomerCombobox
+                value={customerId}
+                initialCustomer={customerId === defaultCustomer?.id ? defaultCustomer : null}
+                placeholder={t("selectCustomer")}
+                noneLabel={t("none")}
+                onChange={(id) => setCustomerId(id)}
+              />
+              <Input
+                value={recipient}
+                onChange={(e) => setRecipient(e.target.value)}
+                placeholder={customerId ? t("recipientOverride") : recipientLabel}
+              />
+              <p className="text-xs text-muted-foreground">
+                {customerId ? t("recipientHintCustomer") : t("recipientHintDirect")}
+              </p>
+            </div>
+          )}
+
+          {channel === "email" && (
+            <div className="space-y-2">
+              <Label htmlFor="scheduled-subject">{t("subjectLabel")}</Label>
+              <Input
+                id="scheduled-subject"
+                value={subject}
+                onChange={(e) => setSubject(e.target.value)}
+                placeholder={t("subjectPlaceholder")}
+                required
+              />
+            </div>
+          )}
+
+          <div className="space-y-2">
+            <Label htmlFor="scheduled-body">{t("messageLabel")}</Label>
+            <Textarea
+              id="scheduled-body"
+              value={body}
+              onChange={(e) => setBody(e.target.value)}
+              placeholder={t("messagePlaceholder")}
+              rows={5}
+              maxLength={channel === "sms" ? SMS_MAX : undefined}
+              required
+            />
+            {channel === "sms" && (
+              <p className="text-xs text-muted-foreground text-right tabular-nums">
+                {t("characters", { count: body.length, max: SMS_MAX })}
+              </p>
+            )}
+          </div>
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="scheduled-date">{t("dateLabel")}</Label>
+              <DateInput id="scheduled-date" value={date} onChange={setDate} />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="scheduled-time">{t("timeLabel")}</Label>
+              <Input
+                id="scheduled-time"
+                type="time"
+                value={time}
+                onChange={(e) => setTime(e.target.value)}
+                required
+              />
+            </div>
+          </div>
+
+          {frequency !== "once" && (
+            <div className="space-y-2">
+              <Label htmlFor="scheduled-end">{t("endDateLabel")}</Label>
+              <DateInput id="scheduled-end" value={endDate} onChange={setEndDate} />
+              <p className="text-xs text-muted-foreground">{t("endDateHint")}</p>
+            </div>
+          )}
+
+          <div className="flex justify-end gap-3 pt-2">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              {tc("cancel")}
+            </Button>
+            <Button type="submit" disabled={saving || !canSave}>
+              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {isEdit ? tc("saveChanges") : t("schedule")}
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 317 - 0
src/features/scheduled-messages/Components/ScheduledMessageList.tsx

@@ -0,0 +1,317 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import {
+  AlertTriangle,
+  CalendarClock,
+  Loader2,
+  MoreVertical,
+  Pencil,
+  Repeat,
+  Send,
+  Trash2,
+  XCircle,
+} from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useFormatDate } from "@/lib/use-format-date";
+import { cn } from "@/lib/utils";
+import {
+  cancelScheduledMessage,
+  deleteScheduledMessage,
+  sendScheduledMessageNow,
+  type ScheduledMessageListItem,
+} from "../Actions/scheduledMessageActions";
+import { CHANNEL_ICONS, ScheduleMessageDialog } from "./ScheduleMessageDialog";
+import type { MessageChannel } from "../Schema/scheduledMessageSchema";
+
+type StatusFilter = "upcoming" | "sent" | "failed" | "cancelled" | "all";
+
+const STATUS_FILTERS: StatusFilter[] = ["upcoming", "sent", "failed", "cancelled", "all"];
+
+function statusBadgeClass(status: string) {
+  switch (status) {
+    case "sent":
+      return "text-teal-600 border-teal-300";
+    case "failed":
+      return "text-red-600 border-red-300";
+    case "cancelled":
+      return "text-muted-foreground";
+    default:
+      return "text-sky-600 border-sky-300";
+  }
+}
+
+interface ScheduledMessageListProps {
+  messages: ScheduledMessageListItem[];
+  availableChannels: MessageChannel[];
+  onChanged: () => void;
+}
+
+export function ScheduledMessageList({
+  messages,
+  availableChannels,
+  onChanged,
+}: ScheduledMessageListProps) {
+  const t = useTranslations("scheduledMessages.list");
+  const td = useTranslations("scheduledMessages.dialog");
+  const tc = useTranslations("common.buttons");
+  const { formatDate, formatTime } = useFormatDate();
+
+  const [filter, setFilter] = useState<StatusFilter>("upcoming");
+  const [editing, setEditing] = useState<ScheduledMessageListItem | undefined>();
+  const [showDialog, setShowDialog] = useState(false);
+  const [deleteTarget, setDeleteTarget] = useState<ScheduledMessageListItem | null>(null);
+  const [busyId, setBusyId] = useState<string | null>(null);
+
+  const filtered = useMemo(() => {
+    if (filter === "all") return messages;
+    if (filter === "upcoming") return messages.filter((m) => m.status === "scheduled");
+    return messages.filter((m) => m.status === filter);
+  }, [messages, filter]);
+
+  const counts = useMemo(
+    () => ({
+      upcoming: messages.filter((m) => m.status === "scheduled").length,
+      sent: messages.filter((m) => m.status === "sent").length,
+      failed: messages.filter((m) => m.status === "failed").length,
+      cancelled: messages.filter((m) => m.status === "cancelled").length,
+      all: messages.length,
+    }),
+    [messages],
+  );
+
+  const handleSendNow = async (message: ScheduledMessageListItem) => {
+    setBusyId(message.id);
+    const result = await sendScheduledMessageNow(message.id);
+    if (result.success) {
+      toast.success(t("sentNow"));
+      onChanged();
+    } else {
+      toast.error(result.error || t("sendError"));
+    }
+    setBusyId(null);
+  };
+
+  const handleCancel = async (message: ScheduledMessageListItem) => {
+    setBusyId(message.id);
+    const result = await cancelScheduledMessage(message.id);
+    if (result.success) {
+      toast.success(t("cancelled"));
+      onChanged();
+    } else {
+      toast.error(result.error || t("cancelError"));
+    }
+    setBusyId(null);
+  };
+
+  const handleDelete = async () => {
+    if (!deleteTarget) return;
+    setBusyId(deleteTarget.id);
+    const result = await deleteScheduledMessage(deleteTarget.id);
+    if (result.success) {
+      toast.success(t("deleted"));
+      onChanged();
+    } else {
+      toast.error(result.error || t("deleteError"));
+    }
+    setBusyId(null);
+    setDeleteTarget(null);
+  };
+
+  return (
+    <div className="flex h-full flex-col">
+      <div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
+        {STATUS_FILTERS.map((key) => (
+          <Button
+            key={key}
+            size="sm"
+            variant={filter === key ? "default" : "outline"}
+            onClick={() => setFilter(key)}
+          >
+            {t(`filters.${key}`)}
+            {counts[key] > 0 && (
+              <Badge variant="secondary" className="ml-1.5 h-5 min-w-5 px-1 text-xs">
+                {counts[key]}
+              </Badge>
+            )}
+          </Button>
+        ))}
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-y-auto">
+        {filtered.length === 0 ? (
+          <div className="flex h-full flex-col items-center justify-center px-4 py-12 text-center">
+            <CalendarClock className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm text-muted-foreground">{t("empty")}</p>
+            <p className="mt-1 text-xs text-muted-foreground">{t("emptyHint")}</p>
+          </div>
+        ) : (
+          <ul className="divide-y">
+            {filtered.map((message) => {
+              const Icon = CHANNEL_ICONS[message.channel as MessageChannel] ?? Send;
+              const isBusy = busyId === message.id;
+              const isOpen = message.status === "scheduled";
+              return (
+                <li key={message.id} className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40">
+                  <div
+                    className={cn(
+                      "mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
+                      message.status === "failed" ? "bg-red-500/10" : "bg-sky-500/10",
+                    )}
+                  >
+                    <Icon
+                      className={cn(
+                        "h-4 w-4",
+                        message.status === "failed" ? "text-red-600" : "text-sky-600",
+                      )}
+                    />
+                  </div>
+
+                  <div className="min-w-0 flex-1">
+                    <div className="flex flex-wrap items-center gap-1.5">
+                      <span className="truncate text-sm font-medium">
+                        {message.subject?.trim() || message.body.slice(0, 60)}
+                      </span>
+                      <Badge
+                        variant="outline"
+                        className={cn("text-[10px] px-1.5 py-0", statusBadgeClass(message.status))}
+                      >
+                        {t(`status.${message.status}`)}
+                      </Badge>
+                      {message.frequency !== "once" && (
+                        <Badge variant="secondary" className="text-[10px] px-1.5 py-0">
+                          <Repeat className="mr-1 h-2.5 w-2.5" />
+                          {td(`frequencies.${message.frequency}`)}
+                        </Badge>
+                      )}
+                    </div>
+
+                    <p className="mt-0.5 truncate text-xs text-muted-foreground">
+                      {message.body}
+                    </p>
+
+                    <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
+                      <span className="font-medium text-foreground">
+                        {formatDate(message.sendAt)} · {formatTime(message.sendAt)}
+                      </span>
+                      <span>{td(`channels.${message.channel}`)}</span>
+                      <span className="truncate">
+                        {message.customer?.name || message.recipient || t("noRecipient")}
+                      </span>
+                      {message.runCount > 0 && <span>{t("sentCount", { count: message.runCount })}</span>}
+                    </div>
+
+                    {message.errorMessage && (
+                      <p className="mt-1 flex items-start gap-1 text-xs text-red-600">
+                        <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
+                        {message.errorMessage}
+                      </p>
+                    )}
+                  </div>
+
+                  <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-8 w-8 shrink-0"
+                        disabled={isBusy}
+                        aria-label={t("rowMenu")}
+                      >
+                        {isBusy ? (
+                          <Loader2 className="h-4 w-4 animate-spin" />
+                        ) : (
+                          <MoreVertical className="h-4 w-4" />
+                        )}
+                      </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent align="end">
+                      {isOpen && (
+                        <>
+                          <DropdownMenuItem
+                            onClick={() => {
+                              setEditing(message);
+                              setShowDialog(true);
+                            }}
+                          >
+                            <Pencil className="mr-2 h-4 w-4" />
+                            {tc("edit")}
+                          </DropdownMenuItem>
+                          <DropdownMenuItem onClick={() => handleSendNow(message)}>
+                            <Send className="mr-2 h-4 w-4" />
+                            {t("sendNow")}
+                          </DropdownMenuItem>
+                          <DropdownMenuItem onClick={() => handleCancel(message)}>
+                            <XCircle className="mr-2 h-4 w-4" />
+                            {t("cancel")}
+                          </DropdownMenuItem>
+                          <DropdownMenuSeparator />
+                        </>
+                      )}
+                      <DropdownMenuItem
+                        className="text-destructive"
+                        onClick={() => setDeleteTarget(message)}
+                      >
+                        <Trash2 className="mr-2 h-4 w-4" />
+                        {tc("delete")}
+                      </DropdownMenuItem>
+                    </DropdownMenuContent>
+                  </DropdownMenu>
+                </li>
+              );
+            })}
+          </ul>
+        )}
+      </div>
+
+      <ScheduleMessageDialog
+        open={showDialog}
+        onOpenChange={(open) => {
+          setShowDialog(open);
+          if (!open) setEditing(undefined);
+        }}
+        availableChannels={availableChannels}
+        message={editing}
+        onSaved={onChanged}
+      />
+
+      <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
+            <AlertDialogDescription>{t("deleteDescription")}</AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>{tc("cancel")}</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleDelete}
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+            >
+              {tc("delete")}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}

+ 27 - 0
src/features/scheduled-messages/Lib/availableChannels.ts

@@ -0,0 +1,27 @@
+import { getFeatures } from "@/lib/features";
+import { getOrgSmsProvider } from "@/lib/sms";
+import { getOrgTelegramBotToken } from "@/lib/telegram";
+import type { MessageChannel } from "../Schema/scheduledMessageSchema";
+
+/**
+ * The channels a workshop can actually schedule on right now.
+ *
+ * Email and the in-app note always work: mail falls back to the platform
+ * sender, and a notification only touches our own database. SMS and Telegram
+ * need both the plan feature and a configured provider, so offering them
+ * before that is set up would only queue messages that fail at send time.
+ */
+export async function getAvailableChannels(organizationId: string): Promise<MessageChannel[]> {
+  const features = await getFeatures(organizationId);
+
+  const [smsProvider, telegramToken] = await Promise.all([
+    features.sms ? getOrgSmsProvider(organizationId).catch(() => null) : null,
+    features.telegram ? getOrgTelegramBotToken(organizationId).catch(() => null) : null,
+  ]);
+
+  const channels: MessageChannel[] = ["email"];
+  if (smsProvider) channels.push("sms");
+  if (telegramToken) channels.push("telegram");
+  channels.push("in_app");
+  return channels;
+}

+ 193 - 0
src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts

@@ -0,0 +1,193 @@
+import { db } from "@/lib/db";
+import { notify } from "@/lib/notify";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { sendOrgSms, getOrgSmsPhoneNumber, normalizeOrgPhone } from "@/lib/sms";
+import { sendTelegramMessage } from "@/lib/telegram";
+import type { MessageFrequency } from "../Schema/scheduledMessageSchema";
+
+/** Everything a send needs, whether it comes from the cron or a manual push. */
+export type DispatchableMessage = {
+  id: string;
+  channel: string;
+  subject: string | null;
+  body: string;
+  recipient: string | null;
+  organizationId: string;
+  customerId: string | null;
+  vehicleId: string | null;
+};
+
+/** Plain text to the minimal HTML the mail senders expect. */
+function toHtml(body: string): string {
+  const escaped = body
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;");
+  return `<div style="font-family:system-ui,sans-serif;white-space:pre-wrap;line-height:1.5;">${escaped}</div>`;
+}
+
+/**
+ * Send one scheduled message through its channel.
+ *
+ * Throws on anything that stops delivery, so the caller can record the reason
+ * against the row. Outbound SMS and Telegram are logged to the same tables the
+ * manual send paths write, which is what makes them show up in the customer's
+ * conversation afterwards.
+ */
+export async function dispatchScheduledMessage(message: DispatchableMessage): Promise<void> {
+  const { organizationId } = message;
+
+  const customer = message.customerId
+    ? await db.customer.findFirst({
+        where: { id: message.customerId, organizationId },
+        select: { id: true, name: true, email: true, phone: true, telegramChatId: true },
+      })
+    : null;
+
+  switch (message.channel) {
+    case "email": {
+      const to = message.recipient?.trim() || customer?.email;
+      if (!to) throw new Error("No email address for this message");
+      const from = await getOrgFromAddress(organizationId);
+      await sendOrgMail(organizationId, {
+        from,
+        to,
+        subject: message.subject?.trim() || "",
+        html: toHtml(message.body),
+      });
+      return;
+    }
+
+    case "sms": {
+      const rawTo = message.recipient?.trim() || customer?.phone;
+      if (!rawTo) throw new Error("No phone number for this message");
+
+      const to = await normalizeOrgPhone(organizationId, rawTo);
+      if (!to) throw new Error("Phone number is not in a format we can dial");
+
+      const fromNumber = await getOrgSmsPhoneNumber(organizationId);
+      if (!fromNumber) throw new Error("SMS phone number is not configured");
+
+      // Logged before the send, so a provider failure still leaves a trace in
+      // the customer's thread rather than vanishing
+      const logged = await db.smsMessage.create({
+        data: {
+          direction: "outbound",
+          fromNumber,
+          toNumber: to,
+          body: message.body,
+          status: "queued",
+          organizationId,
+          customerId: customer?.id ?? null,
+          relatedEntityType: "ScheduledMessage",
+          relatedEntityId: message.id,
+        },
+      });
+
+      try {
+        const result = await sendOrgSms(organizationId, { to, body: message.body });
+        await db.smsMessage.update({
+          where: { id: logged.id },
+          data: { status: "sent", providerMsgId: result?.providerMsgId ?? null },
+        });
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : "Unknown error";
+        await db.smsMessage.update({
+          where: { id: logged.id },
+          data: { status: "failed", errorMessage },
+        });
+        throw error;
+      }
+      return;
+    }
+
+    case "telegram": {
+      const chatId = message.recipient?.trim() || customer?.telegramChatId;
+      if (!chatId) throw new Error("No Telegram chat linked for this message");
+
+      const logged = await db.telegramMessage.create({
+        data: {
+          direction: "outbound",
+          chatId,
+          body: message.body,
+          status: "queued",
+          organizationId,
+          customerId: customer?.id ?? null,
+          relatedEntityType: "ScheduledMessage",
+          relatedEntityId: message.id,
+        },
+      });
+
+      try {
+        const result = await sendTelegramMessage(organizationId, {
+          chatId,
+          text: message.body,
+        });
+        await db.telegramMessage.update({
+          where: { id: logged.id },
+          data: { status: "sent", telegramMessageId: String(result.messageId) },
+        });
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : "Unknown error";
+        await db.telegramMessage.update({
+          where: { id: logged.id },
+          data: { status: "failed", errorMessage },
+        });
+        throw error;
+      }
+      return;
+    }
+
+    case "in_app": {
+      // The workshop's own bell, not the customer's inbox
+      await notify({
+        type: "scheduled_message",
+        title: message.subject?.trim() || message.body.slice(0, 80),
+        message: message.body,
+        entityType: "ScheduledMessage",
+        entityId: message.id,
+        entityUrl: message.customerId
+          ? `/customers/${message.customerId}`
+          : "/messages?tab=scheduled",
+        organizationId,
+      });
+      return;
+    }
+
+    default:
+      throw new Error(`Unknown message channel: ${message.channel}`);
+  }
+}
+
+/**
+ * When a repeating message goes out next, or null when it is done.
+ * Keeps the wall-clock time of day and stops once past `endDate`.
+ */
+export function nextSendAt(
+  current: Date,
+  frequency: MessageFrequency | string,
+  endDate: Date | null,
+): Date | null {
+  const next = new Date(current);
+  switch (frequency) {
+    case "daily":
+      next.setDate(next.getDate() + 1);
+      break;
+    case "weekly":
+      next.setDate(next.getDate() + 7);
+      break;
+    case "biweekly":
+      next.setDate(next.getDate() + 14);
+      break;
+    case "monthly":
+      next.setMonth(next.getMonth() + 1);
+      break;
+    case "yearly":
+      next.setFullYear(next.getFullYear() + 1);
+      break;
+    default:
+      return null; // "once"
+  }
+  if (endDate && next > endDate) return null;
+  return next;
+}

+ 62 - 0
src/features/scheduled-messages/Schema/scheduledMessageSchema.ts

@@ -0,0 +1,62 @@
+import { z } from "zod";
+
+/** Where a scheduled message goes out. "in_app" notifies the workshop, not the customer. */
+export const MESSAGE_CHANNELS = ["email", "sms", "telegram", "in_app"] as const;
+export type MessageChannel = (typeof MESSAGE_CHANNELS)[number];
+
+/** "once" sends and stops; the rest reschedule themselves after each send. */
+export const MESSAGE_FREQUENCIES = [
+  "once",
+  "daily",
+  "weekly",
+  "biweekly",
+  "monthly",
+  "yearly",
+] as const;
+export type MessageFrequency = (typeof MESSAGE_FREQUENCIES)[number];
+
+export const MESSAGE_STATUSES = ["scheduled", "sent", "failed", "cancelled"] as const;
+export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
+
+export const createScheduledMessageSchema = z
+  .object({
+    channel: z.enum(MESSAGE_CHANNELS),
+    subject: z.string().max(200).optional(),
+    body: z.string().min(1, "Message is required"),
+    // Typed by hand when there is no customer, or to override the one on file
+    recipient: z.string().max(320).optional(),
+    customerId: z.string().nullable().optional(),
+    vehicleId: z.string().nullable().optional(),
+    // Local wall-clock the workshop picked, e.g. "2026-08-20T09:00"
+    sendAt: z.string().min(1, "Send time is required"),
+    frequency: z.enum(MESSAGE_FREQUENCIES).default("once"),
+    endDate: z.string().optional(),
+  })
+  .refine((v) => v.channel !== "email" || !!v.subject?.trim(), {
+    message: "Subject is required for email",
+    path: ["subject"],
+  })
+  // A customer carries the address; without one the workshop has to say where
+  // it goes. In-app notifications land in the workshop's own bell, so neither.
+  .refine(
+    (v) => v.channel === "in_app" || !!v.customerId || !!v.recipient?.trim(),
+    { message: "Pick a customer or enter a recipient", path: ["recipient"] },
+  );
+
+export type CreateScheduledMessageInput = z.infer<typeof createScheduledMessageSchema>;
+
+export const updateScheduledMessageSchema = z.object({
+  id: z.string().min(1),
+  channel: z.enum(MESSAGE_CHANNELS).optional(),
+  subject: z.string().max(200).optional(),
+  body: z.string().min(1).optional(),
+  recipient: z.string().max(320).optional(),
+  customerId: z.string().nullable().optional(),
+  vehicleId: z.string().nullable().optional(),
+  sendAt: z.string().optional(),
+  frequency: z.enum(MESSAGE_FREQUENCIES).optional(),
+  endDate: z.string().nullable().optional(),
+  status: z.enum(MESSAGE_STATUSES).optional(),
+});
+
+export type UpdateScheduledMessageInput = z.infer<typeof updateScheduledMessageSchema>;

+ 11 - 3
src/features/settings/Actions/deleteContent.ts

@@ -18,6 +18,7 @@ const deleteContentSchema = z.object({
   inspectionTemplates: z.boolean().default(false),
   inspectionTemplates: z.boolean().default(false),
   notifications: z.boolean().default(false),
   notifications: z.boolean().default(false),
   smsMessages: z.boolean().default(false),
   smsMessages: z.boolean().default(false),
+  scheduledMessages: z.boolean().default(false),
   customFields: z.boolean().default(false),
   customFields: z.boolean().default(false),
 });
 });
 
 
@@ -135,6 +136,12 @@ export async function deleteContent(input: unknown) {
       deleted.push("smsMessages");
       deleted.push("smsMessages");
     }
     }
 
 
+    // --- Scheduled Messages ---
+    if (selections.scheduledMessages) {
+      await db.scheduledMessage.deleteMany({ where: { organizationId } });
+      deleted.push("scheduledMessages");
+    }
+
     // --- Custom Fields ---
     // --- Custom Fields ---
     // Cascades: CustomFieldValue
     // Cascades: CustomFieldValue
     if (selections.customFields) {
     if (selections.customFields) {
@@ -178,14 +185,14 @@ export async function getContentCounts() {
       return {
       return {
         vehicles: 0, customers: 0, quotes: 0, inventory: 0,
         vehicles: 0, customers: 0, quotes: 0, inventory: 0,
         inspections: 0, technicians: 0, inspectionTemplates: 0,
         inspections: 0, technicians: 0, inspectionTemplates: 0,
-        notifications: 0, smsMessages: 0, customFields: 0,
+        notifications: 0, smsMessages: 0, scheduledMessages: 0, customFields: 0,
       };
       };
     }
     }
 
 
     const [
     const [
       vehicles, customers, quotes, inventory,
       vehicles, customers, quotes, inventory,
       inspections, technicians, inspectionTemplates,
       inspections, technicians, inspectionTemplates,
-      notifications, smsMessages, customFields,
+      notifications, smsMessages, scheduledMessages, customFields,
     ] = await Promise.all([
     ] = await Promise.all([
       db.vehicle.count({ where: { organizationId } }),
       db.vehicle.count({ where: { organizationId } }),
       db.customer.count({ where: { organizationId } }),
       db.customer.count({ where: { organizationId } }),
@@ -196,13 +203,14 @@ export async function getContentCounts() {
       db.inspectionTemplate.count({ where: { organizationId } }),
       db.inspectionTemplate.count({ where: { organizationId } }),
       db.notification.count({ where: { organizationId } }),
       db.notification.count({ where: { organizationId } }),
       db.smsMessage.count({ where: { organizationId } }),
       db.smsMessage.count({ where: { organizationId } }),
+      db.scheduledMessage.count({ where: { organizationId } }),
       db.customFieldDefinition.count({ where: { organizationId } }),
       db.customFieldDefinition.count({ where: { organizationId } }),
     ]);
     ]);
 
 
     return {
     return {
       vehicles, customers, quotes, inventory,
       vehicles, customers, quotes, inventory,
       inspections, technicians, inspectionTemplates,
       inspections, technicians, inspectionTemplates,
-      notifications, smsMessages, customFields,
+      notifications, smsMessages, scheduledMessages, customFields,
     };
     };
   });
   });
 }
 }

+ 36 - 0
src/features/sms/Actions/smsActions.ts

@@ -106,6 +106,42 @@ export async function sendSmsToCustomer(input: {
   )
   )
 }
 }
 
 
+/**
+ * Customers who can actually receive a text, for the compose picker.
+ * A customer with no phone on file is left out rather than offered and
+ * rejected at send time.
+ */
+export async function searchSmsRecipients(search?: string, limit = 30) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const term = search?.trim()
+      return db.customer.findMany({
+        where: {
+          organizationId,
+          phone: { not: null },
+          ...(term
+            ? {
+                OR: [
+                  { name: { contains: term, mode: 'insensitive' as const } },
+                  { phone: { contains: term, mode: 'insensitive' as const } },
+                  { company: { contains: term, mode: 'insensitive' as const } },
+                ],
+              }
+            : {}),
+        },
+        select: { id: true, name: true, company: true, phone: true },
+        orderBy: { name: 'asc' },
+        take: limit,
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}
+
 export async function getConversation(customerId: string, cursor?: string, limit: number = 50) {
 export async function getConversation(customerId: string, cursor?: string, limit: number = 50) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {

+ 374 - 145
src/features/sms/Components/MessagesPageClient.tsx

@@ -1,10 +1,12 @@
 "use client";
 "use client";
 
 
-import { useState, useEffect, useCallback, useTransition, useRef } from "react";
-import { useSearchParams, useRouter } from "next/navigation";
+import { useState, useEffect, useCallback, useMemo, useTransition, useRef } from "react";
+import { useSearchParams, useRouter, usePathname } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { cn } from "@/lib/utils";
 import { cn } from "@/lib/utils";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
 import {
 import {
   AlertDialog,
   AlertDialog,
   AlertDialogAction,
   AlertDialogAction,
@@ -21,11 +23,30 @@ import {
   DropdownMenuItem,
   DropdownMenuItem,
   DropdownMenuTrigger,
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 } from "@/components/ui/dropdown-menu";
-import { ArrowLeft, ChevronDown, Loader2, MessageSquare, MoreVertical, Phone, Trash2, User } from "lucide-react";
+import {
+  ArrowLeft,
+  CalendarClock,
+  Check,
+  CheckCheck,
+  ChevronDown,
+  Inbox,
+  Loader2,
+  MessageSquare,
+  MoreVertical,
+  Phone,
+  Plus,
+  Search,
+  Trash2,
+  TriangleAlert,
+} from "lucide-react";
 import { toast } from "sonner";
 import { toast } from "sonner";
 import { getConversation, getRecentSmsThreads, deleteConversation } from "../Actions/smsActions";
 import { getConversation, getRecentSmsThreads, deleteConversation } from "../Actions/smsActions";
 import { SmsConversation } from "./SmsConversation";
 import { SmsConversation } from "./SmsConversation";
 import { useNotificationStore } from "@/features/notifications/store/notificationStore";
 import { useNotificationStore } from "@/features/notifications/store/notificationStore";
+import { ScheduledMessageList } from "@/features/scheduled-messages/Components/ScheduledMessageList";
+import { ScheduleMessageDialog } from "@/features/scheduled-messages/Components/ScheduleMessageDialog";
+import { getScheduledMessages, type ScheduledMessageListItem } from "@/features/scheduled-messages/Actions/scheduledMessageActions";
+import type { MessageChannel } from "@/features/scheduled-messages/Schema/scheduledMessageSchema";
 
 
 interface SmsThread {
 interface SmsThread {
   customerId: string;
   customerId: string;
@@ -50,20 +71,61 @@ interface Message {
   toNumber: string;
   toNumber: string;
 }
 }
 
 
+type Tab = "inbox" | "scheduled";
+
 const THREADS_PAGE_SIZE = 50;
 const THREADS_PAGE_SIZE = 50;
 
 
+/** Initials for the avatar: first letters of the first two words. */
+function initials(name: string): string {
+  const parts = name.trim().split(/\s+/).filter(Boolean);
+  if (parts.length === 0) return "?";
+  return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
+}
+
+/** Stable per-name tint, so the same customer always looks the same. */
+const AVATAR_TINTS = [
+  "bg-blue-500/15 text-blue-700 dark:text-blue-300",
+  "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
+  "bg-violet-500/15 text-violet-700 dark:text-violet-300",
+  "bg-amber-500/15 text-amber-700 dark:text-amber-300",
+  "bg-rose-500/15 text-rose-700 dark:text-rose-300",
+  "bg-sky-500/15 text-sky-700 dark:text-sky-300",
+];
+
+function avatarTint(seed: string): string {
+  let hash = 0;
+  for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
+  return AVATAR_TINTS[hash % AVATAR_TINTS.length];
+}
+
 export function MessagesPageClient({
 export function MessagesPageClient({
   initialThreads,
   initialThreads,
   initialHasMore = false,
   initialHasMore = false,
+  initialScheduled = [],
+  availableChannels = [],
+  smsConfigured = true,
 }: {
 }: {
   initialThreads: SmsThread[];
   initialThreads: SmsThread[];
   initialHasMore?: boolean;
   initialHasMore?: boolean;
+  initialScheduled?: ScheduledMessageListItem[];
+  availableChannels?: MessageChannel[];
+  /** False when SMS has no provider set up: the inbox is replaced by a nudge */
+  smsConfigured?: boolean;
 }) {
 }) {
   const searchParams = useSearchParams();
   const searchParams = useSearchParams();
+  const pathname = usePathname();
   const router = useRouter();
   const router = useRouter();
   const t = useTranslations("messages.threads");
   const t = useTranslations("messages.threads");
+  const tp = useTranslations("messages.page");
+  const ts = useTranslations("scheduledMessages.list");
   const tc = useTranslations("common.buttons");
   const tc = useTranslations("common.buttons");
+
+  const tabParam = searchParams.get("tab");
+  const tab: Tab = tabParam === "scheduled" ? "scheduled" : "inbox";
+
   const [threads, setThreads] = useState<SmsThread[]>(initialThreads);
   const [threads, setThreads] = useState<SmsThread[]>(initialThreads);
+  const [scheduled, setScheduled] = useState<ScheduledMessageListItem[]>(initialScheduled);
+  const [search, setSearch] = useState("");
   const [hasMore, setHasMore] = useState(initialHasMore);
   const [hasMore, setHasMore] = useState(initialHasMore);
   const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(
   const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(
     searchParams.get("customerId"),
     searchParams.get("customerId"),
@@ -78,6 +140,19 @@ export function MessagesPageClient({
   const [isLoadingMore, startLoadMore] = useTransition();
   const [isLoadingMore, startLoadMore] = useTransition();
   const [deleteThreadTarget, setDeleteThreadTarget] = useState<SmsThread | null>(null);
   const [deleteThreadTarget, setDeleteThreadTarget] = useState<SmsThread | null>(null);
   const [isDeletingThread, setIsDeletingThread] = useState(false);
   const [isDeletingThread, setIsDeletingThread] = useState(false);
+  const [showScheduleDialog, setShowScheduleDialog] = useState(false);
+
+  /** Tabs live in the URL, so a conversation or the queue can be linked to directly. */
+  const setTab = useCallback(
+    (next: Tab) => {
+      const params = new URLSearchParams(searchParams.toString());
+      if (next === "inbox") params.delete("tab");
+      else params.set("tab", next);
+      const query = params.toString();
+      router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
+    },
+    [router, pathname, searchParams],
+  );
 
 
   const loadConversation = useCallback(async (customerId: string) => {
   const loadConversation = useCallback(async (customerId: string) => {
     setLoadingConversation(true);
     setLoadingConversation(true);
@@ -143,6 +218,11 @@ export function MessagesPageClient({
     }
     }
   }, [threads.length]);
   }, [threads.length]);
 
 
+  const refreshScheduled = useCallback(async () => {
+    const result = await getScheduledMessages();
+    if (result.success && result.data) setScheduled(result.data);
+  }, []);
+
   const handleDeleteConversation = async () => {
   const handleDeleteConversation = async () => {
     if (!deleteThreadTarget) return;
     if (!deleteThreadTarget) return;
     setIsDeletingThread(true);
     setIsDeletingThread(true);
@@ -192,168 +272,317 @@ export function MessagesPageClient({
     const diffHours = Math.floor(diffMs / 3600000);
     const diffHours = Math.floor(diffMs / 3600000);
     const diffDays = Math.floor(diffMs / 86400000);
     const diffDays = Math.floor(diffMs / 86400000);
 
 
-    if (diffMins < 1) return "now";
+    if (diffMins < 1) return t("now");
     if (diffMins < 60) return `${diffMins}m`;
     if (diffMins < 60) return `${diffMins}m`;
     if (diffHours < 24) return `${diffHours}h`;
     if (diffHours < 24) return `${diffHours}h`;
     if (diffDays < 7) return `${diffDays}d`;
     if (diffDays < 7) return `${diffDays}d`;
     return d.toLocaleDateString([], { month: "short", day: "numeric" });
     return d.toLocaleDateString([], { month: "short", day: "numeric" });
   };
   };
 
 
+  const visibleThreads = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    if (!q) return threads;
+    return threads.filter(
+      (thread) =>
+        thread.customerName.toLowerCase().includes(q) ||
+        thread.customerPhone?.toLowerCase().includes(q) ||
+        thread.lastMessage.body.toLowerCase().includes(q),
+    );
+  }, [threads, search]);
+
+  const upcomingCount = useMemo(
+    () => scheduled.filter((m) => m.status === "scheduled").length,
+    [scheduled],
+  );
+
+  /** Delivery state of the last outbound message, shown beside the preview. */
+  const deliveryIcon = (thread: SmsThread) => {
+    if (thread.lastMessage.direction !== "outbound") return null;
+    switch (thread.lastMessage.status) {
+      case "failed":
+        return <TriangleAlert className="h-3 w-3 shrink-0 text-red-500" />;
+      case "delivered":
+        return <CheckCheck className="h-3 w-3 shrink-0 text-muted-foreground" />;
+      default:
+        return <Check className="h-3 w-3 shrink-0 text-muted-foreground" />;
+    }
+  };
+
   return (
   return (
-    <div className="flex h-[calc(100vh-8rem)] rounded-lg border overflow-hidden bg-background">
-      {/* Thread list — full width on mobile, hidden when conversation open */}
-      <div className={cn(
-        "w-full sm:w-80 shrink-0 sm:border-r flex flex-col",
-        selectedCustomerId ? "hidden sm:flex" : "flex",
-      )}>
-        <div className="shrink-0 border-b px-4 py-3">
-          <h2 className="text-sm font-semibold">{t("title")}</h2>
-        </div>
-        <div className="flex-1 min-h-0 overflow-y-auto">
-          {threads.length === 0 ? (
-            <div className="flex flex-col items-center justify-center py-12 px-4">
-              <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
-              <p className="text-sm text-muted-foreground text-center">
-                {t("empty")}
-              </p>
-            </div>
-          ) : (
-            <>
-              {threads.map((thread) => {
-                const isSelected = selectedCustomerId === thread.customerId;
-                return (
-                  <button
-                    key={thread.customerId}
-                    type="button"
-                    onClick={() => handleSelectThread(thread.customerId)}
-                    className={cn(
-                      "w-full text-left px-4 py-3 border-b transition-colors hover:bg-muted/50",
-                      isSelected && "bg-muted",
-                    )}
-                  >
-                    <div className="flex items-center gap-3">
-                      <div className={cn(
-                        "flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
-                        isSelected ? "bg-primary text-primary-foreground" : "bg-muted",
-                      )}>
-                        <User className="h-4 w-4" />
-                      </div>
-                      <div className="min-w-0 flex-1">
-                        <div className="flex items-baseline justify-between gap-2">
-                          <span className="font-medium text-sm truncate">
-                            {thread.customerName}
-                          </span>
-                          <span className="text-[10px] text-muted-foreground shrink-0">
-                            {formatRelativeTime(thread.lastMessage.createdAt)}
-                          </span>
-                        </div>
-                        <p className="text-xs text-muted-foreground truncate mt-0.5">
-                          {thread.lastMessage.direction === "outbound" ? t("you") : ""}
-                          {thread.lastMessage.body}
-                        </p>
-                      </div>
-                    </div>
-                  </button>
-                );
-              })}
-              {hasMore && (
-                <div className="p-3">
-                  <Button
-                    variant="outline"
-                    size="sm"
-                    className="w-full"
-                    onClick={handleLoadMore}
-                    disabled={isLoadingMore}
-                  >
-                    {isLoadingMore ? (
-                      <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
-                    ) : (
-                      <ChevronDown className="mr-2 h-3.5 w-3.5" />
-                    )}
-                    {t("loadMore")}
-                  </Button>
-                </div>
-              )}
-            </>
-          )}
+    <div className="flex flex-col gap-3">
+      {/* Tab bar, mirrored in the URL */}
+      <div className="flex flex-wrap items-center justify-between gap-2">
+        <div className="flex items-center gap-1 rounded-lg border p-1">
+          <Button
+            size="sm"
+            variant={tab === "inbox" ? "default" : "ghost"}
+            className="h-8"
+            onClick={() => setTab("inbox")}
+          >
+            <Inbox className="mr-1.5 h-3.5 w-3.5" />
+            {tp("tabs.inbox")}
+          </Button>
+          <Button
+            size="sm"
+            variant={tab === "scheduled" ? "default" : "ghost"}
+            className="h-8"
+            onClick={() => setTab("scheduled")}
+          >
+            <CalendarClock className="mr-1.5 h-3.5 w-3.5" />
+            {tp("tabs.scheduled")}
+            {upcomingCount > 0 && (
+              <Badge variant="secondary" className="ml-1.5 h-5 min-w-5 px-1 text-xs">
+                {upcomingCount}
+              </Badge>
+            )}
+          </Button>
         </div>
         </div>
+
+        <Button size="sm" onClick={() => setShowScheduleDialog(true)}>
+          <Plus className="mr-1 h-3.5 w-3.5" />
+          {ts("scheduleMessage")}
+        </Button>
       </div>
       </div>
 
 
-      {/* Conversation panel — full width on mobile, hidden when no conversation on mobile */}
-      <div className={cn(
-        "flex-1 flex flex-col min-w-0",
-        selectedCustomerId ? "flex" : "hidden sm:flex",
-      )}>
-        {selectedCustomerId && conversation && !loadingConversation ? (
+      <div className="flex h-[calc(100vh-11rem)] overflow-hidden rounded-lg border bg-background">
+        {tab === "scheduled" ? (
+          <div className="flex-1 min-w-0">
+            <ScheduledMessageList
+              messages={scheduled}
+              availableChannels={availableChannels}
+              onChanged={refreshScheduled}
+            />
+          </div>
+        ) : !smsConfigured ? (
+          <div className="flex flex-1 flex-col items-center justify-center px-4 text-center">
+            <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm font-medium">{tp("smsNotConfiguredTitle")}</p>
+            <p className="mt-1 max-w-sm text-xs text-muted-foreground">
+              {tp("smsNotConfiguredHint")}
+            </p>
+            <Button size="sm" variant="outline" className="mt-4" asChild>
+              <a href="/settings/sms">{tp("goToSmsSettings")}</a>
+            </Button>
+          </div>
+        ) : (
           <>
           <>
-            {/* Conversation header */}
-            <div className="shrink-0 border-b px-4 py-3 flex items-center gap-3">
-              <Button
-                variant="ghost"
-                size="icon"
-                className="shrink-0 h-8 w-8 sm:hidden"
-                onClick={handleBack}
-                aria-label={t("back")}
-              >
-                <ArrowLeft className="h-4 w-4" />
-              </Button>
-              <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
-                <User className="h-4 w-4" />
+            {/* Thread list — full width on mobile, hidden when a conversation is open */}
+            <div
+              className={cn(
+                "w-full sm:w-80 shrink-0 sm:border-r flex flex-col",
+                selectedCustomerId ? "hidden sm:flex" : "flex",
+              )}
+            >
+              <div className="shrink-0 space-y-2 border-b px-3 py-2.5">
+                <div className="flex items-center justify-between">
+                  <h2 className="text-sm font-semibold">{t("title")}</h2>
+                  <span className="text-xs text-muted-foreground tabular-nums">
+                    {threads.length}
+                  </span>
+                </div>
+                <div className="relative">
+                  <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+                  <Input
+                    value={search}
+                    onChange={(e) => setSearch(e.target.value)}
+                    placeholder={t("searchPlaceholder")}
+                    className="h-8 pl-8 text-sm"
+                  />
+                </div>
               </div>
               </div>
-              <div className="min-w-0 flex-1">
-                <p className="text-sm font-semibold truncate">{conversation.customerName}</p>
-                {conversation.customerPhone && (
-                  <p className="flex items-center gap-1 text-xs text-muted-foreground">
-                    <Phone className="h-3 w-3" />
-                    {conversation.customerPhone}
-                  </p>
+
+              <div className="min-h-0 flex-1 overflow-y-auto">
+                {visibleThreads.length === 0 ? (
+                  <div className="flex flex-col items-center justify-center px-4 py-12">
+                    <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                    <p className="text-center text-sm text-muted-foreground">
+                      {search ? t("noSearchResults") : t("empty")}
+                    </p>
+                  </div>
+                ) : (
+                  <>
+                    {visibleThreads.map((thread) => {
+                      const isSelected = selectedCustomerId === thread.customerId;
+                      const isInbound = thread.lastMessage.direction === "inbound";
+                      return (
+                        <button
+                          key={thread.customerId}
+                          type="button"
+                          onClick={() => handleSelectThread(thread.customerId)}
+                          className={cn(
+                            "w-full border-b px-3 py-2.5 text-left transition-colors hover:bg-muted/50",
+                            isSelected && "bg-muted",
+                          )}
+                        >
+                          <div className="flex items-center gap-3">
+                            <div
+                              className={cn(
+                                "flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold",
+                                avatarTint(thread.customerId),
+                              )}
+                            >
+                              {initials(thread.customerName)}
+                            </div>
+                            <div className="min-w-0 flex-1">
+                              <div className="flex items-baseline justify-between gap-2">
+                                <span className="truncate text-sm font-medium">
+                                  {thread.customerName}
+                                </span>
+                                <span className="shrink-0 text-[10px] text-muted-foreground">
+                                  {formatRelativeTime(thread.lastMessage.createdAt)}
+                                </span>
+                              </div>
+                              <div className="mt-0.5 flex items-center gap-1">
+                                {deliveryIcon(thread)}
+                                <p
+                                  className={cn(
+                                    "truncate text-xs",
+                                    isInbound ? "text-foreground" : "text-muted-foreground",
+                                  )}
+                                >
+                                  {!isInbound && t("you")}
+                                  {thread.lastMessage.body}
+                                </p>
+                              </div>
+                            </div>
+                          </div>
+                        </button>
+                      );
+                    })}
+                    {hasMore && !search && (
+                      <div className="p-3">
+                        <Button
+                          variant="outline"
+                          size="sm"
+                          className="w-full"
+                          onClick={handleLoadMore}
+                          disabled={isLoadingMore}
+                        >
+                          {isLoadingMore ? (
+                            <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
+                          ) : (
+                            <ChevronDown className="mr-2 h-3.5 w-3.5" />
+                          )}
+                          {t("loadMore")}
+                        </Button>
+                      </div>
+                    )}
+                  </>
                 )}
                 )}
               </div>
               </div>
-              <DropdownMenu>
-                <DropdownMenuTrigger asChild>
-                  <Button variant="ghost" size="icon" className="shrink-0 h-8 w-8" aria-label={t("conversationMenu")}>
-                    <MoreVertical className="h-4 w-4" />
-                  </Button>
-                </DropdownMenuTrigger>
-                <DropdownMenuContent align="end">
-                  <DropdownMenuItem
-                    className="text-destructive"
-                    onClick={() => {
-                      const thread = threads.find((t) => t.customerId === selectedCustomerId);
-                      if (thread) setDeleteThreadTarget(thread);
-                    }}
-                  >
-                    <Trash2 className="mr-2 h-4 w-4" />
-                    {t("deleteConversation")}
-                  </DropdownMenuItem>
-                </DropdownMenuContent>
-              </DropdownMenu>
             </div>
             </div>
-            {/* Conversation body — fills remaining height, input pinned to bottom */}
-            <SmsConversation
-              key={selectedCustomerId}
-              customerId={selectedCustomerId}
-              customerName={conversation.customerName}
-              customerPhone={conversation.customerPhone}
-              initialMessages={conversation.messages}
-              initialNextCursor={conversation.nextCursor}
-              className="h-auto flex-1 min-h-0"
-            />
+
+            {/* Conversation panel */}
+            <div
+              className={cn(
+                "flex min-w-0 flex-1 flex-col",
+                selectedCustomerId ? "flex" : "hidden sm:flex",
+              )}
+            >
+              {selectedCustomerId && conversation && !loadingConversation ? (
+                <>
+                  <div className="flex shrink-0 items-center gap-3 border-b px-4 py-2.5">
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8 shrink-0 sm:hidden"
+                      onClick={handleBack}
+                      aria-label={t("back")}
+                    >
+                      <ArrowLeft className="h-4 w-4" />
+                    </Button>
+                    <div
+                      className={cn(
+                        "flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold",
+                        avatarTint(selectedCustomerId),
+                      )}
+                    >
+                      {initials(conversation.customerName)}
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <p className="truncate text-sm font-semibold">{conversation.customerName}</p>
+                      {conversation.customerPhone && (
+                        <p className="flex items-center gap-1 text-xs text-muted-foreground">
+                          <Phone className="h-3 w-3" />
+                          {conversation.customerPhone}
+                        </p>
+                      )}
+                    </div>
+                    <DropdownMenu>
+                      <DropdownMenuTrigger asChild>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          className="h-8 w-8 shrink-0"
+                          aria-label={t("conversationMenu")}
+                        >
+                          <MoreVertical className="h-4 w-4" />
+                        </Button>
+                      </DropdownMenuTrigger>
+                      <DropdownMenuContent align="end">
+                        <DropdownMenuItem onClick={() => setShowScheduleDialog(true)}>
+                          <CalendarClock className="mr-2 h-4 w-4" />
+                          {ts("scheduleForCustomer")}
+                        </DropdownMenuItem>
+                        <DropdownMenuItem
+                          className="text-destructive"
+                          onClick={() => {
+                            const thread = threads.find((t) => t.customerId === selectedCustomerId);
+                            if (thread) setDeleteThreadTarget(thread);
+                          }}
+                        >
+                          <Trash2 className="mr-2 h-4 w-4" />
+                          {t("deleteConversation")}
+                        </DropdownMenuItem>
+                      </DropdownMenuContent>
+                    </DropdownMenu>
+                  </div>
+
+                  <SmsConversation
+                    key={selectedCustomerId}
+                    customerId={selectedCustomerId}
+                    customerName={conversation.customerName}
+                    customerPhone={conversation.customerPhone}
+                    initialMessages={conversation.messages}
+                    initialNextCursor={conversation.nextCursor}
+                    className="h-auto min-h-0 flex-1"
+                  />
+                </>
+              ) : loadingConversation ? (
+                <div className="flex flex-1 items-center justify-center">
+                  <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+                </div>
+              ) : (
+                <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
+                  <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
+                  <p className="text-sm">{t("selectConversation")}</p>
+                </div>
+              )}
+            </div>
           </>
           </>
-        ) : loadingConversation ? (
-          <div className="flex flex-1 items-center justify-center">
-            <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
-          </div>
-        ) : (
-          <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
-            <MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/40" />
-            <p className="text-sm">{t("selectConversation")}</p>
-          </div>
         )}
         )}
       </div>
       </div>
 
 
+      <ScheduleMessageDialog
+        open={showScheduleDialog}
+        onOpenChange={setShowScheduleDialog}
+        availableChannels={availableChannels}
+        defaultCustomer={
+          selectedCustomerId && conversation
+            ? { id: selectedCustomerId, name: conversation.customerName, company: null }
+            : null
+        }
+        onSaved={() => {
+          refreshScheduled();
+          setTab("scheduled");
+        }}
+      />
+
       {/* Delete conversation confirmation */}
       {/* Delete conversation confirmation */}
-      <AlertDialog open={!!deleteThreadTarget} onOpenChange={(open) => !open && setDeleteThreadTarget(null)}>
+      <AlertDialog
+        open={!!deleteThreadTarget}
+        onOpenChange={(open) => !open && setDeleteThreadTarget(null)}
+      >
         <AlertDialogContent>
         <AlertDialogContent>
           <AlertDialogHeader>
           <AlertDialogHeader>
             <AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
             <AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>

+ 2 - 0
src/i18n/request.ts

@@ -44,6 +44,7 @@ export default getRequestConfig(async () => {
   const pdf = (await import(`../../messages/${locale}/pdf.json`)).default
   const pdf = (await import(`../../messages/${locale}/pdf.json`)).default
   const portal = (await import(`../../messages/${locale}/portal.json`)).default
   const portal = (await import(`../../messages/${locale}/portal.json`)).default
   const calendar = (await import(`../../messages/${locale}/calendar.json`)).default
   const calendar = (await import(`../../messages/${locale}/calendar.json`)).default
+  const scheduledMessages = (await import(`../../messages/${locale}/scheduledMessages.json`)).default
   const inventory = (await import(`../../messages/${locale}/inventory.json`)).default
   const inventory = (await import(`../../messages/${locale}/inventory.json`)).default
   const reports = (await import(`../../messages/${locale}/reports.json`)).default
   const reports = (await import(`../../messages/${locale}/reports.json`)).default
   const settings = (await import(`../../messages/${locale}/settings.json`)).default
   const settings = (await import(`../../messages/${locale}/settings.json`)).default
@@ -77,6 +78,7 @@ export default getRequestConfig(async () => {
       pdf,
       pdf,
       portal,
       portal,
       calendar,
       calendar,
+      scheduledMessages,
       inventory,
       inventory,
       reports,
       reports,
       settings,
       settings,

+ 2 - 0
src/instrumentation.ts

@@ -12,6 +12,7 @@ export async function register() {
       startDemoResetCron,
       startDemoResetCron,
       checkLowStock,
       checkLowStock,
       checkDueReminders,
       checkDueReminders,
+      processScheduledMessages,
     } = await import('./cronTasks')
     } = await import('./cronTasks')
     checkLicenses()
     checkLicenses()
     checkSubscriptions()
     checkSubscriptions()
@@ -24,5 +25,6 @@ export async function register() {
     cleanupWebhookDeliveries()
     cleanupWebhookDeliveries()
     checkLowStock()
     checkLowStock()
     checkDueReminders()
     checkDueReminders()
+    processScheduledMessages()
   }
   }
 }
 }

+ 110 - 0
src/lib/cron/scheduled-messages.ts

@@ -0,0 +1,110 @@
+import { CronJob } from "cron";
+import { db } from "@/lib/db";
+import {
+  dispatchScheduledMessage,
+  nextSendAt,
+} from "@/features/scheduled-messages/Lib/dispatchScheduledMessage";
+
+const LOG_PREFIX = "[scheduled-messages]";
+
+/** Nothing older than this is sent on a late start; it is marked failed instead. */
+const MAX_LATENESS_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Send everything whose time has come.
+ *
+ * Each message is isolated: one broken address must not hold up the rest of
+ * the queue. A repeating message rolls forward to its next slot whether the
+ * send worked or not, so a single failure doesn't stop the series.
+ */
+export async function processDueMessages(now = new Date()): Promise<number> {
+  const due = await db.scheduledMessage.findMany({
+    where: { status: "scheduled", sendAt: { lte: now } },
+    select: {
+      id: true,
+      channel: true,
+      subject: true,
+      body: true,
+      recipient: true,
+      organizationId: true,
+      customerId: true,
+      vehicleId: true,
+      sendAt: true,
+      frequency: true,
+      endDate: true,
+      runCount: true,
+    },
+    orderBy: { sendAt: "asc" },
+    take: 200,
+  });
+
+  let sent = 0;
+
+  for (const message of due) {
+    const isStale = now.getTime() - message.sendAt.getTime() > MAX_LATENESS_MS;
+    const following = nextSendAt(message.sendAt, message.frequency, message.endDate);
+
+    if (isStale) {
+      // A day late is no longer the message the workshop meant to send, so it
+      // is recorded as missed rather than delivered out of context.
+      await db.scheduledMessage.update({
+        where: { id: message.id },
+        data: {
+          status: following ? "scheduled" : "failed",
+          sendAt: following ?? message.sendAt,
+          lastRunAt: now,
+          errorMessage: "Missed its send window and was skipped",
+        },
+      });
+      console.warn(`${LOG_PREFIX} skipped stale message ${message.id}`);
+      continue;
+    }
+
+    try {
+      await dispatchScheduledMessage(message);
+      await db.scheduledMessage.update({
+        where: { id: message.id },
+        data: {
+          status: following ? "scheduled" : "sent",
+          sendAt: following ?? message.sendAt,
+          sentAt: now,
+          lastRunAt: now,
+          runCount: message.runCount + 1,
+          errorMessage: null,
+        },
+      });
+      sent++;
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : "Unknown error";
+      await db.scheduledMessage.update({
+        where: { id: message.id },
+        data: {
+          status: following ? "scheduled" : "failed",
+          sendAt: following ?? message.sendAt,
+          lastRunAt: now,
+          runCount: message.runCount + 1,
+          errorMessage,
+        },
+      });
+      console.error(`${LOG_PREFIX} send failed for ${message.id}:`, errorMessage);
+    }
+  }
+
+  return sent;
+}
+
+/** Minute-by-minute scan, so a message goes out on the minute it was set for. */
+export function processScheduledMessages() {
+  const job = new CronJob("* * * * *", async () => {
+    try {
+      const sent = await processDueMessages();
+      if (sent > 0) {
+        console.warn(`${LOG_PREFIX} sent ${sent} scheduled message(s)`);
+      }
+    } catch (error) {
+      console.error(`${LOG_PREFIX} scan failed:`, error);
+    }
+  });
+  job.start();
+  console.warn(`${LOG_PREFIX} Scheduled-message processor started (every minute)`);
+}