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

Integrations platform with Google Calendar, Microsoft 365 and Zoom (#300)

* Split the Prisma schema into one file per domain

prisma/schema.prisma had passed 2,200 lines. It is now prisma/schema/ with
eighteen files grouped by domain, using Prisma's multi-file schema support.
The generated client is byte-identical; tests that read the schema go
through a small helper that joins the files.

Claude-Session: https://claude.ai/code/session_01GMVLMKzNAyR4pAyDYaenXD

* Integrations platform with Google Calendar and Microsoft 365 connectors

A first-party connector platform under src/features/integrations: per-
organisation connections with credentials sealed by AES-256-GCM, one OAuth
router for every vendor (platform-owned or workshop-owned apps), an HTTP
client that refreshes tokens under a lock and retries, a job queue run by
the cron with backoff and dead-lettering, per-connection logs, and a
catalog plus a settings page rendered from each connector's manifest.
App events reach connectors through the audit logger, next to webhooks.

Connectors live in src/integrations/<id> as a data manifest and a lazily
loaded server module. The first two, Google Calendar and Microsoft 365,
push scheduled work orders as events, pull the calendar's busy time onto
the workshop calendar, and attach Google Meet or Teams links shown on the
work order. Twelve locales, docs pages, and a developer guide.

Claude-Session: https://claude.ai/code/session_01GMVLMKzNAyR4pAyDYaenXD

* Pass integration secrets through the prod and staging deploys

Claude-Session: https://claude.ai/code/session_01GMVLMKzNAyR4pAyDYaenXD

* Add a Zoom connector

A Zoom meeting per scheduled work order, kept in step with its start time
and removed on cancellation, with the join link on the work order. Zoom
puts client credentials in a Basic header on token requests, so the OAuth
layer gained a tokenAuth option. Strings in twelve locales; secrets wired
through the deploy workflows.

Claude-Session: https://claude.ai/code/session_01GMVLMKzNAyR4pAyDYaenXD

* Make video calls explicit, split the catalog, and fix the connect flow

- A Zoom meeting is added from the work order's new Video call panel
  (join, copy, remove); the automatic option is off by default and the
  panel says the customer only gets the link when it is sent
- The integrations page lists connected services separately from the
  ones available to add
- Entering the workshop's own client id and secret and pressing Connect
  saves and starts the handshake in one step
- The OAuth redirect URI is computed on the server, which also fixes
  the hydration mismatch on the connection page
- Settings sidebar category renamed to Apps & API so the item under it
  is no longer named the same
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
c7881004c9
100 измененных файлов с 6345 добавлено и 2351 удалено
  1. 13 0
      .env.example
  2. 15 1
      .github/workflows/deploy-prod.yml
  3. 15 1
      .github/workflows/deploy-staging.yml
  4. 6 2
      messages/de/audit.json
  5. 4 2
      messages/de/calendar.json
  6. 160 0
      messages/de/integrations.json
  7. 20 1
      messages/de/service.json
  8. 5 1
      messages/de/settings.json
  9. 6 2
      messages/en/audit.json
  10. 4 2
      messages/en/calendar.json
  11. 160 0
      messages/en/integrations.json
  12. 20 1
      messages/en/service.json
  13. 5 1
      messages/en/settings.json
  14. 6 2
      messages/es/audit.json
  15. 4 2
      messages/es/calendar.json
  16. 160 0
      messages/es/integrations.json
  17. 20 1
      messages/es/service.json
  18. 5 1
      messages/es/settings.json
  19. 6 2
      messages/fr/audit.json
  20. 4 2
      messages/fr/calendar.json
  21. 160 0
      messages/fr/integrations.json
  22. 20 1
      messages/fr/service.json
  23. 5 1
      messages/fr/settings.json
  24. 6 2
      messages/it/audit.json
  25. 4 2
      messages/it/calendar.json
  26. 160 0
      messages/it/integrations.json
  27. 20 1
      messages/it/service.json
  28. 5 1
      messages/it/settings.json
  29. 6 2
      messages/lt/audit.json
  30. 4 2
      messages/lt/calendar.json
  31. 160 0
      messages/lt/integrations.json
  32. 20 1
      messages/lt/service.json
  33. 5 1
      messages/lt/settings.json
  34. 6 2
      messages/nb/audit.json
  35. 4 2
      messages/nb/calendar.json
  36. 160 0
      messages/nb/integrations.json
  37. 20 1
      messages/nb/service.json
  38. 5 1
      messages/nb/settings.json
  39. 6 2
      messages/nl/audit.json
  40. 4 2
      messages/nl/calendar.json
  41. 160 0
      messages/nl/integrations.json
  42. 20 1
      messages/nl/service.json
  43. 5 1
      messages/nl/settings.json
  44. 6 2
      messages/pl/audit.json
  45. 4 2
      messages/pl/calendar.json
  46. 160 0
      messages/pl/integrations.json
  47. 20 1
      messages/pl/service.json
  48. 5 1
      messages/pl/settings.json
  49. 6 2
      messages/pt-BR/audit.json
  50. 4 2
      messages/pt-BR/calendar.json
  51. 160 0
      messages/pt-BR/integrations.json
  52. 20 1
      messages/pt-BR/service.json
  53. 5 1
      messages/pt-BR/settings.json
  54. 6 2
      messages/ru/audit.json
  55. 4 2
      messages/ru/calendar.json
  56. 160 0
      messages/ru/integrations.json
  57. 20 1
      messages/ru/service.json
  58. 5 1
      messages/ru/settings.json
  59. 6 2
      messages/tr/audit.json
  60. 4 2
      messages/tr/calendar.json
  61. 160 0
      messages/tr/integrations.json
  62. 20 1
      messages/tr/service.json
  63. 5 1
      messages/tr/settings.json
  64. 1 1
      prisma.config.ts
  65. 150 0
      prisma/migrations/20260902140000_integrations_platform/migration.sql
  66. 0 2273
      prisma/schema.prisma
  67. 41 0
      prisma/schema/ai.prisma
  68. 22 0
      prisma/schema/audit.prisma
  69. 131 0
      prisma/schema/auth.prisma
  70. 8 0
      prisma/schema/base.prisma
  71. 36 0
      prisma/schema/billing.prisma
  72. 40 0
      prisma/schema/custom-fields.prisma
  73. 116 0
      prisma/schema/customers.prisma
  74. 194 0
      prisma/schema/inspections.prisma
  75. 130 0
      prisma/schema/integrations.prisma
  76. 159 0
      prisma/schema/inventory.prisma
  77. 155 0
      prisma/schema/messaging.prisma
  78. 155 0
      prisma/schema/organization.prisma
  79. 124 0
      prisma/schema/quotes.prisma
  80. 57 0
      prisma/schema/reports.prisma
  81. 274 0
      prisma/schema/service-records.prisma
  82. 229 0
      prisma/schema/technicians.prisma
  83. 302 0
      prisma/schema/tire-hotel.prisma
  84. 150 0
      prisma/schema/vehicles.prisma
  85. 50 0
      prisma/schema/webhooks.prisma
  86. 1 0
      public/images/integrations/google-calendar.svg
  87. 1 0
      public/images/integrations/microsoft-365.svg
  88. 1 0
      public/images/integrations/zoom.svg
  89. 2 1
      src/__tests__/features/backup/part-restore-fields.test.ts
  90. 100 0
      src/__tests__/features/integrations/calendar-sync.test.ts
  91. 155 0
      src/__tests__/features/integrations/oauth.test.ts
  92. 109 0
      src/__tests__/features/integrations/registry.test.ts
  93. 73 0
      src/__tests__/features/integrations/vault.test.ts
  94. 2 1
      src/__tests__/features/tire-hotel/backup-coverage.test.ts
  95. 2 1
      src/__tests__/lib/backup-manifest.test.ts
  96. 16 0
      src/__tests__/stubs/prisma-schema.ts
  97. 675 0
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  98. 47 0
      src/app/(authenticated)/settings/integrations/[connector]/page.tsx
  99. 224 0
      src/app/(authenticated)/settings/integrations/integrations-catalog.tsx
  100. 30 0
      src/app/(authenticated)/settings/integrations/page.tsx

+ 13 - 0
.env.example

@@ -19,3 +19,16 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
 # Without the origin lock it is worse than useless, because cf-connecting-ip is
 # then just a header anyone reaching the box directly can write for themselves.
 TRUST_CF_CONNECTING_IP=false
+
+# Integrations (Settings → Integrations)
+# Key that seals third-party tokens at rest (generate with: openssl rand -hex 32).
+# Without it the key is derived from BETTER_AUTH_SECRET, with a warning at startup.
+INTEGRATIONS_ENCRYPTION_KEY=""
+# OAuth apps the platform registers with each provider. Leave empty on a
+# self-hosted install to have each workshop enter its own app credentials.
+GOOGLE_INTEGRATION_CLIENT_ID=""
+GOOGLE_INTEGRATION_CLIENT_SECRET=""
+MICROSOFT_INTEGRATION_CLIENT_ID=""
+MICROSOFT_INTEGRATION_CLIENT_SECRET=""
+ZOOM_INTEGRATION_CLIENT_ID=""
+ZOOM_INTEGRATION_CLIENT_SECRET=""

+ 15 - 1
.github/workflows/deploy-prod.yml

@@ -48,11 +48,18 @@ jobs:
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
+          INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
+          GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
+          GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
+          MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_ID }}
+          MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
+          ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_ID }}
+          ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_SECRET }}
         run: |
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -83,6 +90,13 @@ jobs:
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
+                INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
+                GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
+                MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
+                MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
+                ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}
+                ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 15 - 1
.github/workflows/deploy-staging.yml

@@ -32,11 +32,18 @@ jobs:
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
+          INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
+          GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_ID }}
+          GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_SECRET }}
+          MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_MICROSOFT_INTEGRATION_CLIENT_ID }}
+          MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
+          ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_ZOOM_INTEGRATION_CLIENT_ID }}
+          ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_ZOOM_INTEGRATION_CLIENT_SECRET }}
         run: |
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -66,6 +73,13 @@ jobs:
                 STRIPE_ENTERPRISE_PRICE_ID: ${STRIPE_ENTERPRISE_PRICE_ID}
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
+                INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
+                GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
+                MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
+                MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
+                ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}
+                ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 6 - 2
messages/de/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "WhatsApp-Nummer registriert",
     "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben",
     "team_createDefaultRoles": "Standardrollen angelegt",
-    "settings_setCustomFieldPlacement": "Position eines benutzerdefinierten Felds geändert"
+    "settings_setCustomFieldPlacement": "Position eines benutzerdefinierten Felds geändert",
+    "integration_connect": "Integration verbunden",
+    "integration_disconnect": "Integration getrennt"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -275,6 +277,8 @@
     "work_bay_create": "Arbeitsplatz „{name}“ angelegt",
     "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
     "work_bay_delete": "Arbeitsplatz {id} gelöscht",
-    "settings_setCustomFieldPlacement": "Hat geändert, wo ein benutzerdefiniertes Feld gedruckt wird"
+    "settings_setCustomFieldPlacement": "Hat geändert, wo ein benutzerdefiniertes Feld gedruckt wird",
+    "integration_connect": "{name} verbunden",
+    "integration_disconnect": "{name} getrennt"
   }
 }

+ 4 - 2
messages/de/calendar.json

@@ -34,7 +34,8 @@
     "services": "Aufträge",
     "reminders": "Erinnerungen",
     "quotes": "Angebote",
-    "messages": "Nachrichten"
+    "messages": "Nachrichten",
+    "external": "Verbundene Kalender"
   },
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Überfällig",
       "upcoming": "Bevorstehend",
       "failed": "Fehlgeschlagen",
-      "queued": "In Warteschlange"
+      "queued": "In Warteschlange",
+      "busy": "Belegt"
     },
     "type": {
       "service": "Service",

+ 160 - 0
messages/de/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integrationen",
+    "description": "Verbinden Sie die Dienste, die Ihre Werkstatt bereits nutzt. Jede Integration wird einmal eingerichtet und läuft im Hintergrund.",
+    "all": "Alle",
+    "empty": "In dieser Kategorie gibt es noch keine Integrationen.",
+    "notConnected": "Nicht verbunden",
+    "connect": "Verbinden",
+    "manage": "Verwalten",
+    "connectedTitle": "Verbunden",
+    "connectedDescription": "Dienste, die diese Werkstatt eingerichtet hat. Öffnen Sie einen, um den Status zu prüfen, Einstellungen zu ändern oder die Verbindung zu trennen.",
+    "noneConnected": "Noch nichts verbunden. Wählen Sie unten einen Dienst, um zu beginnen.",
+    "availableTitle": "Verfügbar",
+    "availableDescription": "Dienste, die Sie hinzufügen können. Jeder wird einmal eingerichtet und läuft im Hintergrund.",
+    "allConnected": "Alle verfügbaren Dienste sind verbunden."
+  },
+  "categories": {
+    "calendar": "Kalender",
+    "conferencing": "Videoanrufe",
+    "accounting": "Buchhaltung",
+    "messaging": "Nachrichten",
+    "payments": "Zahlungen",
+    "automation": "Automatisierung",
+    "storage": "Dateien",
+    "registry": "Fahrzeugregister",
+    "other": "Sonstiges"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Aufträge in den Kalender",
+      "pull": "Belegte Zeiten aus dem Kalender",
+      "conference": "Links für Videoanrufe"
+    }
+  },
+  "statuses": {
+    "active": "Verbunden",
+    "error": "Aufmerksamkeit erforderlich",
+    "pending": "Einrichtung läuft",
+    "disconnected": "Getrennt"
+  },
+  "connection": {
+    "back": "Alle Integrationen",
+    "account": "Verbundenes Konto",
+    "lastHealth": "Zuletzt geprüft",
+    "lastSync": "Letzte Synchronisierung",
+    "lastError": "Der letzte Durchlauf ist fehlgeschlagen",
+    "connected": "{name} ist verbunden.",
+    "connectedShort": "Verbunden",
+    "connectWith": "{name} verbinden",
+    "connect": "Verbinden",
+    "reconnect": "Erneut verbinden",
+    "disconnect": "Trennen",
+    "disconnectTitle": "{name} trennen?",
+    "disconnectDescription": "Torqvoice vergisst das Konto und beendet die Synchronisierung. Bereits im Kalender erstellte Termine bleiben erhalten.",
+    "disconnected": "Getrennt",
+    "test": "Verbindung testen",
+    "testPassed": "Verbindung funktioniert",
+    "syncNow": "Jetzt synchronisieren",
+    "syncQueued": "Synchronisierung eingereiht; sie läuft innerhalb einer Minute",
+    "backfill": "Geplante Aufträge übertragen",
+    "backfillQueued": "Die geplanten Aufträge ab der letzten Woche wurden eingereiht",
+    "retryQueued": "Job erneut eingereiht",
+    "settingsTitle": "Einstellungen",
+    "settingsDescription": "Was synchronisiert wird und wohin. Änderungen gelten ab der nächsten Synchronisierung.",
+    "settingsSaved": "Einstellungen gespeichert",
+    "save": "Speichern",
+    "loading": "Wird geladen...",
+    "choose": "Auswählen...",
+    "planLocked": "Integrationen sind in Ihrem Tarif nicht enthalten.",
+    "ownAppTitle": "Diese Installation verwendet Ihre eigene App beim Anbieter",
+    "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:"
+  },
+  "activity": {
+    "title": "Aktivität",
+    "description": "Aktuelle Jobs und Protokollzeilen dieser Verbindung.",
+    "retry": "Erneut versuchen",
+    "jobStatus": {
+      "queued": "Eingereiht",
+      "running": "Läuft",
+      "done": "Erledigt",
+      "failed": "Fehlgeschlagen",
+      "dead": "Aufgegeben"
+    }
+  },
+  "errors": {
+    "generic": "Etwas ist schiefgelaufen",
+    "demo": "In der Demo können keine Integrationen verbunden werden.",
+    "forbidden": "Nur ein Administrator kann Integrationen verbinden.",
+    "unknown": "Unbekannte Integration.",
+    "plan": "Diese Integration ist in Ihrem Tarif nicht enthalten.",
+    "no-client": "Für diesen Anbieter sind keine App-Zugangsdaten konfiguriert.",
+    "denied": "Sie haben die Anmeldung beim Anbieter abgebrochen.",
+    "vendor": "Der Anbieter hat einen Fehler zurückgegeben. Versuchen Sie es erneut.",
+    "invalid": "Die Antwort des Anbieters war unvollständig. Versuchen Sie es erneut.",
+    "state": "Diese Anmeldung ist abgelaufen oder gehört zu einer anderen Sitzung. Beginnen Sie erneut.",
+    "exchange": "Der Anbieter hat die Anmeldung abgelehnt. Prüfen Sie die App-Zugangsdaten und versuchen Sie es erneut.",
+    "identify": "Verbunden, aber das Konto konnte nicht gelesen werden. Versuchen Sie, die Verbindung neu herzustellen."
+  },
+  "meeting": {
+    "join": "Videoanruf beitreten",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Aus verbundenen Kalendern",
+    "busy": "Belegt"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Geplante Aufträge in einen Google Calendar eintragen, einen Google Meet-Link hinzufügen und die belegten Zeiten des Kalenders in Torqvoice sehen.",
+      "tenantHelp": "Erstellen Sie einen OAuth-Client in der Google Cloud Console (APIs & Dienste → Anmeldedaten), aktivieren Sie die Google Calendar API, fügen Sie die untenstehende Redirect-URI hinzu und tragen Sie Client-ID und Client-Secret hier ein.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "calendarId": "Kalender",
+        "pushEnabled": "Geplante Aufträge in diesen Kalender eintragen",
+        "includeCustomer": "Name und Telefonnummer des Kunden in den Termin aufnehmen",
+        "addConference": "Jedem Termin einen Google Meet-Link hinzufügen",
+        "addConferenceHelp": "Der Link wird im Auftrag angezeigt, damit Sie ihn mit dem Kunden teilen können.",
+        "pullEnabled": "Belegte Zeiten dieses Kalenders im Torqvoice-Kalender anzeigen",
+        "pullEnabledHelp": "Nur lesend. Wird alle 15 Minuten aktualisiert, eine Woche zurück und zwei Monate voraus."
+      }
+    },
+    "microsoft-365": {
+      "description": "Geplante Aufträge in einen Outlook-Kalender eintragen, einen Microsoft Teams-Link hinzufügen und die belegten Zeiten des Kalenders in Torqvoice sehen.",
+      "tenantHelp": "Registrieren Sie eine Anwendung in Microsoft Entra (App-Registrierungen), erlauben Sie persönliche und Geschäftskonten, fügen Sie die untenstehende Redirect-URI als Web-Plattform hinzu, erstellen Sie ein Client-Secret und tragen Sie beides hier ein.",
+      "fields": {
+        "clientId": "Anwendungs-ID (Client-ID)",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "calendarId": "Kalender",
+        "pushEnabled": "Geplante Aufträge in diesen Kalender eintragen",
+        "includeCustomer": "Name und Telefonnummer des Kunden in den Termin aufnehmen",
+        "addConference": "Jedem Termin einen Microsoft Teams-Link hinzufügen",
+        "addConferenceHelp": "Der Link wird im Auftrag angezeigt, damit Sie ihn mit dem Kunden teilen können.",
+        "pullEnabled": "Belegte Zeiten dieses Kalenders im Torqvoice-Kalender anzeigen",
+        "pullEnabledHelp": "Nur lesend. Wird alle 15 Minuten aktualisiert, eine Woche zurück und zwei Monate voraus."
+      }
+    },
+    "zoom": {
+      "description": "Fügen Sie einem geplanten Auftrag ein Zoom-Meeting hinzu und behalten Sie den Beitrittslink am Auftrag, für Ferndiagnosen oder um Kunden durch Befunde zu führen.",
+      "tenantHelp": "Erstellen Sie im Zoom App Marketplace eine General-App (user-managed), tragen Sie die Redirect-URI unten ein, fügen Sie die Scopes meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting und user:read:user hinzu und tragen Sie Client-ID und Client-Secret hier ein.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "autoCreate": "Für jeden geplanten Auftrag automatisch ein Zoom-Meeting erstellen",
+        "autoCreateHelp": "Aus: Sie fügen einem Auftrag den Videoanruf selbst hinzu, auf seiner Seite. An: Jeder Auftrag mit Startzeit bekommt ein Meeting, das bei Terminverschiebung mitwandert und bei Stornierung entfernt wird. In beiden Fällen erhält der Kunde den Link nur, wenn Sie ihn senden.",
+        "includeCustomer": "Kundennamen in das Meeting-Thema aufnehmen",
+        "joinBeforeHost": "Kunden vor Ihnen beitreten lassen",
+        "waitingRoom": "Warteraum verwenden"
+      }
+    }
+  }
+}

+ 20 - 1
messages/de/service.json

@@ -34,7 +34,8 @@
     "delete": "Löschen",
     "unpaid": "Unbezahlt",
     "unsavedChanges": "Nicht gespeicherte Änderungen",
-    "saved": "Gespeichert"
+    "saved": "Gespeichert",
+    "joinCall": "Videoanruf beitreten"
   },
   "basicInfo": {
     "title": "Grundlegende Informationen",
@@ -410,5 +411,23 @@
     "remove": "Dieses Anliegen entfernen",
     "notAnswered": "Dazu wurde noch nichts festgestellt",
     "answered": "{count} Feststellung(en) dazu"
+  },
+  "videoCall": {
+    "title": "Videoanruf",
+    "none": "Kein Videoanruf für diesen Auftrag.",
+    "needsSchedule": "Legen Sie zuerst eine Startzeit fest. Das Meeting folgt dem Termin.",
+    "add": "{provider}-Meeting hinzufügen",
+    "addedHere": "von diesem Auftrag hinzugefügt",
+    "fromCalendar": "aus dem Kalendereintrag",
+    "join": "Beitreten",
+    "copy": "Link kopieren",
+    "copied": "Link kopiert",
+    "remove": "Entfernen",
+    "removeTitle": "Videoanruf entfernen?",
+    "removeDescription": "Das Meeting wird beim Anbieter gelöscht. Wer den Link bereits hat, kann nicht mehr beitreten.",
+    "created": "Meeting erstellt. Der Link ist am Auftrag.",
+    "removed": "Meeting entfernt",
+    "failed": "Etwas ist schiefgelaufen",
+    "shareHint": "Der Kunde erhält diesen Link nur, wenn Sie ihn senden. Kopieren Sie ihn in eine Nachricht oder fügen Sie ihn einer Benachrichtigung hinzu."
   }
 }

+ 5 - 1
messages/de/settings.json

@@ -7,7 +7,7 @@
       "billing": "Abrechnung & Dokumente",
       "communications": "Kommunikation",
       "workshop": "Werkstatt",
-      "integrations": "Integrationen",
+      "integrations": "Apps & API",
       "system": "System"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Berichtsplanung",
         "description": "Automatischer Berichtsversand"
       },
+      "integrations": {
+        "title": "Integrationen",
+        "description": "Kalender, Videoanrufe und verbundene Dienste"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Echtzeit-Ereignisbenachrichtigungen"

+ 6 - 2
messages/en/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Registered a WhatsApp number",
     "team_giveTechnicianTheApp": "Gave a board-only technician an account",
     "team_createDefaultRoles": "Created the standard roles",
-    "settings_setCustomFieldPlacement": "Updated Custom Field Placement"
+    "settings_setCustomFieldPlacement": "Updated Custom Field Placement",
+    "integration_connect": "Connected Integration",
+    "integration_disconnect": "Disconnected Integration"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -275,6 +277,8 @@
     "work_bay_create": "Created work bay \"{name}\"",
     "work_bay_update": "Updated work bay \"{name}\"",
     "work_bay_delete": "Deleted work bay {id}",
-    "settings_setCustomFieldPlacement": "Changed where a custom field prints"
+    "settings_setCustomFieldPlacement": "Changed where a custom field prints",
+    "integration_connect": "Connected {name}",
+    "integration_disconnect": "Disconnected {name}"
   }
 }

+ 4 - 2
messages/en/calendar.json

@@ -34,7 +34,8 @@
     "services": "Services",
     "reminders": "Reminders",
     "quotes": "Quotes",
-    "messages": "Messages"
+    "messages": "Messages",
+    "external": "Connected calendars"
   },
   "selectVehicle": "Select Vehicle for Work Order",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Overdue",
       "upcoming": "Upcoming",
       "failed": "Failed",
-      "queued": "Queued"
+      "queued": "Queued",
+      "busy": "Busy"
     },
     "type": {
       "service": "Service",

+ 160 - 0
messages/en/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integrations",
+    "description": "Connect the services your workshop already uses. Each integration is set up once and runs in the background.",
+    "all": "All",
+    "empty": "No integrations in this category yet.",
+    "notConnected": "Not connected",
+    "connect": "Connect",
+    "manage": "Manage",
+    "connectedTitle": "Connected",
+    "connectedDescription": "Services this workshop has set up. Open one to check its status, change settings or disconnect.",
+    "noneConnected": "Nothing connected yet. Pick a service below to get started.",
+    "availableTitle": "Available",
+    "availableDescription": "Services you can add. Each one is set up once and runs in the background.",
+    "allConnected": "Every available service is connected."
+  },
+  "categories": {
+    "calendar": "Calendar",
+    "conferencing": "Video calls",
+    "accounting": "Accounting",
+    "messaging": "Messaging",
+    "payments": "Payments",
+    "automation": "Automation",
+    "storage": "Files",
+    "registry": "Vehicle registry",
+    "other": "Other"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Work orders to calendar",
+      "pull": "Busy time from calendar",
+      "conference": "Video call links"
+    }
+  },
+  "statuses": {
+    "active": "Connected",
+    "error": "Needs attention",
+    "pending": "Setup in progress",
+    "disconnected": "Disconnected"
+  },
+  "connection": {
+    "back": "All integrations",
+    "account": "Connected account",
+    "lastHealth": "Last checked",
+    "lastSync": "Last sync",
+    "lastError": "The last run failed",
+    "connected": "{name} is connected.",
+    "connectedShort": "Connected",
+    "connectWith": "Connect {name}",
+    "connect": "Connect",
+    "reconnect": "Reconnect",
+    "disconnect": "Disconnect",
+    "disconnectTitle": "Disconnect {name}?",
+    "disconnectDescription": "Torqvoice forgets the account and stops syncing. Events already created in the calendar stay there.",
+    "disconnected": "Disconnected",
+    "test": "Test connection",
+    "testPassed": "Connection works",
+    "syncNow": "Sync now",
+    "syncQueued": "Sync queued; it runs within a minute",
+    "backfill": "Push scheduled work orders",
+    "backfillQueued": "Queued the scheduled work orders from the last week onwards",
+    "retryQueued": "Job queued again",
+    "settingsTitle": "Settings",
+    "settingsDescription": "What to sync and where. Changes apply to the next sync.",
+    "settingsSaved": "Settings saved",
+    "save": "Save",
+    "loading": "Loading...",
+    "choose": "Choose...",
+    "planLocked": "Integrations are not included in your plan.",
+    "ownAppTitle": "This install uses your own app with the provider",
+    "redirectUri": "Redirect URI to register with the provider:"
+  },
+  "activity": {
+    "title": "Activity",
+    "description": "Recent jobs and log lines for this connection.",
+    "retry": "Retry",
+    "jobStatus": {
+      "queued": "Queued",
+      "running": "Running",
+      "done": "Done",
+      "failed": "Failed",
+      "dead": "Gave up"
+    }
+  },
+  "errors": {
+    "generic": "Something went wrong",
+    "demo": "Integrations cannot be connected on the demo.",
+    "forbidden": "Only an administrator can connect integrations.",
+    "unknown": "Unknown integration.",
+    "plan": "This integration is not included in your plan.",
+    "no-client": "No app credentials are configured for this provider.",
+    "denied": "You cancelled the sign-in with the provider.",
+    "vendor": "The provider returned an error. Try again.",
+    "invalid": "The provider's response was incomplete. Try again.",
+    "state": "This sign-in has expired or belongs to another session. Start again.",
+    "exchange": "The provider refused the sign-in. Check the app credentials and try again.",
+    "identify": "Connected, but the account could not be read. Try reconnecting."
+  },
+  "meeting": {
+    "join": "Join video call",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "From connected calendars",
+    "busy": "Busy"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Put scheduled work orders on a Google Calendar, add a Google Meet link, and see the calendar's busy time in Torqvoice.",
+      "tenantHelp": "Create an OAuth client in Google Cloud Console (APIs & Services → Credentials), enable the Google Calendar API, add the redirect URI below, and paste the client id and secret here.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "calendarId": "Calendar",
+        "pushEnabled": "Add scheduled work orders to this calendar",
+        "includeCustomer": "Include the customer's name and phone in the event",
+        "addConference": "Add a Google Meet link to each event",
+        "addConferenceHelp": "The link is shown on the work order so you can share it with the customer.",
+        "pullEnabled": "Show this calendar's busy time on the Torqvoice calendar",
+        "pullEnabledHelp": "Read-only. Refreshed every 15 minutes, one week back and two months ahead."
+      }
+    },
+    "microsoft-365": {
+      "description": "Put scheduled work orders on an Outlook calendar, add a Microsoft Teams link, and see the calendar's busy time in Torqvoice.",
+      "tenantHelp": "Register an application in Microsoft Entra (App registrations), allow personal and work accounts, add the redirect URI below as a Web platform, create a client secret, and paste both here.",
+      "fields": {
+        "clientId": "Application (client) ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "calendarId": "Calendar",
+        "pushEnabled": "Add scheduled work orders to this calendar",
+        "includeCustomer": "Include the customer's name and phone in the event",
+        "addConference": "Add a Microsoft Teams link to each event",
+        "addConferenceHelp": "The link is shown on the work order so you can share it with the customer.",
+        "pullEnabled": "Show this calendar's busy time on the Torqvoice calendar",
+        "pullEnabledHelp": "Read-only. Refreshed every 15 minutes, one week back and two months ahead."
+      }
+    },
+    "zoom": {
+      "description": "Add a Zoom meeting to a scheduled work order and keep the join link on it, for remote diagnosis or walking a customer through findings.",
+      "tenantHelp": "In the Zoom App Marketplace, build a General app (user-managed), add the redirect URI below, add the scopes meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting and user:read:user, and paste the client ID and secret here.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "autoCreate": "Create a Zoom meeting for every scheduled work order automatically",
+        "autoCreateHelp": "Off: you add a video call to a work order yourself, from its page. On: every work order with a start time gets a meeting, moved when it is rescheduled and removed when it is cancelled. Either way the customer only gets the link when you send it.",
+        "includeCustomer": "Include the customer's name in the meeting topic",
+        "joinBeforeHost": "Let the customer join before you",
+        "waitingRoom": "Use a waiting room"
+      }
+    }
+  }
+}

+ 20 - 1
messages/en/service.json

@@ -34,7 +34,8 @@
     "delete": "Delete",
     "unpaid": "Unpaid",
     "unsavedChanges": "Unsaved changes",
-    "saved": "Saved"
+    "saved": "Saved",
+    "joinCall": "Join video call"
   },
   "basicInfo": {
     "title": "Basic Information",
@@ -410,5 +411,23 @@
     "remove": "Remove this concern",
     "notAnswered": "Nothing found against this yet",
     "answered": "{count} finding(s) against this"
+  },
+  "videoCall": {
+    "title": "Video call",
+    "none": "No video call on this work order.",
+    "needsSchedule": "Set a start time first. The meeting follows the schedule.",
+    "add": "Add {provider} meeting",
+    "addedHere": "added from this work order",
+    "fromCalendar": "from the calendar event",
+    "join": "Join",
+    "copy": "Copy link",
+    "copied": "Link copied",
+    "remove": "Remove",
+    "removeTitle": "Remove the video call?",
+    "removeDescription": "The meeting is deleted at the provider. Anyone who already has the link can no longer join.",
+    "created": "Meeting created. The link is on the work order.",
+    "removed": "Meeting removed",
+    "failed": "Something went wrong",
+    "shareHint": "The customer only gets this link when you send it. Copy it into a message or add it to a notification."
   }
 }

+ 5 - 1
messages/en/settings.json

@@ -7,7 +7,7 @@
       "billing": "Billing & Documents",
       "communications": "Communications",
       "workshop": "Workshop",
-      "integrations": "Integrations",
+      "integrations": "Apps & API",
       "system": "System"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Report Schedule",
         "description": "Automated report delivery"
       },
+      "integrations": {
+        "title": "Integrations",
+        "description": "Calendars, video calls and connected services"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Real-time event notifications"

+ 6 - 2
messages/es/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Número de WhatsApp registrado",
     "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero",
     "team_createDefaultRoles": "Roles estándar creados",
-    "settings_setCustomFieldPlacement": "Ubicación de campo personalizado actualizada"
+    "settings_setCustomFieldPlacement": "Ubicación de campo personalizado actualizada",
+    "integration_connect": "Integración conectada",
+    "integration_disconnect": "Integración desconectada"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -275,6 +277,8 @@
     "work_bay_create": "Puesto de trabajo «{name}» creado",
     "work_bay_update": "Puesto de trabajo «{name}» actualizado",
     "work_bay_delete": "Puesto de trabajo {id} eliminado",
-    "settings_setCustomFieldPlacement": "Cambió dónde se imprime un campo personalizado"
+    "settings_setCustomFieldPlacement": "Cambió dónde se imprime un campo personalizado",
+    "integration_connect": "{name} conectado",
+    "integration_disconnect": "{name} desconectado"
   }
 }

+ 4 - 2
messages/es/calendar.json

@@ -34,7 +34,8 @@
     "services": "Servicios",
     "reminders": "Recordatorios",
     "quotes": "Presupuestos",
-    "messages": "Mensajes"
+    "messages": "Mensajes",
+    "external": "Calendarios conectados"
   },
   "selectVehicle": "Seleccionar vehículo para orden de trabajo",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Vencido",
       "upcoming": "Próximo",
       "failed": "Fallido",
-      "queued": "En cola"
+      "queued": "En cola",
+      "busy": "Ocupado"
     },
     "type": {
       "service": "Servicio",

+ 160 - 0
messages/es/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integraciones",
+    "description": "Conecte los servicios que su taller ya utiliza. Cada integración se configura una sola vez y funciona en segundo plano.",
+    "all": "Todas",
+    "empty": "Todavía no hay integraciones en esta categoría.",
+    "notConnected": "No conectada",
+    "connect": "Conectar",
+    "manage": "Administrar",
+    "connectedTitle": "Conectados",
+    "connectedDescription": "Servicios que este taller ha configurado. Abra uno para ver su estado, cambiar ajustes o desconectarlo.",
+    "noneConnected": "Aún no hay nada conectado. Elija un servicio abajo para empezar.",
+    "availableTitle": "Disponibles",
+    "availableDescription": "Servicios que puede añadir. Cada uno se configura una vez y funciona en segundo plano.",
+    "allConnected": "Todos los servicios disponibles están conectados."
+  },
+  "categories": {
+    "calendar": "Calendario",
+    "conferencing": "Videollamadas",
+    "accounting": "Contabilidad",
+    "messaging": "Mensajería",
+    "payments": "Pagos",
+    "automation": "Automatización",
+    "storage": "Archivos",
+    "registry": "Registro de vehículos",
+    "other": "Otras"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Órdenes de trabajo al calendario",
+      "pull": "Tiempo ocupado del calendario",
+      "conference": "Enlaces de videollamada"
+    }
+  },
+  "statuses": {
+    "active": "Conectada",
+    "error": "Requiere atención",
+    "pending": "Configuración en curso",
+    "disconnected": "Desconectada"
+  },
+  "connection": {
+    "back": "Todas las integraciones",
+    "account": "Cuenta conectada",
+    "lastHealth": "Última comprobación",
+    "lastSync": "Última sincronización",
+    "lastError": "La última ejecución falló",
+    "connected": "{name} está conectado.",
+    "connectedShort": "Conectado",
+    "connectWith": "Conectar {name}",
+    "connect": "Conectar",
+    "reconnect": "Volver a conectar",
+    "disconnect": "Desconectar",
+    "disconnectTitle": "¿Desconectar {name}?",
+    "disconnectDescription": "Torqvoice olvida la cuenta y deja de sincronizar. Los eventos ya creados en el calendario se mantienen.",
+    "disconnected": "Desconectado",
+    "test": "Probar conexión",
+    "testPassed": "La conexión funciona",
+    "syncNow": "Sincronizar ahora",
+    "syncQueued": "Sincronización en cola; se ejecuta en menos de un minuto",
+    "backfill": "Enviar órdenes de trabajo programadas",
+    "backfillQueued": "Se pusieron en cola las órdenes de trabajo programadas desde la semana pasada en adelante",
+    "retryQueued": "Tarea puesta en cola de nuevo",
+    "settingsTitle": "Configuración",
+    "settingsDescription": "Qué sincronizar y dónde. Los cambios se aplican en la próxima sincronización.",
+    "settingsSaved": "Configuración guardada",
+    "save": "Guardar",
+    "loading": "Cargando...",
+    "choose": "Elegir...",
+    "planLocked": "Las integraciones no están incluidas en su plan.",
+    "ownAppTitle": "Esta instalación usa su propia aplicación con el proveedor",
+    "redirectUri": "URI de redirección que debe registrar con el proveedor:"
+  },
+  "activity": {
+    "title": "Actividad",
+    "description": "Tareas recientes y líneas de registro de esta conexión.",
+    "retry": "Reintentar",
+    "jobStatus": {
+      "queued": "En cola",
+      "running": "En ejecución",
+      "done": "Hecho",
+      "failed": "Fallido",
+      "dead": "Abandonado"
+    }
+  },
+  "errors": {
+    "generic": "Algo salió mal",
+    "demo": "Las integraciones no se pueden conectar en la demo.",
+    "forbidden": "Solo un administrador puede conectar integraciones.",
+    "unknown": "Integración desconocida.",
+    "plan": "Esta integración no está incluida en su plan.",
+    "no-client": "No hay credenciales de aplicación configuradas para este proveedor.",
+    "denied": "Canceló el inicio de sesión con el proveedor.",
+    "vendor": "El proveedor devolvió un error. Inténtelo de nuevo.",
+    "invalid": "La respuesta del proveedor fue incompleta. Inténtelo de nuevo.",
+    "state": "Este inicio de sesión ha caducado o pertenece a otra sesión. Empiece de nuevo.",
+    "exchange": "El proveedor rechazó el inicio de sesión. Compruebe las credenciales de la aplicación e inténtelo de nuevo.",
+    "identify": "Conectado, pero no se pudo leer la cuenta. Intente volver a conectar."
+  },
+  "meeting": {
+    "join": "Unirse a la videollamada",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "De calendarios conectados",
+    "busy": "Ocupado"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Añada las órdenes de trabajo programadas a un calendario de Google Calendar, incluya un enlace de Google Meet y vea el tiempo ocupado del calendario en Torqvoice.",
+      "tenantHelp": "Cree un cliente OAuth en Google Cloud Console (APIs y servicios → Credenciales), habilite la API de Google Calendar, añada la URI de redirección que aparece abajo y pegue aquí el ID de cliente y el secreto.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "calendarId": "Calendario",
+        "pushEnabled": "Añadir las órdenes de trabajo programadas a este calendario",
+        "includeCustomer": "Incluir el nombre y el teléfono del cliente en el evento",
+        "addConference": "Añadir un enlace de Google Meet a cada evento",
+        "addConferenceHelp": "El enlace se muestra en la orden de trabajo para que pueda compartirlo con el cliente.",
+        "pullEnabled": "Mostrar el tiempo ocupado de este calendario en el calendario de Torqvoice",
+        "pullEnabledHelp": "Solo lectura. Se actualiza cada 15 minutos, una semana hacia atrás y dos meses hacia adelante."
+      }
+    },
+    "microsoft-365": {
+      "description": "Añada las órdenes de trabajo programadas a un calendario de Outlook, incluya un enlace de Microsoft Teams y vea el tiempo ocupado del calendario en Torqvoice.",
+      "tenantHelp": "Registre una aplicación en Microsoft Entra (Registros de aplicaciones), permita cuentas personales y profesionales, añada la URI de redirección que aparece abajo como plataforma Web, cree un secreto de cliente y pegue ambos aquí.",
+      "fields": {
+        "clientId": "ID de aplicación (cliente)",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "calendarId": "Calendario",
+        "pushEnabled": "Añadir las órdenes de trabajo programadas a este calendario",
+        "includeCustomer": "Incluir el nombre y el teléfono del cliente en el evento",
+        "addConference": "Añadir un enlace de Microsoft Teams a cada evento",
+        "addConferenceHelp": "El enlace se muestra en la orden de trabajo para que pueda compartirlo con el cliente.",
+        "pullEnabled": "Mostrar el tiempo ocupado de este calendario en el calendario de Torqvoice",
+        "pullEnabledHelp": "Solo lectura. Se actualiza cada 15 minutos, una semana hacia atrás y dos meses hacia adelante."
+      }
+    },
+    "zoom": {
+      "description": "Añada una reunión de Zoom a una orden de trabajo programada y conserve el enlace en ella, para diagnósticos a distancia o para repasar hallazgos con el cliente.",
+      "tenantHelp": "En Zoom App Marketplace, cree una aplicación General (user-managed), añada la URI de redirección de abajo, añada los ámbitos meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting y user:read:user, y pegue aquí el ID y el secreto de cliente.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "autoCreate": "Crear una reunión de Zoom automáticamente para cada orden de trabajo programada",
+        "autoCreateHelp": "Desactivado: usted añade la videollamada a la orden de trabajo desde su página. Activado: cada orden con hora de inicio recibe una reunión, que se mueve al reprogramarla y se elimina al cancelarla. En ambos casos el cliente solo recibe el enlace cuando usted se lo envía.",
+        "includeCustomer": "Incluir el nombre del cliente en el tema de la reunión",
+        "joinBeforeHost": "Permitir que el cliente entre antes que usted",
+        "waitingRoom": "Usar sala de espera"
+      }
+    }
+  }
+}

+ 20 - 1
messages/es/service.json

@@ -34,7 +34,8 @@
     "delete": "Eliminar",
     "unpaid": "Sin Pagar",
     "unsavedChanges": "Cambios sin guardar",
-    "saved": "Guardado"
+    "saved": "Guardado",
+    "joinCall": "Unirse a la videollamada"
   },
   "basicInfo": {
     "title": "Información Básica",
@@ -410,5 +411,23 @@
     "remove": "Quitar este punto",
     "notAnswered": "Todavía no se ha encontrado nada",
     "answered": "{count} hallazgo(s) sobre esto"
+  },
+  "videoCall": {
+    "title": "Videollamada",
+    "none": "Esta orden de trabajo no tiene videollamada.",
+    "needsSchedule": "Defina primero una hora de inicio. La reunión sigue la programación.",
+    "add": "Añadir reunión de {provider}",
+    "addedHere": "añadida desde esta orden de trabajo",
+    "fromCalendar": "del evento del calendario",
+    "join": "Unirse",
+    "copy": "Copiar enlace",
+    "copied": "Enlace copiado",
+    "remove": "Eliminar",
+    "removeTitle": "¿Eliminar la videollamada?",
+    "removeDescription": "La reunión se elimina en el proveedor. Quien ya tenga el enlace no podrá unirse.",
+    "created": "Reunión creada. El enlace está en la orden de trabajo.",
+    "removed": "Reunión eliminada",
+    "failed": "Algo salió mal",
+    "shareHint": "El cliente solo recibe este enlace cuando usted se lo envía. Cópielo en un mensaje o añádalo a una notificación."
   }
 }

+ 5 - 1
messages/es/settings.json

@@ -7,7 +7,7 @@
       "billing": "Facturación y documentos",
       "communications": "Comunicaciones",
       "workshop": "Taller",
-      "integrations": "Integraciones",
+      "integrations": "Aplicaciones y API",
       "system": "Sistema"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Informes programados",
         "description": "Envío automático de informes"
       },
+      "integrations": {
+        "title": "Integraciones",
+        "description": "Calendarios, videollamadas y servicios conectados"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Notificaciones de eventos en tiempo real"

+ 6 - 2
messages/fr/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Numéro WhatsApp enregistré",
     "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau",
     "team_createDefaultRoles": "Rôles standard créés",
-    "settings_setCustomFieldPlacement": "Emplacement de champ personnalisé mis à jour"
+    "settings_setCustomFieldPlacement": "Emplacement de champ personnalisé mis à jour",
+    "integration_connect": "Intégration connectée",
+    "integration_disconnect": "Intégration déconnectée"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -275,6 +277,8 @@
     "work_bay_create": "Poste de travail « {name} » créé",
     "work_bay_update": "Poste de travail « {name} » mis à jour",
     "work_bay_delete": "Poste de travail {id} supprimé",
-    "settings_setCustomFieldPlacement": "A modifié l'emplacement d'impression d'un champ personnalisé"
+    "settings_setCustomFieldPlacement": "A modifié l'emplacement d'impression d'un champ personnalisé",
+    "integration_connect": "{name} connecté",
+    "integration_disconnect": "{name} déconnecté"
   }
 }

+ 4 - 2
messages/fr/calendar.json

@@ -34,7 +34,8 @@
     "services": "Services",
     "reminders": "Rappels",
     "quotes": "Devis",
-    "messages": "Messages"
+    "messages": "Messages",
+    "external": "Calendriers connectés"
   },
   "selectVehicle": "Sélectionner un véhicule pour l’ordre de travail",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "En retard",
       "upcoming": "À venir",
       "failed": "Échec",
-      "queued": "En file"
+      "queued": "En file",
+      "busy": "Occupé"
     },
     "type": {
       "service": "Service",

+ 160 - 0
messages/fr/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Intégrations",
+    "description": "Connectez les services que votre atelier utilise déjà. Chaque intégration se configure une seule fois et fonctionne en arrière-plan.",
+    "all": "Toutes",
+    "empty": "Aucune intégration dans cette catégorie pour le moment.",
+    "notConnected": "Non connectée",
+    "connect": "Connecter",
+    "manage": "Gérer",
+    "connectedTitle": "Connectés",
+    "connectedDescription": "Services configurés par cet atelier. Ouvrez-en un pour vérifier son état, modifier les réglages ou le déconnecter.",
+    "noneConnected": "Rien de connecté pour l'instant. Choisissez un service ci-dessous pour commencer.",
+    "availableTitle": "Disponibles",
+    "availableDescription": "Services que vous pouvez ajouter. Chacun se configure une fois et fonctionne en arrière-plan.",
+    "allConnected": "Tous les services disponibles sont connectés."
+  },
+  "categories": {
+    "calendar": "Calendrier",
+    "conferencing": "Appels vidéo",
+    "accounting": "Comptabilité",
+    "messaging": "Messagerie",
+    "payments": "Paiements",
+    "automation": "Automatisation",
+    "storage": "Fichiers",
+    "registry": "Registre des véhicules",
+    "other": "Autre"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Ordres de travail vers le calendrier",
+      "pull": "Plages occupées du calendrier",
+      "conference": "Liens d'appel vidéo"
+    }
+  },
+  "statuses": {
+    "active": "Connectée",
+    "error": "Attention requise",
+    "pending": "Configuration en cours",
+    "disconnected": "Déconnectée"
+  },
+  "connection": {
+    "back": "Toutes les intégrations",
+    "account": "Compte connecté",
+    "lastHealth": "Dernière vérification",
+    "lastSync": "Dernière synchronisation",
+    "lastError": "La dernière exécution a échoué",
+    "connected": "{name} est connecté.",
+    "connectedShort": "Connecté",
+    "connectWith": "Connecter {name}",
+    "connect": "Connecter",
+    "reconnect": "Reconnecter",
+    "disconnect": "Déconnecter",
+    "disconnectTitle": "Déconnecter {name} ?",
+    "disconnectDescription": "Torqvoice oublie le compte et arrête la synchronisation. Les événements déjà créés dans le calendrier y restent.",
+    "disconnected": "Déconnecté",
+    "test": "Tester la connexion",
+    "testPassed": "La connexion fonctionne",
+    "syncNow": "Synchroniser maintenant",
+    "syncQueued": "Synchronisation en file d'attente ; elle démarre d'ici une minute",
+    "backfill": "Envoyer les ordres de travail planifiés",
+    "backfillQueued": "Les ordres de travail planifiés depuis la semaine dernière ont été mis en file d'attente",
+    "retryQueued": "Tâche remise en file d'attente",
+    "settingsTitle": "Paramètres",
+    "settingsDescription": "Ce qu'il faut synchroniser et vers où. Les modifications s'appliquent à la prochaine synchronisation.",
+    "settingsSaved": "Paramètres enregistrés",
+    "save": "Enregistrer",
+    "loading": "Chargement...",
+    "choose": "Choisir...",
+    "planLocked": "Les intégrations ne sont pas incluses dans votre forfait.",
+    "ownAppTitle": "Cette installation utilise votre propre application auprès du fournisseur",
+    "redirectUri": "URI de redirection à enregistrer auprès du fournisseur :"
+  },
+  "activity": {
+    "title": "Activité",
+    "description": "Tâches récentes et lignes de journal pour cette connexion.",
+    "retry": "Réessayer",
+    "jobStatus": {
+      "queued": "En file d'attente",
+      "running": "En cours",
+      "done": "Terminée",
+      "failed": "Échec",
+      "dead": "Abandonnée"
+    }
+  },
+  "errors": {
+    "generic": "Une erreur s'est produite",
+    "demo": "Les intégrations ne peuvent pas être connectées sur la démo.",
+    "forbidden": "Seul un administrateur peut connecter des intégrations.",
+    "unknown": "Intégration inconnue.",
+    "plan": "Cette intégration n'est pas incluse dans votre forfait.",
+    "no-client": "Aucun identifiant d'application n'est configuré pour ce fournisseur.",
+    "denied": "Vous avez annulé la connexion auprès du fournisseur.",
+    "vendor": "Le fournisseur a renvoyé une erreur. Réessayez.",
+    "invalid": "La réponse du fournisseur était incomplète. Réessayez.",
+    "state": "Cette connexion a expiré ou appartient à une autre session. Recommencez.",
+    "exchange": "Le fournisseur a refusé la connexion. Vérifiez les identifiants de l'application et réessayez.",
+    "identify": "Connecté, mais le compte n'a pas pu être lu. Essayez de vous reconnecter."
+  },
+  "meeting": {
+    "join": "Rejoindre l'appel vidéo",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Depuis les calendriers connectés",
+    "busy": "Occupé"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Ajoutez les ordres de travail planifiés à un Google Calendar, joignez un lien Google Meet et consultez les plages occupées du calendrier dans Torqvoice.",
+      "tenantHelp": "Créez un client OAuth dans Google Cloud Console (API et services → Identifiants), activez l'API Google Calendar, ajoutez l'URI de redirection ci-dessous, puis collez ici l'identifiant client et le secret.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "calendarId": "Calendrier",
+        "pushEnabled": "Ajouter les ordres de travail planifiés à ce calendrier",
+        "includeCustomer": "Inclure le nom et le téléphone du client dans l'événement",
+        "addConference": "Ajouter un lien Google Meet à chaque événement",
+        "addConferenceHelp": "Le lien est affiché sur l'ordre de travail pour que vous puissiez le partager avec le client.",
+        "pullEnabled": "Afficher les plages occupées de ce calendrier dans le calendrier Torqvoice",
+        "pullEnabledHelp": "Lecture seule. Actualisé toutes les 15 minutes, une semaine en arrière et deux mois en avant."
+      }
+    },
+    "microsoft-365": {
+      "description": "Ajoutez les ordres de travail planifiés à un calendrier Outlook, joignez un lien Microsoft Teams et consultez les plages occupées du calendrier dans Torqvoice.",
+      "tenantHelp": "Enregistrez une application dans Microsoft Entra (Inscriptions d'applications), autorisez les comptes personnels et professionnels, ajoutez l'URI de redirection ci-dessous comme plateforme Web, créez un secret client, puis collez les deux ici.",
+      "fields": {
+        "clientId": "ID d'application (client)",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "calendarId": "Calendrier",
+        "pushEnabled": "Ajouter les ordres de travail planifiés à ce calendrier",
+        "includeCustomer": "Inclure le nom et le téléphone du client dans l'événement",
+        "addConference": "Ajouter un lien Microsoft Teams à chaque événement",
+        "addConferenceHelp": "Le lien est affiché sur l'ordre de travail pour que vous puissiez le partager avec le client.",
+        "pullEnabled": "Afficher les plages occupées de ce calendrier dans le calendrier Torqvoice",
+        "pullEnabledHelp": "Lecture seule. Actualisé toutes les 15 minutes, une semaine en arrière et deux mois en avant."
+      }
+    },
+    "zoom": {
+      "description": "Ajoutez une réunion Zoom à un ordre de travail planifié et gardez le lien dessus, pour un diagnostic à distance ou pour présenter des constats au client.",
+      "tenantHelp": "Dans Zoom App Marketplace, créez une application General (user-managed), ajoutez l'URI de redirection ci-dessous, ajoutez les scopes meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting et user:read:user, puis collez ici l'ID client et le secret.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "autoCreate": "Créer automatiquement une réunion Zoom pour chaque ordre de travail planifié",
+        "autoCreateHelp": "Désactivé : vous ajoutez vous-même l'appel vidéo à un ordre de travail, depuis sa page. Activé : chaque ordre avec une heure de début reçoit une réunion, déplacée en cas de report et supprimée en cas d'annulation. Dans les deux cas, le client ne reçoit le lien que si vous l'envoyez.",
+        "includeCustomer": "Inclure le nom du client dans le sujet de la réunion",
+        "joinBeforeHost": "Laisser le client rejoindre avant vous",
+        "waitingRoom": "Utiliser une salle d'attente"
+      }
+    }
+  }
+}

+ 20 - 1
messages/fr/service.json

@@ -34,7 +34,8 @@
     "delete": "Supprimer",
     "unpaid": "Non Payé",
     "unsavedChanges": "Modifications non enregistrées",
-    "saved": "Enregistré"
+    "saved": "Enregistré",
+    "joinCall": "Rejoindre l'appel vidéo"
   },
   "basicInfo": {
     "title": "Informations de Base",
@@ -410,5 +411,23 @@
     "remove": "Retirer ce point",
     "notAnswered": "Rien trouvé à ce sujet pour l'instant",
     "answered": "{count} constat(s) à ce sujet"
+  },
+  "videoCall": {
+    "title": "Appel vidéo",
+    "none": "Aucun appel vidéo sur cet ordre de travail.",
+    "needsSchedule": "Définissez d'abord une heure de début. La réunion suit le planning.",
+    "add": "Ajouter une réunion {provider}",
+    "addedHere": "ajouté depuis cet ordre de travail",
+    "fromCalendar": "depuis l'événement du calendrier",
+    "join": "Rejoindre",
+    "copy": "Copier le lien",
+    "copied": "Lien copié",
+    "remove": "Supprimer",
+    "removeTitle": "Supprimer l'appel vidéo ?",
+    "removeDescription": "La réunion est supprimée chez le fournisseur. Ceux qui ont déjà le lien ne pourront plus rejoindre.",
+    "created": "Réunion créée. Le lien est sur l'ordre de travail.",
+    "removed": "Réunion supprimée",
+    "failed": "Une erreur s'est produite",
+    "shareHint": "Le client ne reçoit ce lien que si vous l'envoyez. Copiez-le dans un message ou ajoutez-le à une notification."
   }
 }

+ 5 - 1
messages/fr/settings.json

@@ -7,7 +7,7 @@
       "billing": "Facturation et documents",
       "communications": "Communications",
       "workshop": "Atelier",
-      "integrations": "Intégrations",
+      "integrations": "Applications et API",
       "system": "Système"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Rapports planifiés",
         "description": "Envoi automatique de rapports"
       },
+      "integrations": {
+        "title": "Intégrations",
+        "description": "Calendriers, appels vidéo et services connectés"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Notifications d'événements en temps réel"

+ 6 - 2
messages/it/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Numero WhatsApp registrato",
     "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna",
     "team_createDefaultRoles": "Ruoli standard creati",
-    "settings_setCustomFieldPlacement": "Posizione del campo personalizzato aggiornata"
+    "settings_setCustomFieldPlacement": "Posizione del campo personalizzato aggiornata",
+    "integration_connect": "Integrazione collegata",
+    "integration_disconnect": "Integrazione scollegata"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -275,6 +277,8 @@
     "work_bay_create": "Postazione «{name}» creata",
     "work_bay_update": "Postazione «{name}» aggiornata",
     "work_bay_delete": "Postazione {id} eliminata",
-    "settings_setCustomFieldPlacement": "Ha modificato dove viene stampato un campo personalizzato"
+    "settings_setCustomFieldPlacement": "Ha modificato dove viene stampato un campo personalizzato",
+    "integration_connect": "{name} collegato",
+    "integration_disconnect": "{name} scollegato"
   }
 }

+ 4 - 2
messages/it/calendar.json

@@ -34,7 +34,8 @@
     "services": "Servizi",
     "reminders": "Promemoria",
     "quotes": "Preventivi",
-    "messages": "Messaggi"
+    "messages": "Messaggi",
+    "external": "Calendari collegati"
   },
   "selectVehicle": "Seleziona veicolo per ordine di lavoro",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Scaduto",
       "upcoming": "In arrivo",
       "failed": "Non riuscito",
-      "queued": "In coda"
+      "queued": "In coda",
+      "busy": "Occupato"
     },
     "type": {
       "service": "Servizio",

+ 160 - 0
messages/it/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integrazioni",
+    "description": "Collega i servizi che la tua officina usa già. Ogni integrazione si configura una sola volta e funziona in background.",
+    "all": "Tutte",
+    "empty": "Nessuna integrazione in questa categoria per ora.",
+    "notConnected": "Non collegata",
+    "connect": "Collega",
+    "manage": "Gestisci",
+    "connectedTitle": "Collegati",
+    "connectedDescription": "Servizi configurati da questa officina. Aprine uno per controllarne lo stato, cambiare le impostazioni o scollegarlo.",
+    "noneConnected": "Ancora nulla di collegato. Scegli un servizio qui sotto per iniziare.",
+    "availableTitle": "Disponibili",
+    "availableDescription": "Servizi che puoi aggiungere. Ognuno si configura una volta e lavora in background.",
+    "allConnected": "Tutti i servizi disponibili sono collegati."
+  },
+  "categories": {
+    "calendar": "Calendario",
+    "conferencing": "Videochiamate",
+    "accounting": "Contabilità",
+    "messaging": "Messaggistica",
+    "payments": "Pagamenti",
+    "automation": "Automazione",
+    "storage": "File",
+    "registry": "Registro veicoli",
+    "other": "Altro"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Ordini di lavoro nel calendario",
+      "pull": "Impegni dal calendario",
+      "conference": "Link per videochiamate"
+    }
+  },
+  "statuses": {
+    "active": "Collegata",
+    "error": "Richiede attenzione",
+    "pending": "Configurazione in corso",
+    "disconnected": "Scollegata"
+  },
+  "connection": {
+    "back": "Tutte le integrazioni",
+    "account": "Account collegato",
+    "lastHealth": "Ultimo controllo",
+    "lastSync": "Ultima sincronizzazione",
+    "lastError": "L'ultima esecuzione non è riuscita",
+    "connected": "{name} è collegato.",
+    "connectedShort": "Collegato",
+    "connectWith": "Collega {name}",
+    "connect": "Collega",
+    "reconnect": "Ricollega",
+    "disconnect": "Scollega",
+    "disconnectTitle": "Scollegare {name}?",
+    "disconnectDescription": "Torqvoice dimentica l'account e interrompe la sincronizzazione. Gli eventi già creati nel calendario restano lì.",
+    "disconnected": "Scollegato",
+    "test": "Prova la connessione",
+    "testPassed": "La connessione funziona",
+    "syncNow": "Sincronizza ora",
+    "syncQueued": "Sincronizzazione in coda; verrà eseguita entro un minuto",
+    "backfill": "Invia gli ordini di lavoro pianificati",
+    "backfillQueued": "Messi in coda gli ordini di lavoro pianificati dall'ultima settimana in poi",
+    "retryQueued": "Attività rimessa in coda",
+    "settingsTitle": "Impostazioni",
+    "settingsDescription": "Cosa sincronizzare e dove. Le modifiche si applicano alla prossima sincronizzazione.",
+    "settingsSaved": "Impostazioni salvate",
+    "save": "Salva",
+    "loading": "Caricamento...",
+    "choose": "Scegli...",
+    "planLocked": "Le integrazioni non sono incluse nel tuo piano.",
+    "ownAppTitle": "Questa installazione usa la tua app presso il provider",
+    "redirectUri": "URI di reindirizzamento da registrare presso il provider:"
+  },
+  "activity": {
+    "title": "Attività",
+    "description": "Attività recenti e righe di log per questa connessione.",
+    "retry": "Riprova",
+    "jobStatus": {
+      "queued": "In coda",
+      "running": "In esecuzione",
+      "done": "Completata",
+      "failed": "Non riuscita",
+      "dead": "Abbandonata"
+    }
+  },
+  "errors": {
+    "generic": "Qualcosa è andato storto",
+    "demo": "Le integrazioni non possono essere collegate nella demo.",
+    "forbidden": "Solo un amministratore può collegare le integrazioni.",
+    "unknown": "Integrazione sconosciuta.",
+    "plan": "Questa integrazione non è inclusa nel tuo piano.",
+    "no-client": "Nessuna credenziale dell'app configurata per questo provider.",
+    "denied": "Hai annullato l'accesso presso il provider.",
+    "vendor": "Il provider ha restituito un errore. Riprova.",
+    "invalid": "La risposta del provider era incompleta. Riprova.",
+    "state": "Questo accesso è scaduto o appartiene a un'altra sessione. Ricomincia da capo.",
+    "exchange": "Il provider ha rifiutato l'accesso. Controlla le credenziali dell'app e riprova.",
+    "identify": "Collegato, ma non è stato possibile leggere l'account. Prova a ricollegarlo."
+  },
+  "meeting": {
+    "join": "Partecipa alla videochiamata",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Dai calendari collegati",
+    "busy": "Occupato"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Inserisci gli ordini di lavoro pianificati in un Google Calendar, aggiungi un link Google Meet e vedi gli impegni del calendario in Torqvoice.",
+      "tenantHelp": "Crea un client OAuth in Google Cloud Console (API e servizi → Credenziali), abilita l'API Google Calendar, aggiungi l'URI di reindirizzamento qui sotto e incolla qui l'ID client e il segreto.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "calendarId": "Calendario",
+        "pushEnabled": "Aggiungi gli ordini di lavoro pianificati a questo calendario",
+        "includeCustomer": "Includi nome e telefono del cliente nell'evento",
+        "addConference": "Aggiungi un link Google Meet a ogni evento",
+        "addConferenceHelp": "Il link viene mostrato nell'ordine di lavoro, così puoi condividerlo con il cliente.",
+        "pullEnabled": "Mostra gli impegni di questo calendario nel calendario di Torqvoice",
+        "pullEnabledHelp": "Sola lettura. Aggiornato ogni 15 minuti, da una settimana indietro a due mesi avanti."
+      }
+    },
+    "microsoft-365": {
+      "description": "Inserisci gli ordini di lavoro pianificati in un calendario Outlook, aggiungi un link Microsoft Teams e vedi gli impegni del calendario in Torqvoice.",
+      "tenantHelp": "Registra un'applicazione in Microsoft Entra (Registrazioni app), consenti gli account personali e aziendali, aggiungi l'URI di reindirizzamento qui sotto come piattaforma Web, crea un segreto client e incolla entrambi qui.",
+      "fields": {
+        "clientId": "ID applicazione (client)",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "calendarId": "Calendario",
+        "pushEnabled": "Aggiungi gli ordini di lavoro pianificati a questo calendario",
+        "includeCustomer": "Includi nome e telefono del cliente nell'evento",
+        "addConference": "Aggiungi un link Microsoft Teams a ogni evento",
+        "addConferenceHelp": "Il link viene mostrato nell'ordine di lavoro, così puoi condividerlo con il cliente.",
+        "pullEnabled": "Mostra gli impegni di questo calendario nel calendario di Torqvoice",
+        "pullEnabledHelp": "Sola lettura. Aggiornato ogni 15 minuti, da una settimana indietro a due mesi avanti."
+      }
+    },
+    "zoom": {
+      "description": "Aggiungi una riunione Zoom a un ordine di lavoro programmato e tieni il link sull'ordine, per una diagnosi a distanza o per illustrare i risultati al cliente.",
+      "tenantHelp": "Nello Zoom App Marketplace crea un'app General (user-managed), aggiungi l'URI di reindirizzamento qui sotto, aggiungi gli scope meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting e user:read:user, quindi incolla qui client ID e secret.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "autoCreate": "Crea automaticamente una riunione Zoom per ogni ordine di lavoro programmato",
+        "autoCreateHelp": "Spento: aggiungi tu la videochiamata a un ordine di lavoro, dalla sua pagina. Acceso: ogni ordine con un'ora di inizio riceve una riunione, spostata se riprogrammato e rimossa se annullato. In entrambi i casi il cliente riceve il link solo quando glielo invii.",
+        "includeCustomer": "Includi il nome del cliente nell'argomento della riunione",
+        "joinBeforeHost": "Consenti al cliente di entrare prima di te",
+        "waitingRoom": "Usa la sala d'attesa"
+      }
+    }
+  }
+}

+ 20 - 1
messages/it/service.json

@@ -34,7 +34,8 @@
     "delete": "Elimina",
     "unpaid": "Non Pagato",
     "unsavedChanges": "Modifiche non salvate",
-    "saved": "Salvato"
+    "saved": "Salvato",
+    "joinCall": "Partecipa alla videochiamata"
   },
   "basicInfo": {
     "title": "Informazioni di Base",
@@ -410,5 +411,23 @@
     "remove": "Rimuovi questo punto",
     "notAnswered": "Non è ancora stato trovato nulla",
     "answered": "{count} riscontro/i su questo"
+  },
+  "videoCall": {
+    "title": "Videochiamata",
+    "none": "Nessuna videochiamata su questo ordine di lavoro.",
+    "needsSchedule": "Imposta prima un'ora di inizio. La riunione segue la programmazione.",
+    "add": "Aggiungi riunione {provider}",
+    "addedHere": "aggiunta da questo ordine di lavoro",
+    "fromCalendar": "dall'evento del calendario",
+    "join": "Partecipa",
+    "copy": "Copia link",
+    "copied": "Link copiato",
+    "remove": "Rimuovi",
+    "removeTitle": "Rimuovere la videochiamata?",
+    "removeDescription": "La riunione viene eliminata presso il fornitore. Chi ha già il link non potrà più partecipare.",
+    "created": "Riunione creata. Il link è sull'ordine di lavoro.",
+    "removed": "Riunione rimossa",
+    "failed": "Qualcosa è andato storto",
+    "shareHint": "Il cliente riceve questo link solo quando glielo invii. Copialo in un messaggio o aggiungilo a una notifica."
   }
 }

+ 5 - 1
messages/it/settings.json

@@ -7,7 +7,7 @@
       "billing": "Fatturazione e Documenti",
       "communications": "Comunicazioni",
       "workshop": "Officina",
-      "integrations": "Integrazioni",
+      "integrations": "App e API",
       "system": "Sistema"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Report programmati",
         "description": "Invio automatico dei report"
       },
+      "integrations": {
+        "title": "Integrazioni",
+        "description": "Calendari, videochiamate e servizi collegati"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Notifiche di eventi in tempo reale"

+ 6 - 2
messages/lt/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Užregistruotas „WhatsApp“ numeris",
     "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra",
     "team_createDefaultRoles": "Sukurtos standartinės rolės",
-    "settings_setCustomFieldPlacement": "Atnaujinta pasirinktinio lauko vieta"
+    "settings_setCustomFieldPlacement": "Atnaujinta pasirinktinio lauko vieta",
+    "integration_connect": "Prijungta integracija",
+    "integration_disconnect": "Atjungta integracija"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -275,6 +277,8 @@
     "work_bay_create": "Sukurta darbo vieta „{name}“",
     "work_bay_update": "Atnaujinta darbo vieta „{name}“",
     "work_bay_delete": "Ištrinta darbo vieta {id}",
-    "settings_setCustomFieldPlacement": "Pakeitė, kur spausdinamas pasirinktinis laukas"
+    "settings_setCustomFieldPlacement": "Pakeitė, kur spausdinamas pasirinktinis laukas",
+    "integration_connect": "Prijungta {name}",
+    "integration_disconnect": "Atjungta {name}"
   }
 }

+ 4 - 2
messages/lt/calendar.json

@@ -34,7 +34,8 @@
     "services": "Paslaugos",
     "reminders": "Priminimai",
     "quotes": "Pasiūlymai",
-    "messages": "Žinutės"
+    "messages": "Žinutės",
+    "external": "Prijungti kalendoriai"
   },
   "selectVehicle": "Pasirinkite transporto priemonę darbo užsakymui",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Vėluoja",
       "upcoming": "Artėja",
       "failed": "Nepavyko",
-      "queued": "Eilėje"
+      "queued": "Eilėje",
+      "busy": "Užimta"
     },
     "type": {
       "service": "Paslauga",

+ 160 - 0
messages/lt/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integracijos",
+    "description": "Prijunkite paslaugas, kuriomis jūsų dirbtuvė jau naudojasi. Kiekviena integracija nustatoma vieną kartą ir veikia fone.",
+    "all": "Visos",
+    "empty": "Šioje kategorijoje integracijų kol kas nėra.",
+    "notConnected": "Neprijungta",
+    "connect": "Prijungti",
+    "manage": "Tvarkyti",
+    "connectedTitle": "Prijungta",
+    "connectedDescription": "Paslaugos, kurias ši dirbtuvė nustatė. Atidarykite, kad patikrintumėte būseną, pakeistumėte nustatymus ar atjungtumėte.",
+    "noneConnected": "Kol kas niekas neprijungta. Pasirinkite paslaugą žemiau, kad pradėtumėte.",
+    "availableTitle": "Galima pridėti",
+    "availableDescription": "Paslaugos, kurias galite pridėti. Kiekviena nustatoma vieną kartą ir veikia fone.",
+    "allConnected": "Visos galimos paslaugos prijungtos."
+  },
+  "categories": {
+    "calendar": "Kalendorius",
+    "conferencing": "Vaizdo skambučiai",
+    "accounting": "Apskaita",
+    "messaging": "Žinutės",
+    "payments": "Mokėjimai",
+    "automation": "Automatizavimas",
+    "storage": "Failai",
+    "registry": "Transporto priemonių registras",
+    "other": "Kita"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Darbo užsakymai į kalendorių",
+      "pull": "Užimtas laikas iš kalendoriaus",
+      "conference": "Vaizdo skambučių nuorodos"
+    }
+  },
+  "statuses": {
+    "active": "Prijungta",
+    "error": "Reikia dėmesio",
+    "pending": "Nustatoma",
+    "disconnected": "Atjungta"
+  },
+  "connection": {
+    "back": "Visos integracijos",
+    "account": "Prijungta paskyra",
+    "lastHealth": "Paskutinį kartą tikrinta",
+    "lastSync": "Paskutinis sinchronizavimas",
+    "lastError": "Paskutinis vykdymas nepavyko",
+    "connected": "{name} prijungta.",
+    "connectedShort": "Prijungta",
+    "connectWith": "Prijungti {name}",
+    "connect": "Prijungti",
+    "reconnect": "Prijungti iš naujo",
+    "disconnect": "Atjungti",
+    "disconnectTitle": "Atjungti {name}?",
+    "disconnectDescription": "Torqvoice pamirš paskyrą ir nustos sinchronizuoti. Kalendoriuje jau sukurti įvykiai liks.",
+    "disconnected": "Atjungta",
+    "test": "Tikrinti ryšį",
+    "testPassed": "Ryšys veikia",
+    "syncNow": "Sinchronizuoti dabar",
+    "syncQueued": "Sinchronizavimas eilėje; jis įvyks per minutę",
+    "backfill": "Perkelti suplanuotus darbo užsakymus",
+    "backfillQueued": "Į eilę įtraukti suplanuoti darbo užsakymai nuo praėjusios savaitės",
+    "retryQueued": "Užduotis vėl įtraukta į eilę",
+    "settingsTitle": "Nustatymai",
+    "settingsDescription": "Ką sinchronizuoti ir kur. Pakeitimai įsigalios kito sinchronizavimo metu.",
+    "settingsSaved": "Nustatymai išsaugoti",
+    "save": "Išsaugoti",
+    "loading": "Kraunama...",
+    "choose": "Pasirinkite...",
+    "planLocked": "Integracijos neįtrauktos į jūsų planą.",
+    "ownAppTitle": "Šis diegimas naudoja jūsų pačių programą pas tiekėją",
+    "redirectUri": "Nukreipimo URI, kurį reikia užregistruoti pas tiekėją:"
+  },
+  "activity": {
+    "title": "Veikla",
+    "description": "Naujausios šio ryšio užduotys ir žurnalo įrašai.",
+    "retry": "Bandyti dar kartą",
+    "jobStatus": {
+      "queued": "Eilėje",
+      "running": "Vykdoma",
+      "done": "Atlikta",
+      "failed": "Nepavyko",
+      "dead": "Nutraukta"
+    }
+  },
+  "errors": {
+    "generic": "Kažkas nepavyko",
+    "demo": "Demonstracinėje versijoje integracijų prijungti negalima.",
+    "forbidden": "Integracijas prijungti gali tik administratorius.",
+    "unknown": "Nežinoma integracija.",
+    "plan": "Ši integracija neįtraukta į jūsų planą.",
+    "no-client": "Šiam tiekėjui nesukonfigūruoti programos prisijungimo duomenys.",
+    "denied": "Atšaukėte prisijungimą pas tiekėją.",
+    "vendor": "Tiekėjas grąžino klaidą. Bandykite dar kartą.",
+    "invalid": "Tiekėjo atsakymas buvo neišsamus. Bandykite dar kartą.",
+    "state": "Šis prisijungimas baigė galioti arba priklauso kitai sesijai. Pradėkite iš naujo.",
+    "exchange": "Tiekėjas atmetė prisijungimą. Patikrinkite programos prisijungimo duomenis ir bandykite dar kartą.",
+    "identify": "Prijungta, bet paskyros nuskaityti nepavyko. Pabandykite prijungti iš naujo."
+  },
+  "meeting": {
+    "join": "Prisijungti prie vaizdo skambučio",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Iš prijungtų kalendorių",
+    "busy": "Užimta"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Įtraukite suplanuotus darbo užsakymus į Google Calendar, pridėkite Google Meet nuorodą ir matykite kalendoriaus užimtą laiką Torqvoice.",
+      "tenantHelp": "Sukurkite OAuth klientą Google Cloud Console (APIs & Services → Credentials), įjunkite Google Calendar API, pridėkite žemiau nurodytą nukreipimo URI ir įklijuokite čia kliento ID ir slaptažodį.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "calendarId": "Kalendorius",
+        "pushEnabled": "Įtraukti suplanuotus darbo užsakymus į šį kalendorių",
+        "includeCustomer": "Įtraukti į įvykį kliento vardą ir telefoną",
+        "addConference": "Pridėti Google Meet nuorodą prie kiekvieno įvykio",
+        "addConferenceHelp": "Nuoroda rodoma darbo užsakyme, kad galėtumėte ja pasidalinti su klientu.",
+        "pullEnabled": "Rodyti šio kalendoriaus užimtą laiką Torqvoice kalendoriuje",
+        "pullEnabledHelp": "Tik skaitymui. Atnaujinama kas 15 minučių, viena savaitė atgal ir du mėnesiai į priekį."
+      }
+    },
+    "microsoft-365": {
+      "description": "Įtraukite suplanuotus darbo užsakymus į Outlook kalendorių, pridėkite Microsoft Teams nuorodą ir matykite kalendoriaus užimtą laiką Torqvoice.",
+      "tenantHelp": "Užregistruokite programą Microsoft Entra (App registrations), leiskite asmenines ir darbo paskyras, pridėkite žemiau nurodytą nukreipimo URI kaip Web platformą, sukurkite kliento slaptažodį ir įklijuokite abu čia.",
+      "fields": {
+        "clientId": "Programos (kliento) ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "calendarId": "Kalendorius",
+        "pushEnabled": "Įtraukti suplanuotus darbo užsakymus į šį kalendorių",
+        "includeCustomer": "Įtraukti į įvykį kliento vardą ir telefoną",
+        "addConference": "Pridėti Microsoft Teams nuorodą prie kiekvieno įvykio",
+        "addConferenceHelp": "Nuoroda rodoma darbo užsakyme, kad galėtumėte ja pasidalinti su klientu.",
+        "pullEnabled": "Rodyti šio kalendoriaus užimtą laiką Torqvoice kalendoriuje",
+        "pullEnabledHelp": "Tik skaitymui. Atnaujinama kas 15 minučių, viena savaitė atgal ir du mėnesiai į priekį."
+      }
+    },
+    "zoom": {
+      "description": "Pridėkite „Zoom“ susitikimą prie suplanuoto darbo užsakymo ir laikykite nuorodą jame: nuotolinei diagnostikai ar radiniams aptarti su klientu.",
+      "tenantHelp": "Zoom App Marketplace sukurkite General (user-managed) programą, pridėkite žemiau nurodytą peradresavimo URI, pridėkite apimtis meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting ir user:read:user, tada čia įklijuokite kliento ID ir slaptažodį.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "autoCreate": "Automatiškai kurti „Zoom“ susitikimą kiekvienam suplanuotam darbo užsakymui",
+        "autoCreateHelp": "Išjungta: vaizdo skambutį prie darbo užsakymo pridedate patys jo puslapyje. Įjungta: kiekvienas darbo užsakymas su pradžios laiku gauna susitikimą, kuris perkeliamas perplanavus ir pašalinamas atšaukus. Bet kuriuo atveju klientas nuorodą gauna tik tada, kai ją išsiunčiate.",
+        "includeCustomer": "Įtraukti kliento vardą į susitikimo temą",
+        "joinBeforeHost": "Leisti klientui prisijungti anksčiau už jus",
+        "waitingRoom": "Naudoti laukimo kambarį"
+      }
+    }
+  }
+}

+ 20 - 1
messages/lt/service.json

@@ -34,7 +34,8 @@
     "delete": "Ištrinti",
     "unpaid": "Neapmokėta",
     "unsavedChanges": "Neišsaugoti pakeitimai",
-    "saved": "Išsaugota"
+    "saved": "Išsaugota",
+    "joinCall": "Prisijungti prie vaizdo skambučio"
   },
   "basicInfo": {
     "title": "Pagrindinė informacija",
@@ -410,5 +411,23 @@
     "remove": "Pašalinti šį punktą",
     "notAnswered": "Kol kas nieko nenustatyta",
     "answered": "Nustatymų: {count}"
+  },
+  "videoCall": {
+    "title": "Vaizdo skambutis",
+    "none": "Šis darbo užsakymas neturi vaizdo skambučio.",
+    "needsSchedule": "Pirmiausia nustatykite pradžios laiką. Susitikimas seka tvarkaraštį.",
+    "add": "Pridėti {provider} susitikimą",
+    "addedHere": "pridėta iš šio darbo užsakymo",
+    "fromCalendar": "iš kalendoriaus įvykio",
+    "join": "Prisijungti",
+    "copy": "Kopijuoti nuorodą",
+    "copied": "Nuoroda nukopijuota",
+    "remove": "Pašalinti",
+    "removeTitle": "Pašalinti vaizdo skambutį?",
+    "removeDescription": "Susitikimas ištrinamas pas teikėją. Jau turintys nuorodą nebegalės prisijungti.",
+    "created": "Susitikimas sukurtas. Nuoroda yra darbo užsakyme.",
+    "removed": "Susitikimas pašalintas",
+    "failed": "Kažkas nutiko ne taip",
+    "shareHint": "Klientas šią nuorodą gauna tik tada, kai ją išsiunčiate. Nukopijuokite ją į žinutę arba pridėkite prie pranešimo."
   }
 }

+ 5 - 1
messages/lt/settings.json

@@ -7,7 +7,7 @@
       "billing": "Atsiskaitymai ir dokumentai",
       "communications": "Komunikacija",
       "workshop": "Dirbtuvės",
-      "integrations": "Integracijos",
+      "integrations": "Programos ir API",
       "system": "Sistema"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Ataskaitų grafikas",
         "description": "Automatinis ataskaitų siuntimas"
       },
+      "integrations": {
+        "title": "Integracijos",
+        "description": "Kalendoriai, vaizdo skambučiai ir prijungtos paslaugos"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Realaus laiko įvykių pranešimai"

+ 6 - 2
messages/nb/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Registrerte et WhatsApp-nummer",
     "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto",
     "team_createDefaultRoles": "Opprettet standardrollene",
-    "settings_setCustomFieldPlacement": "Oppdaterte plassering av egendefinert felt"
+    "settings_setCustomFieldPlacement": "Oppdaterte plassering av egendefinert felt",
+    "integration_connect": "Koblet til en integrasjon",
+    "integration_disconnect": "Koblet fra en integrasjon"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -275,6 +277,8 @@
     "work_bay_create": "Opprettet arbeidsplass «{name}»",
     "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
     "work_bay_delete": "Slettet arbeidsplass {id}",
-    "settings_setCustomFieldPlacement": "Endret hvor et egendefinert felt skrives ut"
+    "settings_setCustomFieldPlacement": "Endret hvor et egendefinert felt skrives ut",
+    "integration_connect": "Koblet til {name}",
+    "integration_disconnect": "Koblet fra {name}"
   }
 }

+ 4 - 2
messages/nb/calendar.json

@@ -34,7 +34,8 @@
     "services": "Tjenester",
     "reminders": "Påminnelser",
     "quotes": "Tilbud",
-    "messages": "Meldinger"
+    "messages": "Meldinger",
+    "external": "Tilkoblede kalendere"
   },
   "selectVehicle": "Velg kjøretøy for arbeidsordre",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Forfalt",
       "upcoming": "Kommende",
       "failed": "Mislyktes",
-      "queued": "I kø"
+      "queued": "I kø",
+      "busy": "Opptatt"
     },
     "type": {
       "service": "Tjeneste",

+ 160 - 0
messages/nb/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integrasjoner",
+    "description": "Koble til tjenestene verkstedet allerede bruker. Hver integrasjon settes opp én gang og kjører i bakgrunnen.",
+    "all": "Alle",
+    "empty": "Ingen integrasjoner i denne kategorien ennå.",
+    "notConnected": "Ikke tilkoblet",
+    "connect": "Koble til",
+    "manage": "Administrer",
+    "connectedTitle": "Tilkoblet",
+    "connectedDescription": "Tjenester dette verkstedet har satt opp. Åpne en for å sjekke status, endre innstillinger eller koble fra.",
+    "noneConnected": "Ingenting er koblet til ennå. Velg en tjeneste nedenfor for å komme i gang.",
+    "availableTitle": "Tilgjengelig",
+    "availableDescription": "Tjenester du kan legge til. Hver settes opp én gang og kjører i bakgrunnen.",
+    "allConnected": "Alle tilgjengelige tjenester er tilkoblet."
+  },
+  "categories": {
+    "calendar": "Kalender",
+    "conferencing": "Videosamtaler",
+    "accounting": "Regnskap",
+    "messaging": "Meldinger",
+    "payments": "Betalinger",
+    "automation": "Automatisering",
+    "storage": "Filer",
+    "registry": "Kjøretøyregister",
+    "other": "Annet"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Arbeidsordre til kalender",
+      "pull": "Opptatt tid fra kalender",
+      "conference": "Lenker til videosamtaler"
+    }
+  },
+  "statuses": {
+    "active": "Tilkoblet",
+    "error": "Trenger oppfølging",
+    "pending": "Oppsett pågår",
+    "disconnected": "Frakoblet"
+  },
+  "connection": {
+    "back": "Alle integrasjoner",
+    "account": "Tilkoblet konto",
+    "lastHealth": "Sist sjekket",
+    "lastSync": "Siste synkronisering",
+    "lastError": "Siste kjøring mislyktes",
+    "connected": "{name} er tilkoblet.",
+    "connectedShort": "Tilkoblet",
+    "connectWith": "Koble til {name}",
+    "connect": "Koble til",
+    "reconnect": "Koble til på nytt",
+    "disconnect": "Koble fra",
+    "disconnectTitle": "Koble fra {name}?",
+    "disconnectDescription": "Torqvoice glemmer kontoen og slutter å synkronisere. Hendelser som allerede er opprettet i kalenderen, blir liggende der.",
+    "disconnected": "Frakoblet",
+    "test": "Test tilkoblingen",
+    "testPassed": "Tilkoblingen fungerer",
+    "syncNow": "Synkroniser nå",
+    "syncQueued": "Synkronisering lagt i kø; den kjører innen ett minutt",
+    "backfill": "Send planlagte arbeidsordre",
+    "backfillQueued": "La planlagte arbeidsordre fra og med forrige uke i kø",
+    "retryQueued": "Jobben er lagt i kø på nytt",
+    "settingsTitle": "Innstillinger",
+    "settingsDescription": "Hva som skal synkroniseres og hvor. Endringer gjelder fra neste synkronisering.",
+    "settingsSaved": "Innstillinger lagret",
+    "save": "Lagre",
+    "loading": "Laster...",
+    "choose": "Velg...",
+    "planLocked": "Integrasjoner er ikke inkludert i abonnementet ditt.",
+    "ownAppTitle": "Denne installasjonen bruker din egen app hos leverandøren",
+    "redirectUri": "Omdirigerings-URI som må registreres hos leverandøren:"
+  },
+  "activity": {
+    "title": "Aktivitet",
+    "description": "Siste jobber og logglinjer for denne tilkoblingen.",
+    "retry": "Prøv igjen",
+    "jobStatus": {
+      "queued": "I kø",
+      "running": "Kjører",
+      "done": "Ferdig",
+      "failed": "Mislyktes",
+      "dead": "Ga opp"
+    }
+  },
+  "errors": {
+    "generic": "Noe gikk galt",
+    "demo": "Integrasjoner kan ikke kobles til i demoen.",
+    "forbidden": "Bare en administrator kan koble til integrasjoner.",
+    "unknown": "Ukjent integrasjon.",
+    "plan": "Denne integrasjonen er ikke inkludert i abonnementet ditt.",
+    "no-client": "Ingen app-legitimasjon er satt opp for denne leverandøren.",
+    "denied": "Du avbrøt påloggingen hos leverandøren.",
+    "vendor": "Leverandøren returnerte en feil. Prøv igjen.",
+    "invalid": "Svaret fra leverandøren var ufullstendig. Prøv igjen.",
+    "state": "Denne påloggingen har utløpt eller tilhører en annen økt. Start på nytt.",
+    "exchange": "Leverandøren avviste påloggingen. Sjekk app-legitimasjonen og prøv igjen.",
+    "identify": "Tilkoblet, men kontoen kunne ikke leses. Prøv å koble til på nytt."
+  },
+  "meeting": {
+    "join": "Bli med i videosamtale",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Fra tilkoblede kalendere",
+    "busy": "Opptatt"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Legg planlagte arbeidsordre i en Google Calendar, legg til en Google Meet-lenke og se kalenderens opptatte tid i Torqvoice.",
+      "tenantHelp": "Opprett en OAuth-klient i Google Cloud Console (APIs & Services → Credentials), aktiver Google Calendar API, legg til omdirigerings-URI-en nedenfor og lim inn klient-ID og hemmelighet her.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "calendarId": "Kalender",
+        "pushEnabled": "Legg planlagte arbeidsordre i denne kalenderen",
+        "includeCustomer": "Ta med kundens navn og telefonnummer i hendelsen",
+        "addConference": "Legg til en Google Meet-lenke i hver hendelse",
+        "addConferenceHelp": "Lenken vises på arbeidsordren slik at du kan dele den med kunden.",
+        "pullEnabled": "Vis denne kalenderens opptatte tid i Torqvoice-kalenderen",
+        "pullEnabledHelp": "Skrivebeskyttet. Oppdateres hvert 15. minutt, én uke bakover og to måneder fremover."
+      }
+    },
+    "microsoft-365": {
+      "description": "Legg planlagte arbeidsordre i en Outlook-kalender, legg til en Microsoft Teams-lenke og se kalenderens opptatte tid i Torqvoice.",
+      "tenantHelp": "Registrer en applikasjon i Microsoft Entra (App registrations), tillat både personlige kontoer og jobbkontoer, legg til omdirigerings-URI-en nedenfor som en Web-plattform, opprett en klienthemmelighet og lim inn begge her.",
+      "fields": {
+        "clientId": "Applikasjons-ID (klient-ID)",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "calendarId": "Kalender",
+        "pushEnabled": "Legg planlagte arbeidsordre i denne kalenderen",
+        "includeCustomer": "Ta med kundens navn og telefonnummer i hendelsen",
+        "addConference": "Legg til en Microsoft Teams-lenke i hver hendelse",
+        "addConferenceHelp": "Lenken vises på arbeidsordren slik at du kan dele den med kunden.",
+        "pullEnabled": "Vis denne kalenderens opptatte tid i Torqvoice-kalenderen",
+        "pullEnabledHelp": "Skrivebeskyttet. Oppdateres hvert 15. minutt, én uke bakover og to måneder fremover."
+      }
+    },
+    "zoom": {
+      "description": "Legg til et Zoom-møte på en planlagt arbeidsordre og behold lenken på den, for fjerndiagnose eller for å gå gjennom funn med kunden.",
+      "tenantHelp": "I Zoom App Marketplace oppretter du en General-app (user-managed), legger til redirect-URI-en under, legger til scopene meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting og user:read:user, og limer inn klient-ID og hemmelighet her.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "autoCreate": "Opprett et Zoom-møte automatisk for hver planlagte arbeidsordre",
+        "autoCreateHelp": "Av: du legger til videosamtalen selv fra arbeidsordrens side. På: hver arbeidsordre med starttid får et møte, som flyttes når den omplanlegges og fjernes når den kanselleres. Uansett får kunden lenken bare når du sender den.",
+        "includeCustomer": "Ta med kundens navn i møteemnet",
+        "joinBeforeHost": "La kunden bli med før deg",
+        "waitingRoom": "Bruk venterom"
+      }
+    }
+  }
+}

+ 20 - 1
messages/nb/service.json

@@ -34,7 +34,8 @@
     "delete": "Slett",
     "unpaid": "Ubetalt",
     "unsavedChanges": "Ulagrede endringer",
-    "saved": "Lagret"
+    "saved": "Lagret",
+    "joinCall": "Bli med i videosamtale"
   },
   "basicInfo": {
     "title": "Grunnleggende informasjon",
@@ -410,5 +411,23 @@
     "remove": "Fjern dette punktet",
     "notAnswered": "Ingenting funnet på dette ennå",
     "answered": "{count} funn på dette"
+  },
+  "videoCall": {
+    "title": "Videosamtale",
+    "none": "Ingen videosamtale på denne arbeidsordren.",
+    "needsSchedule": "Sett en starttid først. Møtet følger tidsplanen.",
+    "add": "Legg til {provider}-møte",
+    "addedHere": "lagt til fra denne arbeidsordren",
+    "fromCalendar": "fra kalenderhendelsen",
+    "join": "Bli med",
+    "copy": "Kopier lenke",
+    "copied": "Lenke kopiert",
+    "remove": "Fjern",
+    "removeTitle": "Fjerne videosamtalen?",
+    "removeDescription": "Møtet slettes hos leverandøren. De som allerede har lenken, kan ikke lenger bli med.",
+    "created": "Møte opprettet. Lenken ligger på arbeidsordren.",
+    "removed": "Møte fjernet",
+    "failed": "Noe gikk galt",
+    "shareHint": "Kunden får denne lenken bare når du sender den. Kopier den inn i en melding eller legg den ved et varsel."
   }
 }

+ 5 - 1
messages/nb/settings.json

@@ -7,7 +7,7 @@
       "billing": "Fakturering og dokumenter",
       "communications": "Kommunikasjon",
       "workshop": "Verksted",
-      "integrations": "Integrasjoner",
+      "integrations": "Apper og API",
       "system": "System"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Planlagte rapporter",
         "description": "Automatisk rapportlevering"
       },
+      "integrations": {
+        "title": "Integrasjoner",
+        "description": "Kalendere, videosamtaler og tilkoblede tjenester"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Sanntidsvarsler om hendelser"

+ 6 - 2
messages/nl/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "WhatsApp-nummer geregistreerd",
     "team_giveTechnicianTheApp": "Bordmonteur een account gegeven",
     "team_createDefaultRoles": "Standaardrollen aangemaakt",
-    "settings_setCustomFieldPlacement": "Plaatsing van aangepast veld bijgewerkt"
+    "settings_setCustomFieldPlacement": "Plaatsing van aangepast veld bijgewerkt",
+    "integration_connect": "Integratie gekoppeld",
+    "integration_disconnect": "Integratie losgekoppeld"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -275,6 +277,8 @@
     "work_bay_create": "Werkplek \"{name}\" aangemaakt",
     "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
     "work_bay_delete": "Werkplek {id} verwijderd",
-    "settings_setCustomFieldPlacement": "Wijzigde waar een aangepast veld wordt afgedrukt"
+    "settings_setCustomFieldPlacement": "Wijzigde waar een aangepast veld wordt afgedrukt",
+    "integration_connect": "{name} gekoppeld",
+    "integration_disconnect": "{name} losgekoppeld"
   }
 }

+ 4 - 2
messages/nl/calendar.json

@@ -34,7 +34,8 @@
     "services": "Diensten",
     "reminders": "Herinneringen",
     "quotes": "Offertes",
-    "messages": "Berichten"
+    "messages": "Berichten",
+    "external": "Gekoppelde agenda's"
   },
   "selectVehicle": "Selecteer voertuig voor werkorder",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Achterstallig",
       "upcoming": "Aanstaand",
       "failed": "Mislukt",
-      "queued": "In wachtrij"
+      "queued": "In wachtrij",
+      "busy": "Bezet"
     },
     "type": {
       "service": "Dienst",

+ 160 - 0
messages/nl/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integraties",
+    "description": "Koppel de diensten die uw werkplaats al gebruikt. Elke integratie wordt eenmalig ingesteld en draait op de achtergrond.",
+    "all": "Alle",
+    "empty": "Nog geen integraties in deze categorie.",
+    "notConnected": "Niet gekoppeld",
+    "connect": "Koppelen",
+    "manage": "Beheren",
+    "connectedTitle": "Verbonden",
+    "connectedDescription": "Diensten die deze werkplaats heeft ingesteld. Open er een om de status te bekijken, instellingen te wijzigen of de verbinding te verbreken.",
+    "noneConnected": "Nog niets verbonden. Kies hieronder een dienst om te beginnen.",
+    "availableTitle": "Beschikbaar",
+    "availableDescription": "Diensten die u kunt toevoegen. Elke dienst stelt u één keer in en draait daarna op de achtergrond.",
+    "allConnected": "Alle beschikbare diensten zijn verbonden."
+  },
+  "categories": {
+    "calendar": "Agenda",
+    "conferencing": "Videogesprekken",
+    "accounting": "Boekhouding",
+    "messaging": "Berichten",
+    "payments": "Betalingen",
+    "automation": "Automatisering",
+    "storage": "Bestanden",
+    "registry": "Voertuigregister",
+    "other": "Overig"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Werkorders naar agenda",
+      "pull": "Bezette tijd uit agenda",
+      "conference": "Links voor videogesprekken"
+    }
+  },
+  "statuses": {
+    "active": "Gekoppeld",
+    "error": "Aandacht vereist",
+    "pending": "Installatie bezig",
+    "disconnected": "Losgekoppeld"
+  },
+  "connection": {
+    "back": "Alle integraties",
+    "account": "Gekoppeld account",
+    "lastHealth": "Laatst gecontroleerd",
+    "lastSync": "Laatste synchronisatie",
+    "lastError": "De laatste uitvoering is mislukt",
+    "connected": "{name} is gekoppeld.",
+    "connectedShort": "Gekoppeld",
+    "connectWith": "{name} koppelen",
+    "connect": "Koppelen",
+    "reconnect": "Opnieuw koppelen",
+    "disconnect": "Loskoppelen",
+    "disconnectTitle": "{name} loskoppelen?",
+    "disconnectDescription": "Torqvoice vergeet het account en stopt met synchroniseren. Afspraken die al in de agenda zijn aangemaakt, blijven daar staan.",
+    "disconnected": "Losgekoppeld",
+    "test": "Verbinding testen",
+    "testPassed": "Verbinding werkt",
+    "syncNow": "Nu synchroniseren",
+    "syncQueued": "Synchronisatie in wachtrij; wordt binnen een minuut uitgevoerd",
+    "backfill": "Geplande werkorders doorsturen",
+    "backfillQueued": "Geplande werkorders vanaf afgelopen week in de wachtrij gezet",
+    "retryQueued": "Taak opnieuw in de wachtrij gezet",
+    "settingsTitle": "Instellingen",
+    "settingsDescription": "Wat er wordt gesynchroniseerd en waarheen. Wijzigingen gelden vanaf de volgende synchronisatie.",
+    "settingsSaved": "Instellingen opgeslagen",
+    "save": "Opslaan",
+    "loading": "Laden...",
+    "choose": "Kiezen...",
+    "planLocked": "Integraties zijn niet inbegrepen in uw abonnement.",
+    "ownAppTitle": "Deze installatie gebruikt uw eigen app bij de provider",
+    "redirectUri": "Redirect-URI om bij de provider te registreren:"
+  },
+  "activity": {
+    "title": "Activiteit",
+    "description": "Recente taken en logregels voor deze koppeling.",
+    "retry": "Opnieuw proberen",
+    "jobStatus": {
+      "queued": "In wachtrij",
+      "running": "Bezig",
+      "done": "Klaar",
+      "failed": "Mislukt",
+      "dead": "Opgegeven"
+    }
+  },
+  "errors": {
+    "generic": "Er is iets misgegaan",
+    "demo": "Integraties kunnen niet worden gekoppeld in de demo.",
+    "forbidden": "Alleen een beheerder kan integraties koppelen.",
+    "unknown": "Onbekende integratie.",
+    "plan": "Deze integratie is niet inbegrepen in uw abonnement.",
+    "no-client": "Er zijn geen app-gegevens geconfigureerd voor deze provider.",
+    "denied": "U heeft het aanmelden bij de provider geannuleerd.",
+    "vendor": "De provider gaf een fout terug. Probeer het opnieuw.",
+    "invalid": "Het antwoord van de provider was onvolledig. Probeer het opnieuw.",
+    "state": "Deze aanmelding is verlopen of hoort bij een andere sessie. Begin opnieuw.",
+    "exchange": "De provider heeft de aanmelding geweigerd. Controleer de app-gegevens en probeer het opnieuw.",
+    "identify": "Gekoppeld, maar het account kon niet worden gelezen. Probeer opnieuw te koppelen."
+  },
+  "meeting": {
+    "join": "Deelnemen aan videogesprek",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Uit gekoppelde agenda's",
+    "busy": "Bezet"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Zet geplande werkorders in een Google Calendar, voeg een Google Meet-link toe en bekijk de bezette tijd van de agenda in Torqvoice.",
+      "tenantHelp": "Maak een OAuth-client aan in Google Cloud Console (APIs & Services → Credentials), schakel de Google Calendar API in, voeg de onderstaande redirect-URI toe en plak hier de client-ID en het geheim.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "calendarId": "Agenda",
+        "pushEnabled": "Geplande werkorders aan deze agenda toevoegen",
+        "includeCustomer": "Naam en telefoonnummer van de klant in de afspraak opnemen",
+        "addConference": "Een Google Meet-link aan elke afspraak toevoegen",
+        "addConferenceHelp": "De link wordt op de werkorder getoond, zodat u die met de klant kunt delen.",
+        "pullEnabled": "Bezette tijd van deze agenda in de Torqvoice-agenda tonen",
+        "pullEnabledHelp": "Alleen-lezen. Elke 15 minuten vernieuwd, één week terug en twee maanden vooruit."
+      }
+    },
+    "microsoft-365": {
+      "description": "Zet geplande werkorders in een Outlook-agenda, voeg een Microsoft Teams-link toe en bekijk de bezette tijd van de agenda in Torqvoice.",
+      "tenantHelp": "Registreer een toepassing in Microsoft Entra (App registrations), sta persoonlijke en werkaccounts toe, voeg de onderstaande redirect-URI toe als Web-platform, maak een clientgeheim aan en plak beide hier.",
+      "fields": {
+        "clientId": "Toepassings-ID (client-ID)",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "calendarId": "Agenda",
+        "pushEnabled": "Geplande werkorders aan deze agenda toevoegen",
+        "includeCustomer": "Naam en telefoonnummer van de klant in de afspraak opnemen",
+        "addConference": "Een Microsoft Teams-link aan elke afspraak toevoegen",
+        "addConferenceHelp": "De link wordt op de werkorder getoond, zodat u die met de klant kunt delen.",
+        "pullEnabled": "Bezette tijd van deze agenda in de Torqvoice-agenda tonen",
+        "pullEnabledHelp": "Alleen-lezen. Elke 15 minuten vernieuwd, één week terug en twee maanden vooruit."
+      }
+    },
+    "zoom": {
+      "description": "Voeg een Zoom-vergadering toe aan een ingeplande werkorder en houd de deelnamelink erop, voor diagnose op afstand of om bevindingen met de klant door te nemen.",
+      "tenantHelp": "Maak in de Zoom App Marketplace een General-app (user-managed), voeg de onderstaande redirect-URI toe, voeg de scopes meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting en user:read:user toe en plak hier de client-ID en het client secret.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "autoCreate": "Automatisch een Zoom-vergadering aanmaken voor elke ingeplande werkorder",
+        "autoCreateHelp": "Uit: u voegt het videogesprek zelf toe aan een werkorder, vanaf de pagina ervan. Aan: elke werkorder met een starttijd krijgt een vergadering, die meeschuift bij verplaatsen en verdwijnt bij annuleren. In beide gevallen krijgt de klant de link alleen als u die verstuurt.",
+        "includeCustomer": "Naam van de klant in het onderwerp opnemen",
+        "joinBeforeHost": "Klant vóór u laten deelnemen",
+        "waitingRoom": "Wachtkamer gebruiken"
+      }
+    }
+  }
+}

+ 20 - 1
messages/nl/service.json

@@ -34,7 +34,8 @@
     "delete": "Verwijderen",
     "unpaid": "Onbetaald",
     "unsavedChanges": "Niet-opgeslagen wijzigingen",
-    "saved": "Opgeslagen"
+    "saved": "Opgeslagen",
+    "joinCall": "Deelnemen aan videogesprek"
   },
   "basicInfo": {
     "title": "Basisinformatie",
@@ -410,5 +411,23 @@
     "remove": "Dit punt verwijderen",
     "notAnswered": "Hier is nog niets op gevonden",
     "answered": "{count} bevinding(en) hierop"
+  },
+  "videoCall": {
+    "title": "Videogesprek",
+    "none": "Geen videogesprek op deze werkorder.",
+    "needsSchedule": "Stel eerst een starttijd in. De vergadering volgt de planning.",
+    "add": "{provider}-vergadering toevoegen",
+    "addedHere": "toegevoegd vanaf deze werkorder",
+    "fromCalendar": "uit de agenda-afspraak",
+    "join": "Deelnemen",
+    "copy": "Link kopiëren",
+    "copied": "Link gekopieerd",
+    "remove": "Verwijderen",
+    "removeTitle": "Videogesprek verwijderen?",
+    "removeDescription": "De vergadering wordt bij de aanbieder verwijderd. Wie de link al heeft, kan niet meer deelnemen.",
+    "created": "Vergadering aangemaakt. De link staat op de werkorder.",
+    "removed": "Vergadering verwijderd",
+    "failed": "Er is iets misgegaan",
+    "shareHint": "De klant krijgt deze link alleen als u die verstuurt. Kopieer hem in een bericht of voeg hem toe aan een melding."
   }
 }

+ 5 - 1
messages/nl/settings.json

@@ -7,7 +7,7 @@
       "billing": "Facturatie en documenten",
       "communications": "Communicatie",
       "workshop": "Werkplaats",
-      "integrations": "Integraties",
+      "integrations": "Apps en API",
       "system": "Systeem"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Rapportplanning",
         "description": "Automatische rapportlevering"
       },
+      "integrations": {
+        "title": "Integraties",
+        "description": "Agenda's, videogesprekken en gekoppelde diensten"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Realtime gebeurtenismeldingen"

+ 6 - 2
messages/pl/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Zarejestrowano numer WhatsApp",
     "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy",
     "team_createDefaultRoles": "Utworzono role standardowe",
-    "settings_setCustomFieldPlacement": "Zaktualizowano miejsce pola niestandardowego"
+    "settings_setCustomFieldPlacement": "Zaktualizowano miejsce pola niestandardowego",
+    "integration_connect": "Połączono integrację",
+    "integration_disconnect": "Rozłączono integrację"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -275,6 +277,8 @@
     "work_bay_create": "Utworzono stanowisko „{name}”",
     "work_bay_update": "Zaktualizowano stanowisko „{name}”",
     "work_bay_delete": "Usunięto stanowisko {id}",
-    "settings_setCustomFieldPlacement": "Zmieniono miejsce drukowania pola niestandardowego"
+    "settings_setCustomFieldPlacement": "Zmieniono miejsce drukowania pola niestandardowego",
+    "integration_connect": "Połączono {name}",
+    "integration_disconnect": "Rozłączono {name}"
   }
 }

+ 4 - 2
messages/pl/calendar.json

@@ -34,7 +34,8 @@
     "services": "Usługi",
     "reminders": "Przypomnienia",
     "quotes": "Wyceny",
-    "messages": "Wiadomości"
+    "messages": "Wiadomości",
+    "external": "Połączone kalendarze"
   },
   "selectVehicle": "Wybierz pojazd do zlecenia",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Zaległe",
       "upcoming": "Nadchodzące",
       "failed": "Nieudane",
-      "queued": "W kolejce"
+      "queued": "W kolejce",
+      "busy": "Zajęte"
     },
     "type": {
       "service": "Usługa",

+ 160 - 0
messages/pl/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integracje",
+    "description": "Połącz usługi, z których warsztat już korzysta. Każdą integrację konfigurujesz raz, a potem działa w tle.",
+    "all": "Wszystkie",
+    "empty": "W tej kategorii nie ma jeszcze integracji.",
+    "notConnected": "Niepołączono",
+    "connect": "Połącz",
+    "manage": "Zarządzaj",
+    "connectedTitle": "Połączone",
+    "connectedDescription": "Usługi skonfigurowane w tym warsztacie. Otwórz jedną, aby sprawdzić stan, zmienić ustawienia lub rozłączyć.",
+    "noneConnected": "Nic jeszcze nie jest połączone. Wybierz usługę poniżej, aby zacząć.",
+    "availableTitle": "Dostępne",
+    "availableDescription": "Usługi, które możesz dodać. Każdą konfiguruje się raz, a potem działa w tle.",
+    "allConnected": "Wszystkie dostępne usługi są połączone."
+  },
+  "categories": {
+    "calendar": "Kalendarz",
+    "conferencing": "Rozmowy wideo",
+    "accounting": "Księgowość",
+    "messaging": "Wiadomości",
+    "payments": "Płatności",
+    "automation": "Automatyzacja",
+    "storage": "Pliki",
+    "registry": "Rejestr pojazdów",
+    "other": "Inne"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Zlecenia do kalendarza",
+      "pull": "Zajęty czas z kalendarza",
+      "conference": "Linki do rozmów wideo"
+    }
+  },
+  "statuses": {
+    "active": "Połączono",
+    "error": "Wymaga uwagi",
+    "pending": "Konfiguracja w toku",
+    "disconnected": "Rozłączono"
+  },
+  "connection": {
+    "back": "Wszystkie integracje",
+    "account": "Połączone konto",
+    "lastHealth": "Ostatnie sprawdzenie",
+    "lastSync": "Ostatnia synchronizacja",
+    "lastError": "Ostatnie uruchomienie nie powiodło się",
+    "connected": "{name} jest połączony.",
+    "connectedShort": "Połączono",
+    "connectWith": "Połącz {name}",
+    "connect": "Połącz",
+    "reconnect": "Połącz ponownie",
+    "disconnect": "Rozłącz",
+    "disconnectTitle": "Rozłączyć {name}?",
+    "disconnectDescription": "Torqvoice zapomni konto i przestanie synchronizować. Wydarzenia już utworzone w kalendarzu pozostaną.",
+    "disconnected": "Rozłączono",
+    "test": "Testuj połączenie",
+    "testPassed": "Połączenie działa",
+    "syncNow": "Synchronizuj teraz",
+    "syncQueued": "Synchronizacja w kolejce; uruchomi się w ciągu minuty",
+    "backfill": "Wyślij zaplanowane zlecenia",
+    "backfillQueued": "Dodano do kolejki zaplanowane zlecenia od ubiegłego tygodnia wzwyż",
+    "retryQueued": "Zadanie ponownie w kolejce",
+    "settingsTitle": "Ustawienia",
+    "settingsDescription": "Co synchronizować i dokąd. Zmiany obowiązują od następnej synchronizacji.",
+    "settingsSaved": "Zapisano ustawienia",
+    "save": "Zapisz",
+    "loading": "Ładowanie...",
+    "choose": "Wybierz...",
+    "planLocked": "Integracje nie są objęte Twoim planem.",
+    "ownAppTitle": "Ta instalacja używa własnej aplikacji u dostawcy",
+    "redirectUri": "Adres przekierowania (redirect URI) do zarejestrowania u dostawcy:"
+  },
+  "activity": {
+    "title": "Aktywność",
+    "description": "Ostatnie zadania i wpisy dziennika dla tego połączenia.",
+    "retry": "Ponów",
+    "jobStatus": {
+      "queued": "W kolejce",
+      "running": "W trakcie",
+      "done": "Gotowe",
+      "failed": "Nieudane",
+      "dead": "Przerwano"
+    }
+  },
+  "errors": {
+    "generic": "Coś poszło nie tak",
+    "demo": "W wersji demo nie można łączyć integracji.",
+    "forbidden": "Tylko administrator może łączyć integracje.",
+    "unknown": "Nieznana integracja.",
+    "plan": "Ta integracja nie jest objęta Twoim planem.",
+    "no-client": "Dla tego dostawcy nie skonfigurowano danych aplikacji.",
+    "denied": "Anulowano logowanie u dostawcy.",
+    "vendor": "Dostawca zwrócił błąd. Spróbuj ponownie.",
+    "invalid": "Odpowiedź dostawcy była niekompletna. Spróbuj ponownie.",
+    "state": "To logowanie wygasło lub należy do innej sesji. Zacznij od nowa.",
+    "exchange": "Dostawca odrzucił logowanie. Sprawdź dane aplikacji i spróbuj ponownie.",
+    "identify": "Połączono, ale nie udało się odczytać konta. Spróbuj połączyć ponownie."
+  },
+  "meeting": {
+    "join": "Dołącz do rozmowy wideo",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Z połączonych kalendarzy",
+    "busy": "Zajęte"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Dodawaj zaplanowane zlecenia do Google Calendar, dołączaj link Google Meet i zobacz zajęty czas z kalendarza w Torqvoice.",
+      "tenantHelp": "Utwórz klienta OAuth w Google Cloud Console (APIs & Services → Credentials), włącz Google Calendar API, dodaj poniższy adres przekierowania i wklej tutaj identyfikator klienta oraz sekret.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "calendarId": "Kalendarz",
+        "pushEnabled": "Dodawaj zaplanowane zlecenia do tego kalendarza",
+        "includeCustomer": "Umieść w wydarzeniu imię i nazwisko oraz telefon klienta",
+        "addConference": "Dodaj link Google Meet do każdego wydarzenia",
+        "addConferenceHelp": "Link jest widoczny na zleceniu, więc możesz go przekazać klientowi.",
+        "pullEnabled": "Pokazuj zajęty czas z tego kalendarza w kalendarzu Torqvoice",
+        "pullEnabledHelp": "Tylko do odczytu. Odświeżane co 15 minut, tydzień wstecz i dwa miesiące do przodu."
+      }
+    },
+    "microsoft-365": {
+      "description": "Dodawaj zaplanowane zlecenia do kalendarza Outlook, dołączaj link Microsoft Teams i zobacz zajęty czas z kalendarza w Torqvoice.",
+      "tenantHelp": "Zarejestruj aplikację w Microsoft Entra (App registrations), zezwól na konta osobiste i służbowe, dodaj poniższy adres przekierowania jako platformę Web, utwórz sekret klienta i wklej oba tutaj.",
+      "fields": {
+        "clientId": "Identyfikator aplikacji (klienta)",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "calendarId": "Kalendarz",
+        "pushEnabled": "Dodawaj zaplanowane zlecenia do tego kalendarza",
+        "includeCustomer": "Umieść w wydarzeniu imię i nazwisko oraz telefon klienta",
+        "addConference": "Dodaj link Microsoft Teams do każdego wydarzenia",
+        "addConferenceHelp": "Link jest widoczny na zleceniu, więc możesz go przekazać klientowi.",
+        "pullEnabled": "Pokazuj zajęty czas z tego kalendarza w kalendarzu Torqvoice",
+        "pullEnabledHelp": "Tylko do odczytu. Odświeżane co 15 minut, tydzień wstecz i dwa miesiące do przodu."
+      }
+    },
+    "zoom": {
+      "description": "Dodaj spotkanie Zoom do zaplanowanego zlecenia i zachowaj na nim link, do zdalnej diagnostyki lub omówienia ustaleń z klientem.",
+      "tenantHelp": "W Zoom App Marketplace utwórz aplikację General (user-managed), dodaj poniższy adres przekierowania, dodaj zakresy meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting i user:read:user, a następnie wklej tutaj identyfikator i sekret klienta.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "autoCreate": "Automatycznie twórz spotkanie Zoom dla każdego zaplanowanego zlecenia",
+        "autoCreateHelp": "Wyłączone: rozmowę wideo dodajesz do zlecenia samodzielnie, z jego strony. Włączone: każde zlecenie z godziną rozpoczęcia dostaje spotkanie, przesuwane przy zmianie terminu i usuwane przy anulowaniu. W obu przypadkach klient dostaje link tylko wtedy, gdy go wyślesz.",
+        "includeCustomer": "Dodaj nazwę klienta do tematu spotkania",
+        "joinBeforeHost": "Pozwól klientowi dołączyć przed Tobą",
+        "waitingRoom": "Używaj poczekalni"
+      }
+    }
+  }
+}

+ 20 - 1
messages/pl/service.json

@@ -34,7 +34,8 @@
     "delete": "Usuń",
     "unpaid": "Niezapłacone",
     "unsavedChanges": "Niezapisane zmiany",
-    "saved": "Zapisano"
+    "saved": "Zapisano",
+    "joinCall": "Dołącz do rozmowy wideo"
   },
   "basicInfo": {
     "title": "Informacje Podstawowe",
@@ -410,5 +411,23 @@
     "remove": "Usuń ten punkt",
     "notAnswered": "Nic jeszcze nie ustalono",
     "answered": "Ustalenia: {count}"
+  },
+  "videoCall": {
+    "title": "Rozmowa wideo",
+    "none": "To zlecenie nie ma rozmowy wideo.",
+    "needsSchedule": "Najpierw ustaw godzinę rozpoczęcia. Spotkanie podąża za harmonogramem.",
+    "add": "Dodaj spotkanie {provider}",
+    "addedHere": "dodane z tego zlecenia",
+    "fromCalendar": "z wydarzenia w kalendarzu",
+    "join": "Dołącz",
+    "copy": "Kopiuj link",
+    "copied": "Link skopiowany",
+    "remove": "Usuń",
+    "removeTitle": "Usunąć rozmowę wideo?",
+    "removeDescription": "Spotkanie zostanie usunięte u dostawcy. Osoby, które mają już link, nie będą mogły dołączyć.",
+    "created": "Spotkanie utworzone. Link jest na zleceniu.",
+    "removed": "Spotkanie usunięte",
+    "failed": "Coś poszło nie tak",
+    "shareHint": "Klient dostaje ten link tylko wtedy, gdy go wyślesz. Skopiuj go do wiadomości lub dodaj do powiadomienia."
   }
 }

+ 5 - 1
messages/pl/settings.json

@@ -7,7 +7,7 @@
       "billing": "Fakturowanie i dokumenty",
       "communications": "Komunikacja",
       "workshop": "Warsztat",
-      "integrations": "Integracje",
+      "integrations": "Aplikacje i API",
       "system": "System"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Harmonogram raportów",
         "description": "Automatyczne wysyłanie raportów"
       },
+      "integrations": {
+        "title": "Integracje",
+        "description": "Kalendarze, rozmowy wideo i połączone usługi"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Powiadomienia o zdarzeniach w czasie rzeczywistym"

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

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Número do WhatsApp registrado",
     "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro",
     "team_createDefaultRoles": "Funções padrão criadas",
-    "settings_setCustomFieldPlacement": "Posição de campo personalizado atualizada"
+    "settings_setCustomFieldPlacement": "Posição de campo personalizado atualizada",
+    "integration_connect": "Integração conectada",
+    "integration_disconnect": "Integração desconectada"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -275,6 +277,8 @@
     "work_bay_create": "Baia \"{name}\" criada",
     "work_bay_update": "Baia \"{name}\" atualizada",
     "work_bay_delete": "Baia {id} excluída",
-    "settings_setCustomFieldPlacement": "Alterou onde um campo personalizado é impresso"
+    "settings_setCustomFieldPlacement": "Alterou onde um campo personalizado é impresso",
+    "integration_connect": "{name} conectado",
+    "integration_disconnect": "{name} desconectado"
   }
 }

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

@@ -34,7 +34,8 @@
     "services": "Serviços",
     "reminders": "Lembretes",
     "quotes": "Orçamentos",
-    "messages": "Mensagens"
+    "messages": "Mensagens",
+    "external": "Calendários conectados"
   },
   "selectVehicle": "Selecionar veículo para ordem de serviço",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Atrasado",
       "upcoming": "Próximo",
       "failed": "Falhou",
-      "queued": "Na fila"
+      "queued": "Na fila",
+      "busy": "Ocupado"
     },
     "type": {
       "service": "Serviço",

+ 160 - 0
messages/pt-BR/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Integrações",
+    "description": "Conecte os serviços que sua oficina já usa. Cada integração é configurada uma vez e funciona em segundo plano.",
+    "all": "Todas",
+    "empty": "Ainda não há integrações nesta categoria.",
+    "notConnected": "Não conectada",
+    "connect": "Conectar",
+    "manage": "Gerenciar",
+    "connectedTitle": "Conectados",
+    "connectedDescription": "Serviços que esta oficina configurou. Abra um para ver o status, alterar configurações ou desconectar.",
+    "noneConnected": "Nada conectado ainda. Escolha um serviço abaixo para começar.",
+    "availableTitle": "Disponíveis",
+    "availableDescription": "Serviços que você pode adicionar. Cada um é configurado uma vez e roda em segundo plano.",
+    "allConnected": "Todos os serviços disponíveis estão conectados."
+  },
+  "categories": {
+    "calendar": "Calendário",
+    "conferencing": "Videochamadas",
+    "accounting": "Contabilidade",
+    "messaging": "Mensagens",
+    "payments": "Pagamentos",
+    "automation": "Automação",
+    "storage": "Arquivos",
+    "registry": "Registro de veículos",
+    "other": "Outros"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Ordens de serviço no calendário",
+      "pull": "Horários ocupados do calendário",
+      "conference": "Links de videochamada"
+    }
+  },
+  "statuses": {
+    "active": "Conectada",
+    "error": "Precisa de atenção",
+    "pending": "Configuração em andamento",
+    "disconnected": "Desconectada"
+  },
+  "connection": {
+    "back": "Todas as integrações",
+    "account": "Conta conectada",
+    "lastHealth": "Última verificação",
+    "lastSync": "Última sincronização",
+    "lastError": "A última execução falhou",
+    "connected": "{name} está conectado.",
+    "connectedShort": "Conectado",
+    "connectWith": "Conectar {name}",
+    "connect": "Conectar",
+    "reconnect": "Reconectar",
+    "disconnect": "Desconectar",
+    "disconnectTitle": "Desconectar {name}?",
+    "disconnectDescription": "O Torqvoice esquece a conta e para de sincronizar. Os eventos já criados no calendário permanecem lá.",
+    "disconnected": "Desconectado",
+    "test": "Testar conexão",
+    "testPassed": "A conexão funciona",
+    "syncNow": "Sincronizar agora",
+    "syncQueued": "Sincronização na fila; será executada em até um minuto",
+    "backfill": "Enviar ordens de serviço agendadas",
+    "backfillQueued": "Ordens de serviço agendadas a partir da semana passada colocadas na fila",
+    "retryQueued": "Tarefa colocada na fila novamente",
+    "settingsTitle": "Configurações",
+    "settingsDescription": "O que sincronizar e para onde. As alterações valem a partir da próxima sincronização.",
+    "settingsSaved": "Configurações salvas",
+    "save": "Salvar",
+    "loading": "Carregando...",
+    "choose": "Escolher...",
+    "planLocked": "As integrações não estão incluídas no seu plano.",
+    "ownAppTitle": "Esta instalação usa seu próprio aplicativo junto ao provedor",
+    "redirectUri": "URI de redirecionamento para registrar no provedor:"
+  },
+  "activity": {
+    "title": "Atividade",
+    "description": "Tarefas recentes e linhas de log desta conexão.",
+    "retry": "Tentar novamente",
+    "jobStatus": {
+      "queued": "Na fila",
+      "running": "Em execução",
+      "done": "Concluída",
+      "failed": "Falhou",
+      "dead": "Desistiu"
+    }
+  },
+  "errors": {
+    "generic": "Algo deu errado",
+    "demo": "Não é possível conectar integrações na demonstração.",
+    "forbidden": "Somente um administrador pode conectar integrações.",
+    "unknown": "Integração desconhecida.",
+    "plan": "Esta integração não está incluída no seu plano.",
+    "no-client": "Nenhuma credencial de aplicativo está configurada para este provedor.",
+    "denied": "Você cancelou o login no provedor.",
+    "vendor": "O provedor retornou um erro. Tente novamente.",
+    "invalid": "A resposta do provedor estava incompleta. Tente novamente.",
+    "state": "Este login expirou ou pertence a outra sessão. Comece de novo.",
+    "exchange": "O provedor recusou o login. Verifique as credenciais do aplicativo e tente novamente.",
+    "identify": "Conectado, mas não foi possível ler a conta. Tente reconectar."
+  },
+  "meeting": {
+    "join": "Entrar na videochamada",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "De calendários conectados",
+    "busy": "Ocupado"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Coloque as ordens de serviço agendadas em um Google Calendar, adicione um link do Google Meet e veja os horários ocupados do calendário no Torqvoice.",
+      "tenantHelp": "Crie um cliente OAuth no Google Cloud Console (APIs e serviços → Credenciais), ative a API do Google Calendar, adicione o URI de redirecionamento abaixo e cole aqui o ID do cliente e o segredo.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "calendarId": "Calendário",
+        "pushEnabled": "Adicionar ordens de serviço agendadas a este calendário",
+        "includeCustomer": "Incluir o nome e o telefone do cliente no evento",
+        "addConference": "Adicionar um link do Google Meet a cada evento",
+        "addConferenceHelp": "O link aparece na ordem de serviço para que você possa compartilhá-lo com o cliente.",
+        "pullEnabled": "Mostrar os horários ocupados deste calendário no calendário do Torqvoice",
+        "pullEnabledHelp": "Somente leitura. Atualizado a cada 15 minutos, uma semana para trás e dois meses para a frente."
+      }
+    },
+    "microsoft-365": {
+      "description": "Coloque as ordens de serviço agendadas em um calendário do Outlook, adicione um link do Microsoft Teams e veja os horários ocupados do calendário no Torqvoice.",
+      "tenantHelp": "Registre um aplicativo no Microsoft Entra (Registros de aplicativo), permita contas pessoais e corporativas, adicione o URI de redirecionamento abaixo como plataforma Web, crie um segredo do cliente e cole os dois aqui.",
+      "fields": {
+        "clientId": "ID do aplicativo (cliente)",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "calendarId": "Calendário",
+        "pushEnabled": "Adicionar ordens de serviço agendadas a este calendário",
+        "includeCustomer": "Incluir o nome e o telefone do cliente no evento",
+        "addConference": "Adicionar um link do Microsoft Teams a cada evento",
+        "addConferenceHelp": "O link aparece na ordem de serviço para que você possa compartilhá-lo com o cliente.",
+        "pullEnabled": "Mostrar os horários ocupados deste calendário no calendário do Torqvoice",
+        "pullEnabledHelp": "Somente leitura. Atualizado a cada 15 minutos, uma semana para trás e dois meses para a frente."
+      }
+    },
+    "zoom": {
+      "description": "Adicione uma reunião do Zoom a uma ordem de serviço agendada e mantenha o link nela, para diagnóstico remoto ou para mostrar as constatações ao cliente.",
+      "tenantHelp": "No Zoom App Marketplace, crie um app General (user-managed), adicione a URI de redirecionamento abaixo, adicione os escopos meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting e user:read:user, e cole aqui o ID e o segredo do cliente.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "autoCreate": "Criar uma reunião do Zoom automaticamente para cada ordem de serviço agendada",
+        "autoCreateHelp": "Desligado: você adiciona a videochamada à ordem de serviço pela página dela. Ligado: toda ordem com horário de início recebe uma reunião, movida ao reagendar e removida ao cancelar. Em ambos os casos o cliente só recebe o link quando você envia.",
+        "includeCustomer": "Incluir o nome do cliente no tópico da reunião",
+        "joinBeforeHost": "Permitir que o cliente entre antes de você",
+        "waitingRoom": "Usar sala de espera"
+      }
+    }
+  }
+}

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

@@ -34,7 +34,8 @@
     "delete": "Deletar",
     "unpaid": "Não Pago",
     "unsavedChanges": "Alterações não salvas",
-    "saved": "Salvo"
+    "saved": "Salvo",
+    "joinCall": "Entrar na videochamada"
   },
   "basicInfo": {
     "title": "Informações Básicas",
@@ -410,5 +411,23 @@
     "remove": "Remover este item",
     "notAnswered": "Nada encontrado sobre isso ainda",
     "answered": "{count} constatação(ões) sobre isso"
+  },
+  "videoCall": {
+    "title": "Videochamada",
+    "none": "Nenhuma videochamada nesta ordem de serviço.",
+    "needsSchedule": "Defina um horário de início primeiro. A reunião segue o agendamento.",
+    "add": "Adicionar reunião do {provider}",
+    "addedHere": "adicionada por esta ordem de serviço",
+    "fromCalendar": "do evento do calendário",
+    "join": "Entrar",
+    "copy": "Copiar link",
+    "copied": "Link copiado",
+    "remove": "Remover",
+    "removeTitle": "Remover a videochamada?",
+    "removeDescription": "A reunião é excluída no provedor. Quem já tem o link não poderá mais entrar.",
+    "created": "Reunião criada. O link está na ordem de serviço.",
+    "removed": "Reunião removida",
+    "failed": "Algo deu errado",
+    "shareHint": "O cliente só recebe este link quando você envia. Copie-o em uma mensagem ou adicione-o a uma notificação."
   }
 }

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

@@ -7,7 +7,7 @@
       "billing": "Faturamento e documentos",
       "communications": "Comunicacoes",
       "workshop": "Oficina",
-      "integrations": "Integrações",
+      "integrations": "Aplicativos e API",
       "system": "Sistema"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Agendamento de relatórios",
         "description": "Envio automático de relatórios"
       },
+      "integrations": {
+        "title": "Integrações",
+        "description": "Calendários, videochamadas e serviços conectados"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Notificações de eventos em tempo real"

+ 6 - 2
messages/ru/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "Зарегистрирован номер WhatsApp",
     "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись",
     "team_createDefaultRoles": "Созданы стандартные роли",
-    "settings_setCustomFieldPlacement": "Обновлено расположение пользовательского поля"
+    "settings_setCustomFieldPlacement": "Обновлено расположение пользовательского поля",
+    "integration_connect": "Подключена интеграция",
+    "integration_disconnect": "Отключена интеграция"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -275,6 +277,8 @@
     "work_bay_create": "Создан пост «{name}»",
     "work_bay_update": "Обновлён пост «{name}»",
     "work_bay_delete": "Удалён пост {id}",
-    "settings_setCustomFieldPlacement": "Изменено место печати пользовательского поля"
+    "settings_setCustomFieldPlacement": "Изменено место печати пользовательского поля",
+    "integration_connect": "Подключена интеграция {name}",
+    "integration_disconnect": "Отключена интеграция {name}"
   }
 }

+ 4 - 2
messages/ru/calendar.json

@@ -34,7 +34,8 @@
     "services": "Обслуживания",
     "reminders": "Напоминания",
     "quotes": "Предложения",
-    "messages": "Сообщения"
+    "messages": "Сообщения",
+    "external": "Подключённые календари"
   },
   "selectVehicle": "Выберите транспортное средство для заказ-наряда",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Просрочено",
       "upcoming": "Предстоящее",
       "failed": "Ошибка",
-      "queued": "В очереди"
+      "queued": "В очереди",
+      "busy": "Занято"
     },
     "type": {
       "service": "Обслуживание",

+ 160 - 0
messages/ru/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Интеграции",
+    "description": "Подключите сервисы, которыми ваша мастерская уже пользуется. Каждая интеграция настраивается один раз и работает в фоновом режиме.",
+    "all": "Все",
+    "empty": "В этой категории пока нет интеграций.",
+    "notConnected": "Не подключено",
+    "connect": "Подключить",
+    "manage": "Управлять",
+    "connectedTitle": "Подключено",
+    "connectedDescription": "Сервисы, настроенные в этой мастерской. Откройте сервис, чтобы проверить статус, изменить настройки или отключить его.",
+    "noneConnected": "Пока ничего не подключено. Выберите сервис ниже, чтобы начать.",
+    "availableTitle": "Доступно",
+    "availableDescription": "Сервисы, которые можно добавить. Каждый настраивается один раз и работает в фоне.",
+    "allConnected": "Все доступные сервисы подключены."
+  },
+  "categories": {
+    "calendar": "Календарь",
+    "conferencing": "Видеозвонки",
+    "accounting": "Бухгалтерия",
+    "messaging": "Сообщения",
+    "payments": "Платежи",
+    "automation": "Автоматизация",
+    "storage": "Файлы",
+    "registry": "Реестр транспортных средств",
+    "other": "Другое"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "Заказ-наряды в календарь",
+      "pull": "Занятое время из календаря",
+      "conference": "Ссылки на видеозвонки"
+    }
+  },
+  "statuses": {
+    "active": "Подключено",
+    "error": "Требует внимания",
+    "pending": "Идёт настройка",
+    "disconnected": "Отключено"
+  },
+  "connection": {
+    "back": "Все интеграции",
+    "account": "Подключённый аккаунт",
+    "lastHealth": "Последняя проверка",
+    "lastSync": "Последняя синхронизация",
+    "lastError": "Последний запуск завершился ошибкой",
+    "connected": "{name} подключено.",
+    "connectedShort": "Подключено",
+    "connectWith": "Подключить {name}",
+    "connect": "Подключить",
+    "reconnect": "Переподключить",
+    "disconnect": "Отключить",
+    "disconnectTitle": "Отключить {name}?",
+    "disconnectDescription": "Torqvoice забудет аккаунт и прекратит синхронизацию. События, уже созданные в календаре, останутся на месте.",
+    "disconnected": "Отключено",
+    "test": "Проверить подключение",
+    "testPassed": "Подключение работает",
+    "syncNow": "Синхронизировать сейчас",
+    "syncQueued": "Синхронизация поставлена в очередь и запустится в течение минуты",
+    "backfill": "Отправить запланированные заказ-наряды",
+    "backfillQueued": "Запланированные заказ-наряды за последнюю неделю и позже поставлены в очередь",
+    "retryQueued": "Задача снова поставлена в очередь",
+    "settingsTitle": "Настройки",
+    "settingsDescription": "Что синхронизировать и куда. Изменения применяются при следующей синхронизации.",
+    "settingsSaved": "Настройки сохранены",
+    "save": "Сохранить",
+    "loading": "Загрузка...",
+    "choose": "Выберите...",
+    "planLocked": "Интеграции не входят в ваш тариф.",
+    "ownAppTitle": "Эта установка использует ваше собственное приложение у провайдера",
+    "redirectUri": "URI перенаправления для регистрации у провайдера:"
+  },
+  "activity": {
+    "title": "Активность",
+    "description": "Последние задачи и записи журнала для этого подключения.",
+    "retry": "Повторить",
+    "jobStatus": {
+      "queued": "В очереди",
+      "running": "Выполняется",
+      "done": "Готово",
+      "failed": "Ошибка",
+      "dead": "Прекращено"
+    }
+  },
+  "errors": {
+    "generic": "Что-то пошло не так",
+    "demo": "В демо-режиме интеграции подключить нельзя.",
+    "forbidden": "Подключать интеграции может только администратор.",
+    "unknown": "Неизвестная интеграция.",
+    "plan": "Эта интеграция не входит в ваш тариф.",
+    "no-client": "Для этого провайдера не настроены данные приложения.",
+    "denied": "Вы отменили вход у провайдера.",
+    "vendor": "Провайдер вернул ошибку. Попробуйте ещё раз.",
+    "invalid": "Ответ провайдера был неполным. Попробуйте ещё раз.",
+    "state": "Срок этого входа истёк, или он относится к другой сессии. Начните заново.",
+    "exchange": "Провайдер отклонил вход. Проверьте данные приложения и попробуйте ещё раз.",
+    "identify": "Подключено, но не удалось прочитать данные аккаунта. Попробуйте переподключиться."
+  },
+  "meeting": {
+    "join": "Присоединиться к видеозвонку",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Из подключённых календарей",
+    "busy": "Занято"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Добавляйте запланированные заказ-наряды в Google Calendar, прикрепляйте ссылку Google Meet и видите занятое время календаря в Torqvoice.",
+      "tenantHelp": "Создайте OAuth-клиент в Google Cloud Console (APIs & Services → Credentials), включите Google Calendar API, добавьте указанный ниже URI перенаправления и вставьте сюда идентификатор клиента и секрет.",
+      "fields": {
+        "clientId": "Идентификатор клиента",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "calendarId": "Календарь",
+        "pushEnabled": "Добавлять запланированные заказ-наряды в этот календарь",
+        "includeCustomer": "Включать имя и телефон клиента в событие",
+        "addConference": "Добавлять ссылку Google Meet к каждому событию",
+        "addConferenceHelp": "Ссылка отображается в заказ-наряде, чтобы вы могли поделиться ею с клиентом.",
+        "pullEnabled": "Показывать занятое время этого календаря в календаре Torqvoice",
+        "pullEnabledHelp": "Только чтение. Обновляется каждые 15 минут: неделя назад и два месяца вперёд."
+      }
+    },
+    "microsoft-365": {
+      "description": "Добавляйте запланированные заказ-наряды в календарь Outlook, прикрепляйте ссылку Microsoft Teams и видите занятое время календаря в Torqvoice.",
+      "tenantHelp": "Зарегистрируйте приложение в Microsoft Entra (App registrations), разрешите личные и рабочие аккаунты, добавьте указанный ниже URI перенаправления как платформу Web, создайте секрет клиента и вставьте сюда оба значения.",
+      "fields": {
+        "clientId": "Идентификатор приложения (клиента)",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "calendarId": "Календарь",
+        "pushEnabled": "Добавлять запланированные заказ-наряды в этот календарь",
+        "includeCustomer": "Включать имя и телефон клиента в событие",
+        "addConference": "Добавлять ссылку Microsoft Teams к каждому событию",
+        "addConferenceHelp": "Ссылка отображается в заказ-наряде, чтобы вы могли поделиться ею с клиентом.",
+        "pullEnabled": "Показывать занятое время этого календаря в календаре Torqvoice",
+        "pullEnabledHelp": "Только чтение. Обновляется каждые 15 минут: неделя назад и два месяца вперёд."
+      }
+    },
+    "zoom": {
+      "description": "Добавьте встречу Zoom к запланированному заказ-наряду и держите ссылку на нём: для удалённой диагностики или разбора результатов с клиентом.",
+      "tenantHelp": "В Zoom App Marketplace создайте приложение General (user-managed), добавьте указанный ниже redirect URI, добавьте scope meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting и user:read:user, затем вставьте сюда client ID и секрет.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "autoCreate": "Автоматически создавать встречу Zoom для каждого запланированного заказ-наряда",
+        "autoCreateHelp": "Выкл.: вы сами добавляете видеозвонок к заказ-наряду на его странице. Вкл.: каждый заказ-наряд с временем начала получает встречу, которая переносится при изменении расписания и удаляется при отмене. В любом случае клиент получает ссылку, только когда вы её отправите.",
+        "includeCustomer": "Указывать имя клиента в теме встречи",
+        "joinBeforeHost": "Разрешить клиенту входить раньше вас",
+        "waitingRoom": "Использовать зал ожидания"
+      }
+    }
+  }
+}

+ 20 - 1
messages/ru/service.json

@@ -34,7 +34,8 @@
     "delete": "Удалить",
     "unpaid": "Не оплачено",
     "unsavedChanges": "Несохранённые изменения",
-    "saved": "Сохранено"
+    "saved": "Сохранено",
+    "joinCall": "Присоединиться к видеозвонку"
   },
   "basicInfo": {
     "title": "Основная информация",
@@ -410,5 +411,23 @@
     "remove": "Удалить этот пункт",
     "notAnswered": "По этому пункту пока ничего не найдено",
     "answered": "Найдено по этому пункту: {count}"
+  },
+  "videoCall": {
+    "title": "Видеозвонок",
+    "none": "У этого заказ-наряда нет видеозвонка.",
+    "needsSchedule": "Сначала укажите время начала. Встреча следует расписанию.",
+    "add": "Добавить встречу {provider}",
+    "addedHere": "добавлен из этого заказ-наряда",
+    "fromCalendar": "из события календаря",
+    "join": "Присоединиться",
+    "copy": "Копировать ссылку",
+    "copied": "Ссылка скопирована",
+    "remove": "Удалить",
+    "removeTitle": "Удалить видеозвонок?",
+    "removeDescription": "Встреча удаляется у провайдера. Те, у кого уже есть ссылка, больше не смогут присоединиться.",
+    "created": "Встреча создана. Ссылка на заказ-наряде.",
+    "removed": "Встреча удалена",
+    "failed": "Что-то пошло не так",
+    "shareHint": "Клиент получает эту ссылку, только когда вы её отправите. Скопируйте её в сообщение или добавьте в уведомление."
   }
 }

+ 5 - 1
messages/ru/settings.json

@@ -7,7 +7,7 @@
       "billing": "Счета и документы",
       "communications": "Коммуникации",
       "workshop": "Мастерская",
-      "integrations": "Интеграции",
+      "integrations": "Приложения и API",
       "system": "Система"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Расписание отчётов",
         "description": "Автоматическая отправка отчётов"
       },
+      "integrations": {
+        "title": "Интеграции",
+        "description": "Календари, видеозвонки и подключённые сервисы"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Уведомления о событиях в реальном времени"

+ 6 - 2
messages/tr/audit.json

@@ -151,7 +151,9 @@
     "settings_whatsappNumberRegistered": "WhatsApp numarası kaydedildi",
     "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi",
     "team_createDefaultRoles": "Standart roller oluşturuldu",
-    "settings_setCustomFieldPlacement": "Özel alan konumu güncellendi"
+    "settings_setCustomFieldPlacement": "Özel alan konumu güncellendi",
+    "integration_connect": "Entegrasyon bağlandı",
+    "integration_disconnect": "Entegrasyon bağlantısı kesildi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -275,6 +277,8 @@
     "work_bay_create": "\"{name}\" çalışma alanı oluşturuldu",
     "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
     "work_bay_delete": "{id} çalışma alanı silindi",
-    "settings_setCustomFieldPlacement": "Bir özel alanın yazdırıldığı yer değiştirildi"
+    "settings_setCustomFieldPlacement": "Bir özel alanın yazdırıldığı yer değiştirildi",
+    "integration_connect": "{name} bağlandı",
+    "integration_disconnect": "{name} bağlantısı kesildi"
   }
 }

+ 4 - 2
messages/tr/calendar.json

@@ -34,7 +34,8 @@
     "services": "Servisler",
     "reminders": "Hatırlatıcılar",
     "quotes": "Teklifler",
-    "messages": "Mesajlar"
+    "messages": "Mesajlar",
+    "external": "Bağlı takvimler"
   },
   "selectVehicle": "İş emri için araç seçin",
   "dateChoice": {
@@ -66,7 +67,8 @@
       "overdue": "Gecikmiş",
       "upcoming": "Yaklaşan",
       "failed": "Başarısız",
-      "queued": "Sırada"
+      "queued": "Sırada",
+      "busy": "Meşgul"
     },
     "type": {
       "service": "Servis",

+ 160 - 0
messages/tr/integrations.json

@@ -0,0 +1,160 @@
+{
+  "catalog": {
+    "title": "Entegrasyonlar",
+    "description": "Atölyenizin zaten kullandığı servisleri bağlayın. Her entegrasyon bir kez kurulur ve arka planda çalışır.",
+    "all": "Tümü",
+    "empty": "Bu kategoride henüz entegrasyon yok.",
+    "notConnected": "Bağlı değil",
+    "connect": "Bağlan",
+    "manage": "Yönet",
+    "connectedTitle": "Bağlı",
+    "connectedDescription": "Bu atölyenin kurduğu hizmetler. Durumunu görmek, ayarları değiştirmek veya bağlantıyı kesmek için birini açın.",
+    "noneConnected": "Henüz bağlı bir hizmet yok. Başlamak için aşağıdan bir hizmet seçin.",
+    "availableTitle": "Kullanılabilir",
+    "availableDescription": "Ekleyebileceğiniz hizmetler. Her biri bir kez kurulur ve arka planda çalışır.",
+    "allConnected": "Kullanılabilir tüm hizmetler bağlı."
+  },
+  "categories": {
+    "calendar": "Takvim",
+    "conferencing": "Görüntülü görüşmeler",
+    "accounting": "Muhasebe",
+    "messaging": "Mesajlaşma",
+    "payments": "Ödemeler",
+    "automation": "Otomasyon",
+    "storage": "Dosyalar",
+    "registry": "Araç kayıt sistemi",
+    "other": "Diğer"
+  },
+  "capabilities": {
+    "calendar": {
+      "push": "İş emirlerini takvime aktarma",
+      "pull": "Takvimden meşgul zamanlar",
+      "conference": "Görüntülü görüşme bağlantıları"
+    }
+  },
+  "statuses": {
+    "active": "Bağlı",
+    "error": "Dikkat gerekiyor",
+    "pending": "Kurulum sürüyor",
+    "disconnected": "Bağlantı kesildi"
+  },
+  "connection": {
+    "back": "Tüm entegrasyonlar",
+    "account": "Bağlı hesap",
+    "lastHealth": "Son kontrol",
+    "lastSync": "Son senkronizasyon",
+    "lastError": "Son çalıştırma başarısız oldu",
+    "connected": "{name} bağlandı.",
+    "connectedShort": "Bağlı",
+    "connectWith": "{name} bağlantısını kur",
+    "connect": "Bağlan",
+    "reconnect": "Yeniden bağlan",
+    "disconnect": "Bağlantıyı kes",
+    "disconnectTitle": "{name} bağlantısı kesilsin mi?",
+    "disconnectDescription": "Torqvoice hesabı unutur ve senkronizasyonu durdurur. Takvimde zaten oluşturulmuş etkinlikler orada kalır.",
+    "disconnected": "Bağlantı kesildi",
+    "test": "Bağlantıyı test et",
+    "testPassed": "Bağlantı çalışıyor",
+    "syncNow": "Şimdi senkronize et",
+    "syncQueued": "Senkronizasyon sıraya alındı; bir dakika içinde çalışır",
+    "backfill": "Planlanmış iş emirlerini gönder",
+    "backfillQueued": "Geçen haftadan itibaren planlanmış iş emirleri sıraya alındı",
+    "retryQueued": "İş yeniden sıraya alındı",
+    "settingsTitle": "Ayarlar",
+    "settingsDescription": "Neyin nereye senkronize edileceği. Değişiklikler bir sonraki senkronizasyonda uygulanır.",
+    "settingsSaved": "Ayarlar kaydedildi",
+    "save": "Kaydet",
+    "loading": "Yükleniyor...",
+    "choose": "Seçin...",
+    "planLocked": "Entegrasyonlar planınıza dahil değil.",
+    "ownAppTitle": "Bu kurulum, sağlayıcıda kendi uygulamanızı kullanıyor",
+    "redirectUri": "Sağlayıcıya kaydedilecek yönlendirme URI'si:"
+  },
+  "activity": {
+    "title": "Aktivite",
+    "description": "Bu bağlantıya ait son işler ve günlük satırları.",
+    "retry": "Yeniden dene",
+    "jobStatus": {
+      "queued": "Sırada",
+      "running": "Çalışıyor",
+      "done": "Tamamlandı",
+      "failed": "Başarısız",
+      "dead": "Vazgeçildi"
+    }
+  },
+  "errors": {
+    "generic": "Bir şeyler ters gitti",
+    "demo": "Demoda entegrasyon bağlanamaz.",
+    "forbidden": "Entegrasyonları yalnızca bir yönetici bağlayabilir.",
+    "unknown": "Bilinmeyen entegrasyon.",
+    "plan": "Bu entegrasyon planınıza dahil değil.",
+    "no-client": "Bu sağlayıcı için yapılandırılmış uygulama kimlik bilgisi yok.",
+    "denied": "Sağlayıcıdaki oturum açmayı iptal ettiniz.",
+    "vendor": "Sağlayıcı bir hata döndürdü. Yeniden deneyin.",
+    "invalid": "Sağlayıcının yanıtı eksikti. Yeniden deneyin.",
+    "state": "Bu oturum açma işleminin süresi doldu veya başka bir oturuma ait. Yeniden başlayın.",
+    "exchange": "Sağlayıcı oturum açmayı reddetti. Uygulama kimlik bilgilerini kontrol edip yeniden deneyin.",
+    "identify": "Bağlandı, ancak hesap okunamadı. Yeniden bağlanmayı deneyin."
+  },
+  "meeting": {
+    "join": "Görüntülü görüşmeye katıl",
+    "google-meet": "Google Meet",
+    "teams": "Microsoft Teams",
+    "zoom": "Zoom"
+  },
+  "calendar": {
+    "external": "Bağlı takvimlerden",
+    "busy": "Meşgul"
+  },
+  "connectors": {
+    "google-calendar": {
+      "description": "Planlanmış iş emirlerini bir Google Calendar takvimine ekleyin, Google Meet bağlantısı ekleyin ve takvimin meşgul zamanlarını Torqvoice içinde görün.",
+      "tenantHelp": "Google Cloud Console'da (APIs & Services → Credentials) bir OAuth istemcisi oluşturun, Google Calendar API'sini etkinleştirin, aşağıdaki yönlendirme URI'sini ekleyin ve istemci kimliği ile gizli anahtarı buraya yapıştırın.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "calendarId": "Takvim",
+        "pushEnabled": "Planlanmış iş emirlerini bu takvime ekle",
+        "includeCustomer": "Etkinliğe müşterinin adını ve telefonunu ekle",
+        "addConference": "Her etkinliğe bir Google Meet bağlantısı ekle",
+        "addConferenceHelp": "Bağlantı iş emrinde gösterilir, böylece müşteriyle paylaşabilirsiniz.",
+        "pullEnabled": "Bu takvimin meşgul zamanlarını Torqvoice takviminde göster",
+        "pullEnabledHelp": "Salt okunur. 15 dakikada bir yenilenir; bir hafta geriye ve iki ay ileriye."
+      }
+    },
+    "microsoft-365": {
+      "description": "Planlanmış iş emirlerini bir Outlook takvimine ekleyin, Microsoft Teams bağlantısı ekleyin ve takvimin meşgul zamanlarını Torqvoice içinde görün.",
+      "tenantHelp": "Microsoft Entra'da (App registrations) bir uygulama kaydedin, kişisel ve iş hesaplarına izin verin, aşağıdaki yönlendirme URI'sini Web platformu olarak ekleyin, bir istemci gizli anahtarı oluşturun ve her ikisini buraya yapıştırın.",
+      "fields": {
+        "clientId": "Uygulama (istemci) kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "calendarId": "Takvim",
+        "pushEnabled": "Planlanmış iş emirlerini bu takvime ekle",
+        "includeCustomer": "Etkinliğe müşterinin adını ve telefonunu ekle",
+        "addConference": "Her etkinliğe bir Microsoft Teams bağlantısı ekle",
+        "addConferenceHelp": "Bağlantı iş emrinde gösterilir, böylece müşteriyle paylaşabilirsiniz.",
+        "pullEnabled": "Bu takvimin meşgul zamanlarını Torqvoice takviminde göster",
+        "pullEnabledHelp": "Salt okunur. 15 dakikada bir yenilenir; bir hafta geriye ve iki ay ileriye."
+      }
+    },
+    "zoom": {
+      "description": "Planlanmış bir iş emrine Zoom toplantısı ekleyin ve katılım bağlantısını iş emrinde tutun; uzaktan arıza tespiti veya bulguları müşteriyle gözden geçirmek için.",
+      "tenantHelp": "Zoom App Marketplace'te bir General (user-managed) uygulaması oluşturun, aşağıdaki yönlendirme URI'sini ekleyin, meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting ve user:read:user kapsamlarını ekleyin ve istemci kimliği ile gizli anahtarı buraya yapıştırın.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "autoCreate": "Planlanmış her iş emri için otomatik olarak Zoom toplantısı oluştur",
+        "autoCreateHelp": "Kapalı: görüntülü görüşmeyi iş emrinin sayfasından kendiniz eklersiniz. Açık: başlangıç saati olan her iş emri bir toplantı alır; yeniden planlandığında taşınır, iptal edildiğinde kaldırılır. Her iki durumda da müşteri bağlantıyı yalnızca siz gönderdiğinizde alır.",
+        "includeCustomer": "Toplantı konusuna müşteri adını ekle",
+        "joinBeforeHost": "Müşterinin sizden önce katılmasına izin ver",
+        "waitingRoom": "Bekleme odası kullan"
+      }
+    }
+  }
+}

+ 20 - 1
messages/tr/service.json

@@ -34,7 +34,8 @@
     "delete": "Sil",
     "unpaid": "Ödenmedi",
     "unsavedChanges": "Kaydedilmemiş değişiklikler",
-    "saved": "Kaydedildi"
+    "saved": "Kaydedildi",
+    "joinCall": "Görüntülü görüşmeye katıl"
   },
   "basicInfo": {
     "title": "Temel Bilgiler",
@@ -410,5 +411,23 @@
     "remove": "Bu maddeyi kaldır",
     "notAnswered": "Bu konuda henüz bir şey bulunmadı",
     "answered": "Bu konuda {count} tespit"
+  },
+  "videoCall": {
+    "title": "Görüntülü görüşme",
+    "none": "Bu iş emrinde görüntülü görüşme yok.",
+    "needsSchedule": "Önce bir başlangıç saati belirleyin. Toplantı programı izler.",
+    "add": "{provider} toplantısı ekle",
+    "addedHere": "bu iş emrinden eklendi",
+    "fromCalendar": "takvim etkinliğinden",
+    "join": "Katıl",
+    "copy": "Bağlantıyı kopyala",
+    "copied": "Bağlantı kopyalandı",
+    "remove": "Kaldır",
+    "removeTitle": "Görüntülü görüşme kaldırılsın mı?",
+    "removeDescription": "Toplantı sağlayıcıda silinir. Bağlantıya sahip olanlar artık katılamaz.",
+    "created": "Toplantı oluşturuldu. Bağlantı iş emrinde.",
+    "removed": "Toplantı kaldırıldı",
+    "failed": "Bir şeyler ters gitti",
+    "shareHint": "Müşteri bu bağlantıyı yalnızca siz gönderdiğinizde alır. Bir mesaja kopyalayın veya bir bildirime ekleyin."
   }
 }

+ 5 - 1
messages/tr/settings.json

@@ -7,7 +7,7 @@
       "billing": "Fatura & Belgeler",
       "communications": "İletişim",
       "workshop": "Atölye",
-      "integrations": "Entegrasyonlar",
+      "integrations": "Uygulamalar ve API",
       "system": "Sistem"
     },
     "items": {
@@ -91,6 +91,10 @@
         "title": "Rapor zamanlayıcı",
         "description": "Otomatik rapor gönderimi"
       },
+      "integrations": {
+        "title": "Entegrasyonlar",
+        "description": "Takvimler, görüntülü görüşmeler ve bağlı servisler"
+      },
       "webhooks": {
         "title": "Webhooks",
         "description": "Gerçek zamanlı olay bildirimleri"

+ 1 - 1
prisma.config.ts

@@ -2,7 +2,7 @@ import "dotenv/config";
 import { defineConfig } from "prisma/config";
 
 export default defineConfig({
-  schema: "prisma/schema.prisma",
+  schema: "prisma/schema",
   migrations: {
     path: "prisma/migrations",
   },

+ 150 - 0
prisma/migrations/20260902140000_integrations_platform/migration.sql

@@ -0,0 +1,150 @@
+-- CreateTable
+CREATE TABLE "integration_connections" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "connectorId" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "label" TEXT,
+    "credentials" TEXT,
+    "settings" JSONB NOT NULL DEFAULT '{}',
+    "state" JSONB NOT NULL DEFAULT '{}',
+    "externalAccountId" TEXT,
+    "externalAccountName" TEXT,
+    "scopes" TEXT,
+    "oauthState" TEXT,
+    "lastHealthAt" TIMESTAMP(3),
+    "lastSyncAt" TIMESTAMP(3),
+    "lastError" TEXT,
+    "createdById" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "integration_connections_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "integration_links" (
+    "id" TEXT NOT NULL,
+    "connectionId" TEXT NOT NULL,
+    "entityType" TEXT NOT NULL,
+    "entityId" TEXT NOT NULL,
+    "remoteId" TEXT NOT NULL,
+    "remoteUrl" TEXT,
+    "metadata" JSONB,
+    "checksum" TEXT,
+    "remoteUpdatedAt" TIMESTAMP(3),
+    "lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "integration_links_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "integration_jobs" (
+    "id" TEXT NOT NULL,
+    "connectionId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "kind" TEXT NOT NULL,
+    "payload" JSONB,
+    "idempotencyKey" TEXT,
+    "status" TEXT NOT NULL DEFAULT 'queued',
+    "attempts" INTEGER NOT NULL DEFAULT 0,
+    "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+    "runAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "lockedAt" TIMESTAMP(3),
+    "startedAt" TIMESTAMP(3),
+    "finishedAt" TIMESTAMP(3),
+    "error" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "integration_jobs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "integration_logs" (
+    "id" TEXT NOT NULL,
+    "connectionId" TEXT NOT NULL,
+    "jobId" TEXT,
+    "level" TEXT NOT NULL DEFAULT 'info',
+    "message" TEXT NOT NULL,
+    "details" JSONB,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "integration_logs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "external_calendar_events" (
+    "id" TEXT NOT NULL,
+    "connectionId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "remoteId" TEXT NOT NULL,
+    "calendarId" TEXT NOT NULL,
+    "title" TEXT NOT NULL,
+    "startAt" TIMESTAMP(3) NOT NULL,
+    "endAt" TIMESTAMP(3) NOT NULL,
+    "allDay" BOOLEAN NOT NULL DEFAULT false,
+    "remoteUpdatedAt" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "external_calendar_events_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "integration_connections_oauthState_key" ON "integration_connections"("oauthState");
+
+-- CreateIndex
+CREATE INDEX "integration_connections_organizationId_idx" ON "integration_connections"("organizationId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "integration_connections_organizationId_connectorId_key" ON "integration_connections"("organizationId", "connectorId");
+
+-- CreateIndex
+CREATE INDEX "integration_links_connectionId_remoteId_idx" ON "integration_links"("connectionId", "remoteId");
+
+-- CreateIndex
+CREATE INDEX "integration_links_entityType_entityId_idx" ON "integration_links"("entityType", "entityId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "integration_links_connectionId_entityType_entityId_key" ON "integration_links"("connectionId", "entityType", "entityId");
+
+-- CreateIndex
+CREATE INDEX "integration_jobs_status_runAfter_idx" ON "integration_jobs"("status", "runAfter");
+
+-- CreateIndex
+CREATE INDEX "integration_jobs_connectionId_createdAt_idx" ON "integration_jobs"("connectionId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "integration_jobs_organizationId_createdAt_idx" ON "integration_jobs"("organizationId", "createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "integration_jobs_connectionId_idempotencyKey_key" ON "integration_jobs"("connectionId", "idempotencyKey");
+
+-- CreateIndex
+CREATE INDEX "integration_logs_connectionId_createdAt_idx" ON "integration_logs"("connectionId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "external_calendar_events_organizationId_startAt_idx" ON "external_calendar_events"("organizationId", "startAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "external_calendar_events_connectionId_remoteId_key" ON "external_calendar_events"("connectionId", "remoteId");
+
+-- AddForeignKey
+ALTER TABLE "integration_connections" ADD CONSTRAINT "integration_connections_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "integration_links" ADD CONSTRAINT "integration_links_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "integration_jobs" ADD CONSTRAINT "integration_jobs_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "integration_logs" ADD CONSTRAINT "integration_logs_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "external_calendar_events" ADD CONSTRAINT "external_calendar_events_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "external_calendar_events" ADD CONSTRAINT "external_calendar_events_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 0 - 2273
prisma/schema.prisma

@@ -1,2273 +0,0 @@
-generator client {
-  provider = "prisma-client"
-  output   = "../src/generated/prisma"
-}
-
-datasource db {
-  provider = "postgresql"
-}
-
-model User {
-  id            String   @id @default(cuid())
-  name          String
-  email         String   @unique
-  emailVerified Boolean  @default(false)
-  /// The mobile a technician signs in with, and the number a workshop holds
-  /// for whoever this is.
-  ///
-  /// Deliberately not unique. When a desk creates an account for a mechanic
-  /// standing at the counter, a second workshop may create another for the
-  /// same human, and neither should be able to block the other. Every lookup
-  /// reaches it through a technician row and so is already scoped to one
-  /// workshop; the number alone is never the question being asked.
-  phone         String?
-  image         String?
-  isSuperAdmin  Boolean  @default(false)
-  createdAt     DateTime @default(now())
-  updatedAt     DateTime @updatedAt
-
-  twoFactorEnabled Boolean   @default(false)
-  termsAcceptedAt  DateTime?
-  lastLogin        DateTime?
-  lastSeen         DateTime?
-  /// App version this user last saw/dismissed the update banner for.
-  /// Null = never seeded (seeded silently on first load, no banner).
-  lastSeenVersion  String?
-  /// Per-user dashboard grid layout (card positions/sizes/hidden set).
-  /// Null = default layout. Shape validated in dashboardLayoutActions.
-  dashboardLayout  Json?
-
-  sessions               Session[]
-  accounts               Account[]
-  pushDevices            PushDevice[]
-  appSetupCodes          TechnicianSetupCode[]
-  vehicles               Vehicle[]
-  customers              Customer[]
-  settings               AppSetting[]
-  inventoryParts         InventoryPart[]
-  quotes                 Quote[]
-  customFieldDefinitions CustomFieldDefinition[]
-  twoFactor              TwoFactor?
-  passkeys               Passkey[]
-  organizationMembers    OrganizationMember[]
-  dashboardWidgets       DashboardWidget[]
-  auditLogs              AuditLog[]
-  technicians            Technician[]
-  stockMovements         StockMovement[]
-  tireWarehouses         TireWarehouse[]
-  tireSets               TireSet[]
-  tireSetAttachments     TireSetAttachment[]
-  tireMeasurements       TireMeasurement[]
-  tireMovements          TireMovement[]
-  tireTreatments         TireTreatment[]
-
-  @@map("users")
-}
-
-model AppSetting {
-  id    String @id @default(cuid())
-  key   String
-  value String
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  @@unique([organizationId, key])
-  @@index([organizationId])
-  @@map("app_settings")
-}
-
-model Session {
-  id        String   @id @default(cuid())
-  expiresAt DateTime
-  token     String   @unique
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-  ipAddress String?
-  userAgent String?
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  @@index([userId])
-  @@map("sessions")
-}
-
-model Account {
-  id                    String    @id @default(cuid())
-  accountId             String
-  providerId            String
-  userId                String
-  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
-  accessToken           String?
-  refreshToken          String?
-  idToken               String?
-  accessTokenExpiresAt  DateTime?
-  refreshTokenExpiresAt DateTime?
-  scope                 String?
-  password              String?
-  createdAt             DateTime  @default(now())
-  updatedAt             DateTime  @updatedAt
-
-  @@index([userId])
-  @@map("accounts")
-}
-
-model Verification {
-  id         String    @id @default(cuid())
-  identifier String    @unique
-  value      String
-  expiresAt  DateTime
-  createdAt  DateTime? @default(now())
-  updatedAt  DateTime? @updatedAt
-
-  @@map("verifications")
-}
-
-model TwoFactor {
-  id          String @id @default(cuid())
-  secret      String
-  backupCodes String
-  userId      String @unique
-  user        User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  @@map("two_factor")
-}
-
-model Passkey {
-  id           String   @id @default(cuid())
-  name         String?
-  publicKey    String
-  userId       String
-  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
-  credentialID String   @unique
-  counter      Int
-  deviceType   String
-  backedUp     Boolean
-  transports   String?
-  aaguid       String?
-  createdAt    DateTime @default(now())
-
-  @@index([userId])
-  @@map("passkeys")
-}
-
-model Vehicle {
-  id                     String               @id @default(cuid())
-  make                   String
-  model                  String
-  year                   Int
-  vin                    String?
-  licensePlate           String?
-  color                  String?
-  mileage                Int                  @default(0)
-  fuelType               String?              @default("gasoline")
-  transmission           String?              @default("automatic")
-  engineSize             String?
-  engineCode             String?
-  purchaseDate           DateTime?
-  purchasePrice          Float?
-  imageUrl               String?
-  isArchived             Boolean              @default(false)
-  archiveReason          String?
-  maintenanceDismissed   Boolean              @default(false)
-  maintenanceDismissedAt DateTime?
-  aiMessages             AiGeneratedMessage[]
-  createdAt              DateTime             @default(now())
-  updatedAt              DateTime             @updatedAt
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  customerId String?
-  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
-
-  serviceRecords    ServiceRecord[]
-  notes             Note[]
-  fuelLogs          FuelLog[]
-  reminders         Reminder[]
-  quotes            Quote[]
-  recurringInvoices RecurringInvoice[]
-  inspections       Inspection[]
-  serviceRequests   ServiceRequest[]
-  findings          VehicleFinding[]
-  scheduledMessages ScheduledMessage[]
-  tireSets          TireSet[]
-
-  @@index([organizationId])
-  @@map("vehicles")
-}
-
-model ServiceRecord {
-  id              String    @id @default(cuid())
-  title           String
-  description     String?
-  type            String    @default("maintenance")
-  status          String    @default("completed")
-  cost            Float     @default(0)
-  mileage         Int?
-  serviceDate     DateTime  @default(now())
-  startDateTime   DateTime?
-  endDateTime     DateTime?
-  shopName        String?
-  techName        String?
-  parts           String?
-  laborHours      Float?
-  diagnosticNotes String?
-  invoiceNotes    String?
-  subtotal        Float     @default(0)
-  taxRate         Float     @default(0)
-  taxAmount       Float     @default(0)
-  taxInclusive    Boolean   @default(false)
-  totalAmount     Float     @default(0)
-  invoiceNumber   String?
-  invoiceDate     DateTime?
-  invoiceDueDate  DateTime?
-  discountType    String?
-  discountValue   Float     @default(0)
-  discountAmount  Float     @default(0)
-  manuallyPaid    Boolean   @default(false)
-
-  // Warranty tracking
-  warrantyMonths    Int?
-  warrantyMileage   Int?
-  warrantyExpiresAt DateTime?
-  warrantyNotes     String?
-
-  publicToken  String?   @unique
-  sharedAt     DateTime?
-  viewCount    Int       @default(0)
-  lastViewedAt DateTime?
-  createdAt    DateTime  @default(now())
-  updatedAt    DateTime  @updatedAt
-
-  /// First time this invoice reached the customer, by email or share link.
-  /// Distinct from sharedAt, which tracks only the link and is cleared when
-  /// the link is revoked; sending is not undone by withdrawing the link.
-  sentAt DateTime?
-
-  /// Set when an owner or admin deliberately reopens a locked document.
-  /// Nullable rather than a boolean so the audit trail can show when, and the
-  /// screen can say the document was reopened rather than never locked.
-  editUnlockedAt   DateTime?
-  editUnlockedById String?
-
-  /// The stored set this job is about, when it came out of the tire hotel.
-  /// Without it a tire-change work order says nothing about which tires, and
-  /// the technician has to go and ask.
-  tireSetId String?
-  tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
-
-  /// Null for parts-only / counter sales; such records use customerId
-  /// (or neither, for anonymous walk-in sales)
-  vehicleId String?
-  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  /// Direct customer for records without a vehicle. Vehicle-linked records
-  /// leave this null and resolve the customer through the vehicle, so
-  /// reassigning a vehicle keeps invoices following the vehicle's customer.
-  customerId String?
-  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
-
-  /// Set when the job came straight from an inspection, so the inspection can
-  /// show what was raised from it. SetNull: deleting the inspection must not
-  /// take the job — and its invoice — with it.
-  inspectionId String?
-  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
-
-  /// Denormalized from the vehicle historically; now the canonical scope so
-  /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  technicianId String?
-  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-
-  /// Where in the shop the job is booked in: a lift, a ramp, an alignment
-  /// stand. Independent of the technician, because a shop plans its bays and
-  /// its people separately and the work board can lane by either.
-  workBayId String?
-  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
-  sortOrder Int      @default(0)
-
-  partItems      ServicePart[]
-  laborItems     ServiceLabor[]
-  attachments    ServiceAttachment[]
-  payments       Payment[]
-  statusReports  StatusReport[]
-  stockMovements StockMovement[]
-  timeEntries    TimeEntry[]
-
-  concerns           ServiceConcern[]
-  discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
-  resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
-
-  @@index([vehicleId])
-  @@index([technicianId])
-  @@index([workBayId])
-  @@index([organizationId])
-  @@index([customerId])
-  @@index([inspectionId])
-  @@map("service_records")
-}
-
-model Note {
-  id        String   @id @default(cuid())
-  title     String
-  content   String
-  isPinned  Boolean  @default(false)
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  @@index([vehicleId])
-  @@map("notes")
-}
-
-model FuelLog {
-  id             String   @id @default(cuid())
-  date           DateTime @default(now())
-  mileage        Int
-  gallons        Float
-  pricePerGallon Float
-  totalCost      Float
-  isFillUp       Boolean  @default(true)
-  station        String?
-  notes          String?
-  createdAt      DateTime @default(now())
-  updatedAt      DateTime @updatedAt
-
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  @@index([vehicleId])
-  @@map("fuel_logs")
-}
-
-model Reminder {
-  id          String    @id @default(cuid())
-  title       String
-  description String?
-  dueDate     DateTime?
-  dueMileage  Int?
-  isCompleted Boolean   @default(false)
-  /// Per-reminder notification channels, chosen when creating the reminder
-  notifyInApp Boolean   @default(true)
-  notifyEmail Boolean   @default(false)
-  /// Set by the due-reminder cron once notified; cleared when dueDate changes
-  notifiedAt  DateTime?
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
-
-  /// A reminder relates to a vehicle, a customer, or just the workshop
-  /// (both null). organizationId is the canonical scope for all three.
-  vehicleId String?
-  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  customerId String?
-  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  @@index([vehicleId])
-  @@index([customerId])
-  @@index([organizationId])
-  @@map("reminders")
-}
-
-/// One thing the customer asked about, in their words.
-///
-/// A job is a list of these rather than a single note, because that is how the
-/// work actually divides: "it pulls right when braking" and "the aircon smells"
-/// are two conversations, two diagnoses and two lines on the bill. Storing them
-/// as one blob means the shop can answer one and quietly drop the other, which
-/// is the single most common reason a car comes back.
-///
-/// This is the first of the trade's three Cs. The cause is a VehicleFinding
-/// pointing back here, carrying its own notes and photographs; the correction
-/// is the labour and parts on the job. Together they let the invoice explain
-/// itself: you said this, we found that, we did this about it.
-model ServiceConcern {
-  id          String   @id @default(cuid())
-  /// The customer's own words, kept as they said them.
-  description String
-  /// The order the customer raised them in, which is the order they care about.
-  sortOrder   Int      @default(0)
-  createdAt   DateTime @default(now())
-  updatedAt   DateTime @updatedAt
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  /// What the shop found when it went looking. Empty means nobody has answered
-  /// this one yet, which is worth being able to ask about before closing a job.
-  findings VehicleFinding[]
-
-  @@index([serviceRecordId])
-  @@map("service_concerns")
-}
-
-model VehicleFinding {
-  id          String   @id @default(cuid())
-  description String
-  severity    String   @default("needs_work")
-  status      String   @default("open")
-  notes       String?
-  imageUrls   String[] @default([])
-  createdAt   DateTime @default(now())
-  updatedAt   DateTime @updatedAt
-
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  serviceRecordId String?
-  serviceRecord   ServiceRecord? @relation("FindingDiscoveredIn", fields: [serviceRecordId], references: [id], onDelete: SetNull)
-
-  resolvedServiceRecordId String?
-  resolvedServiceRecord   ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
-
-  /// The customer concern this finding answers, if it answers one.
-  ///
-  /// Nullable because plenty of findings are things nobody asked about: the
-  /// perished bush somebody noticed while the car was on the lift. Those are
-  /// worth just as much, they just are not part of the story the customer
-  /// started. SetNull rather than Cascade because a finding is a fact about
-  /// the vehicle and outlives the concern that prompted the look.
-  concernId String?
-  concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
-
-  @@index([vehicleId])
-  @@index([serviceRecordId])
-  @@index([concernId])
-  @@map("vehicle_findings")
-}
-
-model Customer {
-  id             String   @id @default(cuid())
-  /// Human-facing customer number: auto-assigned sequentially on create,
-  /// editable afterwards. Unique per organization (nulls allowed for
-  /// legacy customers).
-  customerNumber String?
-  name           String
-  email          String?
-  phone          String?
-  address        String?
-  company        String?
-  taxId          String?
-  taxExempt      Boolean  @default(false)
-  notes          String?
-  telegramChatId String?
-  createdAt      DateTime @default(now())
-  updatedAt      DateTime @updatedAt
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  vehicles          Vehicle[]
-  quotes            Quote[]
-  smsMessages       SmsMessage[]
-  telegramMessages  TelegramMessage[]
-  whatsappMessages  WhatsappMessage[]
-  customerSessions  CustomerSession[]
-  scheduledMessages ScheduledMessage[]
-  serviceRequests   ServiceRequest[]
-
-  serviceRecords ServiceRecord[]
-  reminders      Reminder[]
-  tireSets       TireSet[]
-
-  @@unique([organizationId, customerNumber])
-  @@index([organizationId])
-  @@map("customers")
-}
-
-model Payment {
-  id         String   @id @default(cuid())
-  amount     Float
-  date       DateTime @default(now())
-  method     String   @default("other")
-  note       String?
-  provider   String?
-  externalId String?
-  createdAt  DateTime @default(now())
-  updatedAt  DateTime @updatedAt
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  @@index([serviceRecordId])
-  @@map("payments")
-}
-
-model ServiceAttachment {
-  id               String   @id @default(cuid())
-  fileName         String
-  fileUrl          String
-  fileType         String
-  fileSize         Int
-  category         String   @default("diagnostic")
-  description      String?
-  includeInInvoice Boolean  @default(true)
-  createdAt        DateTime @default(now())
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  @@index([serviceRecordId])
-  @@map("service_attachments")
-}
-
-model ServicePart {
-  id              String  @id @default(cuid())
-  partNumber      String?
-  name            String
-  quantity        Float   @default(1)
-  /// Unit of measure snapshotted from InventoryPart at pick time; null for
-  /// free-text lines and legacy rows.
-  unit            String?
-  unitPrice       Float   @default(0)
-  total           Float   @default(0)
-  unitCost        Float   @default(0)
-  markupPercent   Float   @default(0)
-  inventoryPartId String?
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  @@index([serviceRecordId])
-  @@map("service_parts")
-}
-
-model ServiceLabor {
-  id          String @id @default(cuid())
-  description String
-  hours       Float  @default(0)
-  rate        Float  @default(0)
-  total       Float  @default(0)
-  pricingType String @default("hourly")
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  @@index([serviceRecordId])
-  @@map("service_labor")
-}
-
-model LaborPreset {
-  id             String            @id @default(cuid())
-  name           String
-  description    String?
-  isArchived     Boolean           @default(false)
-  createdAt      DateTime          @default(now())
-  updatedAt      DateTime          @updatedAt
-  userId         String
-  organizationId String
-  organization   Organization      @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-  items          LaborPresetItem[]
-  parts          LaborPresetPart[]
-
-  @@index([organizationId])
-  @@map("labor_presets")
-}
-
-model LaborPresetItem {
-  id          String      @id @default(cuid())
-  description String
-  hours       Float       @default(0)
-  rate        Float       @default(0)
-  pricingType String      @default("hourly")
-  sortOrder   Int         @default(0)
-  presetId    String
-  preset      LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
-
-  @@index([presetId])
-  @@map("labor_preset_items")
-}
-
-model LaborPresetPart {
-  id              String  @id @default(cuid())
-  name            String
-  partNumber      String?
-  quantity        Float   @default(1)
-  /// Unit of measure snapshotted from InventoryPart at pick time.
-  unit            String?
-  unitPrice       Float   @default(0)
-  inventoryPartId String?
-  sortOrder       Int     @default(0)
-
-  presetId String
-  preset   LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
-
-  @@index([presetId])
-  @@map("labor_preset_parts")
-}
-
-model InventoryPart {
-  id            String  @id @default(cuid())
-  partNumber    String?
-  barcode       String?
-  name          String
-  description   String?
-  category      String?
-  quantity      Float   @default(0)
-  minQuantity   Float   @default(0)
-  /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
-  /// snapshotted onto part lines at pick time so invoices keep the unit the
-  /// part had when it was billed.
-  unit          String?
-  unitCost      Float   @default(0)
-  sellPrice     Float   @default(0)
-  supplier      String?
-  supplierPhone String?
-  supplierEmail String?
-  supplierUrl   String?
-  imageUrl      String?
-  location      String?
-  isArchived    Boolean @default(false)
-
-  /// Set when a low-stock alert has been sent for the current dip below
-  /// `minQuantity`, and cleared once stock climbs back above it. This
-  /// hysteresis is what stops a part that sits at zero from re-alerting on
-  /// every check — it alerts once per descent, not once per run.
-  lowStockAlertedAt DateTime?
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  gallery   StoredImage[]   @relation("InventoryPartImages")
-  movements StockMovement[]
-
-  @@unique([organizationId, barcode])
-  @@index([organizationId])
-  @@map("inventory_parts")
-}
-
-/// Append-only audit trail of every change to InventoryPart.quantity.
-///
-/// Written inside the same transaction as the stock mutation itself, so the
-/// ledger can never drift from the balance it describes. `quantityAfter` is
-/// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
-/// balance even under concurrent writes.
-model StockMovement {
-  id String @id @default(cuid())
-
-  /// Signed change in stock units (fractional allowed, e.g. 0.5 l).
-  /// Negative = consumed, positive = restocked.
-  delta         Float
-  /// Part balance immediately after this movement was applied.
-  quantityAfter Float
-  /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
-  reason        String
-  note          String?
-
-  inventoryPartId String
-  inventoryPart   InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
-
-  /// The job the part was used on. Kept on SetNull so history survives
-  /// deletion of the service record — "where was this used" outlives the job.
-  serviceRecordId String?
-  serviceRecord   ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
-
-  /// Denormalised so history still reads correctly after a service record is
-  /// deleted and serviceRecordId goes null.
-  serviceRecordLabel String?
-
-  userId String?
-  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-
-  @@index([inventoryPartId, createdAt(sort: Desc)])
-  @@index([organizationId, createdAt(sort: Desc)])
-  @@index([serviceRecordId])
-  @@map("stock_movements")
-}
-
-model StoredImage {
-  id          String   @id @default(cuid())
-  url         String
-  fileName    String?
-  description String?
-  sortOrder   Int      @default(0)
-  createdAt   DateTime @default(now())
-
-  inventoryPartId String?
-  inventoryPart   InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
-
-  /// Condition photos taken when a tire is measured. Second parent on this
-  /// model — an image belongs to exactly one of them.
-  tireMeasurementId String?
-  tireMeasurement   TireMeasurement? @relation("TireMeasurementImages", fields: [tireMeasurementId], references: [id], onDelete: Cascade)
-
-  @@index([inventoryPartId])
-  @@index([tireMeasurementId])
-  @@map("stored_images")
-}
-
-model Quote {
-  id              String    @id @default(cuid())
-  quoteNumber     String?
-  title           String
-  description     String?
-  status          String    @default("draft")
-  validUntil      DateTime?
-  subtotal        Float     @default(0)
-  taxRate         Float     @default(0)
-  taxAmount       Float     @default(0)
-  taxInclusive    Boolean   @default(false)
-  discountType    String?
-  discountValue   Float     @default(0)
-  discountAmount  Float     @default(0)
-  totalAmount     Float     @default(0)
-  notes           String?
-  publicToken     String?
-  sharedAt        DateTime?
-  viewCount       Int       @default(0)
-  lastViewedAt    DateTime?
-  customerMessage String?
-  convertedToId   String?
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  /// Most recent time this quote reached the customer, by email or share
-  /// link. Exists so an unlock is spent when the quote is issued again; the
-  /// status alone cannot show that, because re-sending an accepted quote does
-  /// not change it.
-  sentAt DateTime?
-
-  /// See ServiceRecord.editUnlockedAt.
-  editUnlockedAt   DateTime?
-  editUnlockedById String?
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  customerId String?
-  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
-
-  /// The stored set this quote is about, when it came out of the tire hotel.
-  /// SetNull rather than Cascade: a quote already sent is a document the
-  /// customer holds, and deleting the set must not erase it.
-  tireSetId String?
-  tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
-
-  vehicleId String?
-  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
-
-  inspectionId String?
-  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
-
-  partItems   QuotePart[]
-  laborItems  QuoteLabor[]
-  attachments QuoteAttachment[]
-
-  @@index([organizationId])
-  @@map("quotes")
-}
-
-model QuoteAttachment {
-  id               String   @id @default(cuid())
-  fileName         String
-  fileUrl          String
-  fileType         String
-  fileSize         Int
-  category         String   @default("image")
-  description      String?
-  includeInInvoice Boolean  @default(true)
-  createdAt        DateTime @default(now())
-
-  quoteId String
-  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
-
-  @@index([quoteId])
-  @@map("quote_attachments")
-}
-
-model QuotePart {
-  id            String  @id @default(cuid())
-  partNumber    String?
-  name          String
-  quantity      Float   @default(1)
-  /// Unit of measure snapshotted from InventoryPart at pick time; null for
-  /// free-text lines and legacy rows.
-  unit          String?
-  /// Internal purchase cost — never shown to customers (quote PDF/share
-  /// render unitPrice only)
-  unitCost      Float   @default(0)
-  markupPercent Float   @default(0)
-  unitPrice     Float   @default(0)
-  total         Float   @default(0)
-  excluded      Boolean @default(false)
-
-  /// Optional link to the stocked part this line was picked from. Carried into
-  /// ServicePart on conversion so the job deducts the right inventory item.
-  inventoryPartId String?
-
-  quoteId String
-  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
-
-  @@index([quoteId])
-  @@map("quote_parts")
-}
-
-model QuoteLabor {
-  id          String  @id @default(cuid())
-  description String
-  hours       Float   @default(0)
-  rate        Float   @default(0)
-  total       Float   @default(0)
-  pricingType String  @default("hourly")
-  excluded    Boolean @default(false)
-
-  quoteId String
-  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
-
-  @@index([quoteId])
-  @@map("quote_labor")
-}
-
-model CustomFieldDefinition {
-  id           String   @id @default(cuid())
-  name         String
-  label        String
-  fieldType    String   @default("text")
-  options      String?
-  defaultValue String?
-  required     Boolean  @default(false)
-  entityType   String
-  sortOrder    Int      @default(0)
-  isActive     Boolean  @default(true)
-  createdAt    DateTime @default(now())
-  updatedAt    DateTime @updatedAt
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  values CustomFieldValue[]
-
-  @@unique([organizationId, name, entityType])
-  @@index([organizationId])
-  @@map("custom_field_definitions")
-}
-
-model CustomFieldValue {
-  id         String @id @default(cuid())
-  value      String
-  entityId   String
-  entityType String
-
-  fieldId String
-  field   CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)
-
-  @@unique([fieldId, entityId])
-  @@index([fieldId])
-  @@map("custom_field_values")
-}
-
-/// A user-defined dashboard card: a named, filtered table over one entity.
-/// The config shape (entity, filters, columns, limit) is validated against
-/// the field registry in customCardActions — nothing here reaches SQL raw.
-model DashboardWidget {
-  id             String   @id @default(cuid())
-  name           String
-  config         Json
-  userId         String
-  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
-  organizationId String
-  createdAt      DateTime @default(now())
-  updatedAt      DateTime @updatedAt
-
-  @@index([userId, organizationId])
-  @@map("dashboard_widgets")
-}
-
-model Organization {
-  id         String   @id @default(cuid())
-  name       String
-  portalSlug String?  @unique
-  createdAt  DateTime @default(now())
-  updatedAt  DateTime @updatedAt
-
-  members                OrganizationMember[]
-  roles                  Role[]
-  vehicles               Vehicle[]
-  serviceRecords         ServiceRecord[]
-  reminders              Reminder[]
-  customers              Customer[]
-  quotes                 Quote[]
-  inventoryParts         InventoryPart[]
-  laborPresets           LaborPreset[]
-  customFieldDefinitions CustomFieldDefinition[]
-  settings               AppSetting[]
-  subscription           Subscription?
-  invitations            TeamInvitation[]
-  inspectionTemplates    InspectionTemplate[]
-  smsMessages            SmsMessage[]
-  telegramMessages       TelegramMessage[]
-  whatsappMessages       WhatsappMessage[]
-  technicians            Technician[]
-  workBays               WorkBay[]
-  timeEntries            TimeEntry[]
-  pushDevices            PushDevice[]
-  appSetupCodes          TechnicianSetupCode[]
-  technicianLoginCodes   TechnicianLoginCode[]
-  auditLogs              AuditLog[]
-  statusReports          StatusReport[]
-  reportSchedules        ReportSchedule[]
-  scheduledMessages      ScheduledMessage[]
-  webhooks               Webhook[]
-  stockMovements         StockMovement[]
-  tireWarehouses         TireWarehouse[]
-  tireLocations          TireLocation[]
-  tireSets               TireSet[]
-  tireSetAttachments     TireSetAttachment[]
-  tireMovements          TireMovement[]
-  tireTreatments         TireTreatment[]
-
-  @@map("organizations")
-}
-
-model OrganizationMember {
-  id   String @id @default(cuid())
-  role String @default("member")
-
-  userId         String
-  user           User         @relation(fields: [userId], references: [id], onDelete: Cascade)
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  roleId     String?
-  customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
-
-  @@unique([userId, organizationId])
-  @@map("organization_members")
-}
-
-model Role {
-  id        String   @id @default(cuid())
-  name      String
-  isAdmin   Boolean  @default(false)
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  permissions     Permission[]
-  members         OrganizationMember[]
-  teamInvitations TeamInvitation[]
-
-  @@unique([organizationId, name])
-  @@map("roles")
-}
-
-model Permission {
-  id      String @id @default(cuid())
-  action  String
-  subject String
-
-  roleId String
-  role   Role   @relation(fields: [roleId], references: [id], onDelete: Cascade)
-
-  @@unique([roleId, action, subject])
-  @@index([roleId])
-  @@map("permissions")
-}
-
-model SystemSetting {
-  id    String @id @default(cuid())
-  key   String @unique
-  value String
-
-  @@map("system_settings")
-}
-
-model TeamInvitation {
-  id             String       @id @default(cuid())
-  email          String
-  role           String       @default("member")
-  token          String       @unique
-  status         String       @default("pending") // pending | accepted | cancelled
-  expiresAt      DateTime
-  createdAt      DateTime     @default(now())
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-  invitedById    String
-
-  roleId     String?
-  customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
-
-  @@unique([email, organizationId])
-  @@index([token])
-  @@map("team_invitations")
-}
-
-// Stripe-ready models (implementation in follow-up)
-
-model SubscriptionPlan {
-  id            String   @id @default(cuid())
-  name          String
-  stripePriceId String?  @unique
-  price         Float    @default(0)
-  interval      String   @default("month") // "month" | "year"
-  maxMembers    Int      @default(1)
-  features      String? // JSON feature flags
-  isActive      Boolean  @default(true)
-  createdAt     DateTime @default(now())
-  updatedAt     DateTime @updatedAt
-
-  subscriptions Subscription[]
-
-  @@map("subscription_plans")
-}
-
-model Subscription {
-  id                   String    @id @default(cuid())
-  status               String    @default("active") // active | canceled | past_due | trialing
-  stripeSubscriptionId String?   @unique
-  stripeCustomerId     String?
-  currentPeriodStart   DateTime?
-  currentPeriodEnd     DateTime?
-  cancelAtPeriodEnd    Boolean   @default(false)
-  createdAt            DateTime  @default(now())
-  updatedAt            DateTime  @updatedAt
-
-  organizationId String       @unique
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  planId String
-  plan   SubscriptionPlan @relation(fields: [planId], references: [id])
-
-  @@map("subscriptions")
-}
-
-model RecurringInvoice {
-  id          String    @id @default(cuid())
-  title       String
-  description String?
-  frequency   String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
-  nextRunDate DateTime
-  endDate     DateTime?
-  isActive    Boolean   @default(true)
-  lastRunAt   DateTime?
-  runCount    Int       @default(0)
-
-  // Template fields (copied to ServiceRecord on generation)
-  type         String  @default("maintenance")
-  cost         Float   @default(0)
-  taxRate      Float   @default(0)
-  taxInclusive Boolean @default(false)
-  invoiceNotes String?
-
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  templateParts RecurringPart[]
-  templateLabor RecurringLabor[]
-
-  @@index([vehicleId])
-  @@index([nextRunDate, isActive])
-  @@map("recurring_invoices")
-}
-
-model RecurringPart {
-  id         String  @id @default(cuid())
-  name       String
-  partNumber String?
-  quantity   Float   @default(1)
-  unitPrice  Float   @default(0)
-
-  recurringInvoiceId String
-  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
-
-  @@map("recurring_parts")
-}
-
-model RecurringLabor {
-  id          String @id @default(cuid())
-  description String
-  hours       Float  @default(0)
-  rate        Float  @default(0)
-  pricingType String @default("hourly")
-
-  recurringInvoiceId String
-  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
-
-  @@map("recurring_labor")
-}
-
-model InspectionTemplate {
-  id          String   @id @default(cuid())
-  name        String
-  description String?
-  isDefault   Boolean  @default(false)
-  createdAt   DateTime @default(now())
-  updatedAt   DateTime @updatedAt
-
-  // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
-  // shop-specific checklist), `standard` names the regime the template follows
-  // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
-  // technician grades against — see Lib/conditions.ts.
-  country       String?
-  standard      String? @default("custom")
-  severityScale String  @default("eu")
-
-  /// Provenance, for templates that came from a package rather than being
-  /// written here. `packageId` is namespaced and stable across renames
-  /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
-  /// update from a duplicate. Null on a template the workshop wrote itself.
-  packageId      String?
-  packageVersion String?
-  /// Where it came from: "builtin", "file" or "catalogue".
-  packageSource  String?
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  sections    InspectionTemplateSection[]
-  inspections Inspection[]
-
-  @@index([organizationId])
-  @@index([organizationId, packageId])
-  @@map("inspection_templates")
-}
-
-model InspectionTemplateSection {
-  id          String  @id @default(cuid())
-  name        String
-  description String?
-  // Regulation reference for the section, e.g. "1" for Braking equipment in
-  // Directive 2014/45/EU Annex I.
-  code        String?
-  sortOrder   Int     @default(0)
-
-  templateId String
-  template   InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
-
-  items InspectionTemplateItem[]
-
-  @@index([templateId])
-  @@map("inspection_template_sections")
-}
-
-model InspectionTemplateItem {
-  id          String  @id @default(cuid())
-  name        String
-  description String?
-  // Regulation reference for the check, e.g. "1.1.13".
-  code        String?
-  sortOrder   Int     @default(0)
-
-  // How the technician records the result: "condition" (defect grading only),
-  // "measurement" (numeric value in `unit`, graded against min/max),
-  // "text" (free text) or "choice" (one of `choices`).
-  inputType String   @default("condition")
-  unit      String?
-  minValue  Float?
-  maxValue  Float?
-  choices   String[] @default([])
-
-  required          Boolean  @default(false)
-  photoRequired     Boolean  @default(false)
-  // Defect severity applied when a measurement falls outside min/max.
-  defaultSeverity   String?
-  // Phrases this workshop wants offered when a defect is recorded against the
-  // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
-  defectSuggestions String[] @default([])
-
-  sectionId String
-  section   InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
-
-  @@index([sectionId])
-  @@map("inspection_template_items")
-}
-
-model Inspection {
-  id            String    @id @default(cuid())
-  status        String    @default("in_progress")
-  mileage       Int?
-  notes         String?
-  startDateTime DateTime?
-  endDateTime   DateTime?
-  publicToken   String?   @unique
-  completedAt   DateTime?
-  createdAt     DateTime  @default(now())
-  updatedAt     DateTime  @updatedAt
-
-  vehicleId String
-  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  templateId String
-  template   InspectionTemplate @relation(fields: [templateId], references: [id])
-
-  technicianId String?
-  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-
-  /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
-  /// repair does, and the work board lanes both the same way.
-  workBayId String?
-  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
-  sortOrder Int      @default(0)
-
-  // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
-  // `inspectorName` and `certificateNumber` are snapshots taken at completion so
-  // the issued certificate stays stable even if staff or settings change later.
-  /// Snapshot of how this inspection was graded, taken when it was created.
-  /// Read in preference to the template's current setting: a workshop that
-  /// later switches a template to the EU scale must not relabel a certificate
-  /// it already issued. Null on inspections that predate the snapshot; those
-  /// fall back to the template, which the migration pinned to its old scale.
-  severityScale String?
-  country       String?
-
-  vehicleCategory   String?
-  nextTestDue       DateTime?
-  certificateNumber String?
-  inspectorName     String?
-  testLocation      String?
-
-  organizationId String
-
-  items          InspectionItem[]
-  quoteRequests  InspectionQuoteRequest[]
-  quotes         Quote[]
-  serviceRecords ServiceRecord[]
-
-  @@index([vehicleId])
-  @@index([organizationId])
-  @@index([technicianId])
-  @@index([workBayId])
-  @@map("inspections")
-}
-
-model InspectionItem {
-  id        String   @id @default(cuid())
-  name      String
-  section   String
-  sortOrder Int      @default(0)
-  condition String   @default("not_inspected")
-  notes     String?
-  imageUrls String[] @default([])
-
-  // Copied from the template at creation so an inspection stays readable even
-  // after its template is edited or deleted.
-  description       String?
-  code              String?
-  sectionCode       String?
-  inputType         String   @default("condition")
-  unit              String?
-  minValue          Float?
-  maxValue          Float?
-  choices           String[] @default([])
-  required          Boolean  @default(false)
-  photoRequired     Boolean  @default(false)
-  defaultSeverity   String?
-  defectSuggestions String[] @default([])
-
-  measuredValue Float?
-  textValue     String?
-
-  inspectionId String
-  inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
-
-  @@index([inspectionId])
-  @@map("inspection_items")
-}
-
-model InspectionQuoteRequest {
-  id              String   @id @default(cuid())
-  status          String   @default("pending")
-  message         String?
-  selectedItemIds String[]
-  createdAt       DateTime @default(now())
-
-  inspectionId String
-  inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
-
-  organizationId String
-
-  @@index([organizationId])
-  @@index([inspectionId])
-  @@map("inspection_quote_requests")
-}
-
-model Notification {
-  id             String   @id @default(cuid())
-  type           String
-  title          String
-  message        String
-  entityType     String
-  entityId       String
-  entityUrl      String
-  read           Boolean  @default(false)
-  organizationId String
-  createdAt      DateTime @default(now())
-
-  @@index([organizationId, createdAt(sort: Desc)])
-  @@index([organizationId, read])
-  @@map("notifications")
-}
-
-model SmsMessage {
-  id                String   @id @default(cuid())
-  direction         String // "inbound" | "outbound"
-  fromNumber        String
-  toNumber          String
-  body              String
-  status            String   @default("queued") // queued | sent | delivered | failed | received
-  providerMsgId     String?
-  errorMessage      String?
-  relatedEntityType String?
-  relatedEntityId   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)
-
-  @@index([organizationId, customerId, createdAt(sort: Desc)])
-  @@index([organizationId, createdAt(sort: Desc)])
-  @@index([toNumber, organizationId])
-  @@map("sms_messages")
-}
-
-model TelegramMessage {
-  id                String   @id @default(cuid())
-  direction         String // "inbound" | "outbound"
-  chatId            String // Telegram chat ID
-  body              String
-  status            String   @default("queued") // queued | sent | delivered | failed | received
-  telegramMessageId String? // Telegram's message ID for tracking
-  errorMessage      String?
-  relatedEntityType String?
-  relatedEntityId   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)
-
-  @@index([organizationId, customerId, createdAt(sort: Desc)])
-  @@index([organizationId, chatId, createdAt(sort: Desc)])
-  @@map("telegram_messages")
-}
-
-/// One WhatsApp message in either direction.
-///
-/// `provider` records which adapter carried it, because a workshop can switch
-/// provider without its history becoming unreadable, and a status callback
-/// arriving later has to find the row it belongs to.
-model WhatsappMessage {
-  id        String @id @default(cuid())
-  direction String // "inbound" | "outbound"
-  provider  String // "meta" | "twilio" | any adapter registered later
-
-  /// E.164 without the "whatsapp:" prefix a provider may use on the wire.
-  fromNumber String
-  toNumber   String
-
-  body String?
-
-  /// Media the message carried. Inbound URLs may need the provider's
-  /// credentials to fetch, so they are not necessarily public.
-  mediaUrl      String?
-  mediaType     String? // image | document | video | audio | sticker
-  mediaFilename String?
-
-  /// Set when the message had to go out as an approved template, which is the
-  /// only thing WhatsApp allows outside the 24 hour service window.
-  templateName String?
-
-  status            String  @default("queued") // queued | sent | delivered | read | failed | received
-  providerMessageId String?
-  errorMessage      String?
-
-  relatedEntityType String?
-  relatedEntityId   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)
-
-  @@index([organizationId, customerId, createdAt(sort: Desc)])
-  @@index([organizationId, createdAt(sort: Desc)])
-  @@index([providerMessageId])
-  @@map("whatsapp_messages")
-}
-
-model Technician {
-  id            String  @id @default(cuid())
-  name          String
-  color         String  @default("#3b82f6")
-  isActive      Boolean @default(true)
-  sortOrder     Int     @default(0)
-  dailyCapacity Int     @default(480)
-
-  userId String?
-  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  loginCodes     TechnicianLoginCode[]
-  serviceRecords ServiceRecord[]
-  inspections    Inspection[]
-  statusReports  StatusReport[]
-  timeEntries    TimeEntry[]
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@index([organizationId])
-  @@map("technicians")
-}
-
-/// One stretch of a technician actually working on a job, as clocked from the
-/// technician app.
-///
-/// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
-/// those are the work board's planned slot, one pair per job, owned by whoever
-/// schedules the day. This is what happened, many rows per job, one per person
-/// per stretch. A job worked on Monday, parked for parts and finished Thursday
-/// by someone else is two entries, and neither of them is the booking.
-///
-/// `endedAt` null means the clock is still running. At most one open entry per
-/// technician is enforced in the API rather than the schema, because "open"
-/// is not a value a partial unique index can express portably.
-model TimeEntry {
-  id String @id @default(cuid())
-
-  startedAt DateTime  @default(now())
-  endedAt   DateTime?
-
-  /// Cached on stop so reports do not recompute across millions of rows, and
-  /// so a correction to the timestamps stays visible against what was billed.
-  durationMinutes Int?
-
-  /// What the technician typed when stopping, if anything.
-  note String?
-
-  /// "app" | "web" | "manual". A manually corrected entry is not the same
-  /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
-  source String @default("app")
-
-  /// Set when someone edited the times after the fact, so the audit trail can
-  /// show the entry is no longer purely as-clocked.
-  editedAt       DateTime?
-  editedByUserId String?
-
-  technicianId String
-  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@index([technicianId, startedAt])
-  @@index([serviceRecordId])
-  @@index([organizationId, startedAt])
-  /// Finding the technician's open entry is the hottest query in the app: it
-  /// runs on every launch and every job screen.
-  @@index([technicianId, endedAt])
-  @@map("time_entries")
-}
-
-/// A phone that has asked to be told about its technician's work.
-///
-/// Keyed on the Expo push token rather than on the user, because the unit that
-/// receives a notification is a device: one technician may carry a personal
-/// phone and a shared bench tablet, and a shared tablet may be signed into by
-/// several people over a week. Both cases have to work.
-///
-/// The token is a routing address, not a secret, but it is still deleted on
-/// sign-out: a device that has signed out must stop receiving a workshop's job
-/// details, and expiry alone would leave a window where it still did.
-/// A one-time code that puts a technician's phone onto a workshop.
-///
-/// Typing a URL and a password is the worst moment in the app, and it lands on
-/// the person least equipped for it: a technician with dirty hands, on a
-/// phone, being watched. The desk shows a QR instead and the phone reads it.
-///
-/// It is a bearer credential in a picture, so it is deliberately short-lived,
-/// single-use, and bound to one person in one workshop. It exchanges for a
-/// normal session and is then dead; it is never itself a way to authenticate.
-/// A one-time code that signs a technician back in.
-///
-/// Modelled on CustomerSmsCode, which does the same job for the customer
-/// portal: org-scoped, single use, short-lived. The organisation is not
-/// optional and is never inferred from the phone number. A technician's phone
-/// is only ever looked up inside the workshop their app already belongs to,
-/// so nothing here can reach across workshops even if a person works at two.
-model TechnicianLoginCode {
-  id String @id @default(cuid())
-
-  /// SHA-256 of the digits. The code exists in a text message and in the
-  /// technician's head, and nowhere that can be read back out of a database.
-  codeHash String
-
-  /// How it was sent, so the app can say "check your messages" or "check your
-  /// email" rather than guessing on the technician's behalf.
-  channel String
-
-  /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
-  /// does not protect: this burns the code after a handful of attempts.
-  attempts Int @default(0)
-
-  expiresAt DateTime
-  usedAt    DateTime?
-
-  technicianId String
-  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-
-  @@index([organizationId, technicianId])
-  @@index([expiresAt])
-  @@map("technician_login_codes")
-}
-
-model TechnicianSetupCode {
-  id String @id @default(cuid())
-
-  /// SHA-256 of the code. The code itself is shown once, on the screen that
-  /// asked for it, and is never written down anywhere it could be read back.
-  codeHash String @unique
-
-  expiresAt DateTime
-  /// Set on redemption. Kept rather than deleted so a second scan can say the
-  /// code was already used, which is a different problem from a wrong code.
-  usedAt    DateTime?
-
-  /// Who the code signs in.
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  /// Who issued it, for the audit trail. Not a relation: the issuer may be
-  /// removed from the workshop later and that must not delete the record.
-  issuedByUserId String
-
-  createdAt DateTime @default(now())
-
-  @@index([organizationId, expiresAt])
-  @@index([userId])
-  @@map("technician_setup_codes")
-}
-
-model PushDevice {
-  id String @id @default(cuid())
-
-  /// Expo push token, unique across the estate. A reinstall issues a new one
-  /// and the old becomes invalid, which is why sends prune on rejection.
-  token String @unique
-
-  /// "ios" | "android". Only used to explain failures in logs.
-  platform String
-
-  /// Set false when Expo reports the token as dead, rather than deleting, so
-  /// a flapping device does not churn rows.
-  isActive Boolean @default(true)
-
-  /// Last time the app confirmed this token still belongs to this user, so a
-  /// device that stops checking in can be aged out.
-  lastSeenAt DateTime @default(now())
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@index([userId, isActive])
-  @@index([organizationId])
-  @@map("push_devices")
-}
-
-/// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
-/// plan by bay as often as by person — the bay is the scarce resource, and a
-/// technician moves between them during a day. The work board can group its
-/// lanes by either, so both are first-class.
-model WorkBay {
-  id            String  @id @default(cuid())
-  name          String
-  color         String  @default("#64748b")
-  isActive      Boolean @default(true)
-  /// Ordering on the board, left to right. Shops name bays by position
-  /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
-  sortOrder     Int     @default(0)
-  /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
-  /// can show utilisation for whichever lane grouping is on.
-  dailyCapacity Int     @default(480)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  serviceRecords ServiceRecord[]
-  inspections    Inspection[]
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@index([organizationId])
-  @@map("work_bays")
-}
-
-model AuditLog {
-  id        String   @id @default(cuid())
-  timestamp DateTime @default(now())
-  action    String
-  entity    String?
-  entityId  String?
-  message   String?
-  metadata  Json?
-  ip        String?
-  userAgent String?
-
-  userId String?
-  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
-
-  organizationId String?
-  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
-
-  @@index([organizationId, timestamp(sort: Desc)])
-  @@index([userId, timestamp(sort: Desc)])
-  @@index([action, timestamp(sort: Desc)])
-  @@map("audit_logs")
-}
-
-model CustomerSession {
-  id        String   @id @default(cuid())
-  token     String   @unique
-  expiresAt DateTime
-  createdAt DateTime @default(now())
-
-  customerId     String
-  customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
-  organizationId String
-
-  @@index([token])
-  @@index([customerId, organizationId])
-  @@map("customer_sessions")
-}
-
-model CustomerMagicLink {
-  id             String    @id @default(cuid())
-  token          String    @unique
-  email          String
-  organizationId String
-  expiresAt      DateTime
-  usedAt         DateTime?
-  createdAt      DateTime  @default(now())
-
-  @@index([token])
-  @@map("customer_magic_links")
-}
-
-model CustomerSmsCode {
-  id String @id @default(cuid())
-
-  /// SHA-256 of the digits, not the digits.
-  ///
-  /// The code reaches the customer in a text message and lives in their head
-  /// for a minute. It has no business also sitting in a table where reading a
-  /// row hands over somebody's portal session.
-  code String
-
-  /// Wrong guesses so far. Six digits is a million, which sounds like plenty
-  /// and is not: limiting by address does nothing about a hundred machines
-  /// sharing the work. Five attempts against one code makes the space
-  /// irrelevant, because the code is gone long before the guessing arrives.
-  attempts Int @default(0)
-
-  phone          String
-  organizationId String
-  expiresAt      DateTime
-  usedAt         DateTime?
-  createdAt      DateTime  @default(now())
-
-  @@index([phone, organizationId])
-  @@index([organizationId])
-  @@map("customer_sms_codes")
-}
-
-model ServiceRequest {
-  id            String    @id @default(cuid())
-  description   String
-  preferredDate DateTime?
-  status        String    @default("pending")
-  adminNotes    String?
-  createdAt     DateTime  @default(now())
-  updatedAt     DateTime  @updatedAt
-
-  customerId     String
-  customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
-  vehicleId      String
-  vehicle        Vehicle  @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-  organizationId String
-
-  @@index([organizationId, status])
-  @@index([customerId])
-  @@map("service_requests")
-}
-
-model AiGeneratedMessage {
-  id        String   @id @default(cuid())
-  vehicleId String
-  type      String // "summary" | "common_issues" | etc.
-  content   String
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
-
-  @@unique([vehicleId, type])
-  @@map("ai_generated_messages")
-}
-
-model AiChat {
-  id        String   @id @default(cuid())
-  title     String   @default("New chat")
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  userId         String
-  organizationId String
-
-  messages AiChatMessage[]
-
-  @@index([organizationId, userId, updatedAt(sort: Desc)])
-  @@map("ai_chats")
-}
-
-model AiChatMessage {
-  id        String   @id @default(cuid())
-  role      String // "user" | "assistant"
-  content   String
-  createdAt DateTime @default(now())
-
-  chatId String
-  chat   AiChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
-
-  @@index([chatId, createdAt])
-  @@map("ai_chat_messages")
-}
-
-model StatusReport {
-  id               String    @id @default(cuid())
-  title            String?
-  message          String?   @db.Text
-  videoUrl         String?
-  videoFileName    String?
-  publicToken      String    @unique
-  status           String    @default("draft") // draft | published | sent | viewed
-  sentVia          String? // email, sms, telegram (comma-separated)
-  sentAt           DateTime?
-  viewedAt         DateTime?
-  customerFeedback String?   @db.Text
-  feedbackAt       DateTime?
-  expiresAt        DateTime?
-  createdAt        DateTime  @default(now())
-  updatedAt        DateTime  @updatedAt
-
-  serviceRecordId String
-  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  technicianId String?
-  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-
-  @@index([serviceRecordId])
-  @@index([organizationId])
-  @@index([publicToken])
-  @@index([expiresAt])
-  @@map("status_reports")
-}
-
-model ReportSchedule {
-  id          String    @id @default(cuid())
-  name        String    @default("Scheduled Report")
-  frequency   String // "daily" | "weekly" | "monthly" etc.
-  dateRange   String    @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
-  sections    String // JSON array of section keys
-  recipients  String // JSON array of user IDs
-  nextRunDate DateTime
-  endDate     DateTime?
-  isActive    Boolean   @default(true)
-  lastRunAt   DateTime?
-  runCount    Int       @default(0)
-  createdAt   DateTime  @default(now())
-  updatedAt   DateTime  @updatedAt
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdById String
-
-  @@index([organizationId])
-  @@index([nextRunDate, isActive])
-  @@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 {
-  id              String    @id @default(cuid())
-  name            String
-  url             String
-  secret          String
-  events          String // JSON array of event names; "*" matches all
-  isActive        Boolean   @default(true)
-  description     String?
-  lastTriggeredAt DateTime?
-  lastSuccessAt   DateTime?
-  lastFailureAt   DateTime?
-  failureCount    Int       @default(0)
-  createdAt       DateTime  @default(now())
-  updatedAt       DateTime  @updatedAt
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdById String
-
-  deliveries WebhookDelivery[]
-
-  @@index([organizationId])
-  @@index([organizationId, isActive])
-  @@map("webhooks")
-}
-
-model WebhookDelivery {
-  id           String    @id @default(cuid())
-  event        String
-  payload      String // JSON-stringified event payload as sent
-  status       String    @default("pending") // pending | success | failed | retrying
-  statusCode   Int?
-  responseBody String?
-  errorMessage String?
-  attempt      Int       @default(0)
-  maxAttempts  Int       @default(5)
-  nextRetryAt  DateTime?
-  deliveredAt  DateTime?
-  durationMs   Int?
-  createdAt    DateTime  @default(now())
-  updatedAt    DateTime  @updatedAt
-
-  webhookId String
-  webhook   Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
-
-  @@index([webhookId, createdAt(sort: Desc)])
-  @@index([status, nextRetryAt])
-  @@map("webhook_deliveries")
-}
-
-// ---------------------------------------------------------------------------
-// Tire hotel
-//
-// Seasonal tire storage. Opt-in per organization via the `tireHotel.enabled`
-// app setting — every model here stays empty and every route stays hidden
-// until a workshop switches it on.
-//
-// Capacity is counted in individual tires rather than sets, because a shelf
-// that holds eight 15" tires does not hold eight 21" tires, and customers
-// store anything from a single spare to a five-wheel set. Counting the real
-// unit is the only thing that stays true in every market.
-// ---------------------------------------------------------------------------
-
-/// A physical building or room holding tire storage. Most workshops have one;
-/// multi-site operators have several.
-model TireWarehouse {
-  id         String  @id @default(cuid())
-  name       String
-  address    String?
-  notes      String?
-  /// Preselected when checking tires in. Exactly one per organization.
-  isDefault  Boolean @default(false)
-  isArchived Boolean @default(false)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  locations TireLocation[]
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@index([organizationId])
-  @@map("tire_warehouses")
-}
-
-/// One addressable storage slot: a shelf, a rack level, a pallet, a floor
-/// square. The workshop names its own geography, so `zone`/`rack`/`shelf` are
-/// free text rather than a fixed hierarchy — "Room B / Rack 4 / Shelf 2" and
-/// "Container 1 / -- / Pallet 7" both fit.
-model TireLocation {
-  id String @id @default(cuid())
-
-  /// Short human code used on labels and when directing a technician,
-  /// e.g. "B-04-2". Unique within a warehouse.
-  code String
-
-  zone     String?
-  rack     String?
-  shelf    String?
-  position String?
-
-  /// How many individual tires fit here. The shelf overview divides stored
-  /// tires by this to show how full the location is.
-  capacity Int @default(8)
-
-  notes      String?
-  isArchived Boolean @default(false)
-
-  warehouseId String
-  warehouse   TireWarehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  tireSets  TireSet[]
-  movesFrom TireMovement[] @relation("TireMovementFrom")
-  movesTo   TireMovement[] @relation("TireMovementTo")
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@unique([warehouseId, code])
-  @@index([organizationId])
-  @@index([warehouseId, isArchived])
-  @@map("tire_locations")
-}
-
-/// A group of tires stored together for one vehicle. Usually four, but the
-/// count is explicit so a pair or a five-wheel set works the same way.
-model TireSet {
-  id String @id @default(cuid())
-
-  /// Sequential per organization, shown to staff and printed on labels.
-  reference String?
-
-  /// summer | winter | all_season | other — see TIRE_SEASONS in
-  /// src/features/tire-hotel/Lib/tireConstants.ts
-  season  String  @default("summer")
-  /// Studded winter tires are legal in some markets and banned in others, so
-  /// this is a flag on the season rather than a season of its own.
-  studded Boolean @default(false)
-
-  brand          String?
-  model          String?
-  /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
-  /// "31x10.5R15".
-  size           String?
-  /// DOT / TIN week-year code used to judge tire age.
-  dotCode        String?
-  loadSpeedIndex String?
-
-  /// Mounted on rims, which changes both handling and what a shelf holds.
-  withRims Boolean @default(false)
-  rimType  String?
-  /// Tire pressure monitoring sensors present — they need care on refit.
-  hasTpms  Boolean @default(false)
-
-  /// Individual tires in this set. Drives every capacity calculation.
-  quantity Int @default(4)
-
-  /// stored | released | disposed — see TIRE_SET_STATUSES
-  status String @default("stored")
-
-  notes String?
-
-  /// Where it physically sits. Null once released to the customer.
-  locationId String?
-  location   TireLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
-
-  vehicleId String?
-  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
-
-  customerId String?
-  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  userId String
-  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
-
-  checkedInAt  DateTime?
-  checkedOutAt DateTime?
-
-  measurements   TireMeasurement[]
-  movements      TireMovement[]
-  treatments     TireTreatment[]
-  attachments    TireSetAttachment[]
-  quotes         Quote[]
-  serviceRecords ServiceRecord[]
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  @@unique([organizationId, reference])
-  @@index([organizationId, status])
-  @@index([locationId])
-  @@index([vehicleId])
-  @@index([customerId])
-  @@map("tire_sets")
-}
-
-/// Condition of one tire at one moment. Kept as history rather than current
-/// state so wear between seasons is visible, and so a damage dispute has a
-/// dated record behind it.
-/// A photo or document held against a stored set: a picture of a kerbed rim,
-/// a damage report, the customer's written agreement to store them.
-///
-/// Deliberately the same shape as ServiceAttachment, because that is where
-/// these end up when the set is billed. Copying a row between two models with
-/// the same fields cannot lose anything in translation, and the invoice PDF
-/// already knows how to render one.
-model TireSetAttachment {
-  id       String @id @default(cuid())
-  fileName String
-  fileUrl  String
-  fileType String
-  fileSize Int
-
-  /// What the technician wants said about it, e.g. "kerbing on the near side".
-  /// Travels onto the invoice as the caption under the image.
-  description String?
-
-  /// Whether it should reach the customer's invoice when this set is billed.
-  /// On by default: a photo taken at check-in is usually taken to be shown.
-  includeInInvoice Boolean @default(true)
-
-  sortOrder Int @default(0)
-
-  tireSetId String
-  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  uploadedById String?
-  uploadedBy   User?   @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
-
-  createdAt DateTime @default(now())
-
-  @@index([tireSetId])
-  @@index([organizationId])
-  @@map("tire_set_attachments")
-}
-
-model TireMeasurement {
-  id String @id @default(cuid())
-
-  /// front_left | front_right | rear_left | rear_right | spare | unspecified
-  position String @default("unspecified")
-
-  /// Always stored in millimetres. The UI converts to 32nds of an inch for
-  /// workshops on imperial units, so one number means the same thing in
-  /// every market.
-  treadDepthMm Float?
-  /// Always stored in bar. Converted to psi/kPa for display.
-  pressureBar  Float?
-
-  /// good | fair | replace — see TIRE_CONDITIONS
-  condition String  @default("good")
-  damage    String?
-  notes     String?
-
-  tireSetId String
-  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
-
-  /// Groups the four readings taken during one check-in or inspection so the
-  /// history reads as events rather than loose numbers.
-  movementId String?
-  movement   TireMovement? @relation(fields: [movementId], references: [id], onDelete: SetNull)
-
-  measuredById String?
-  measuredBy   User?   @relation(fields: [measuredById], references: [id], onDelete: SetNull)
-
-  images StoredImage[] @relation("TireMeasurementImages")
-
-  measuredAt DateTime @default(now())
-
-  @@index([tireSetId, measuredAt(sort: Desc)])
-  @@index([movementId])
-  @@map("tire_measurements")
-}
-
-/// Every physical handling of a set: arrival, departure, a move between
-/// shelves, disposal. This is the storage audit trail — "where has this been
-/// and who touched it" has to outlive the set's current row.
-model TireMovement {
-  id String @id @default(cuid())
-
-  /// check_in | check_out | relocate | dispose — see TIRE_MOVEMENT_TYPES
-  type String
-
-  /// Denormalised location codes so history still reads correctly after a
-  /// shelf is renamed or deleted.
-  fromCode String?
-  toCode   String?
-
-  fromLocationId String?
-  fromLocation   TireLocation? @relation("TireMovementFrom", fields: [fromLocationId], references: [id], onDelete: SetNull)
-
-  toLocationId String?
-  toLocation   TireLocation? @relation("TireMovementTo", fields: [toLocationId], references: [id], onDelete: SetNull)
-
-  note String?
-
-  tireSetId String
-  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  performedById String?
-  performedBy   User?   @relation(fields: [performedById], references: [id], onDelete: SetNull)
-
-  measurements TireMeasurement[]
-
-  createdAt DateTime @default(now())
-
-  @@index([tireSetId, createdAt(sort: Desc)])
-  @@index([organizationId, createdAt(sort: Desc)])
-  @@map("tire_movements")
-}
-
-/// Work a set needs before it goes back on the car, or before it goes on the
-/// shelf: washing, balancing, a TPMS service.
-///
-/// A task with a state rather than a flag on the set, because the tire
-/// department's real question is "what is left to do on this one", and a
-/// boolean cannot answer that once someone has started. Recording who
-/// finished it also settles the "was this washed?" conversation at pickup.
-model TireTreatment {
-  id String @id @default(cuid())
-
-  /// wash_tires | wash_rims | balance | tpms_service | new_valves | repair
-  /// — see TREATMENT_TYPES
-  type String
-
-  /// pending | done | skipped — see TREATMENT_STATUSES
-  status String @default("pending")
-
-  notes String?
-
-  tireSetId String
-  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
-
-  completedAt   DateTime?
-  completedById String?
-  completedBy   User?     @relation(fields: [completedById], references: [id], onDelete: SetNull)
-
-  organizationId String
-  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-
-  /// One row per kind of work per set: asking for a wash twice is the same
-  /// request, not two.
-  @@unique([tireSetId, type])
-  @@index([organizationId, status])
-  @@map("tire_treatments")
-}

+ 41 - 0
prisma/schema/ai.prisma

@@ -0,0 +1,41 @@
+model AiGeneratedMessage {
+  id        String   @id @default(cuid())
+  vehicleId String
+  type      String // "summary" | "common_issues" | etc.
+  content   String
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  @@unique([vehicleId, type])
+  @@map("ai_generated_messages")
+}
+
+model AiChat {
+  id        String   @id @default(cuid())
+  title     String   @default("New chat")
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  userId         String
+  organizationId String
+
+  messages AiChatMessage[]
+
+  @@index([organizationId, userId, updatedAt(sort: Desc)])
+  @@map("ai_chats")
+}
+
+model AiChatMessage {
+  id        String   @id @default(cuid())
+  role      String // "user" | "assistant"
+  content   String
+  createdAt DateTime @default(now())
+
+  chatId String
+  chat   AiChat @relation(fields: [chatId], references: [id], onDelete: Cascade)
+
+  @@index([chatId, createdAt])
+  @@map("ai_chat_messages")
+}

+ 22 - 0
prisma/schema/audit.prisma

@@ -0,0 +1,22 @@
+model AuditLog {
+  id        String   @id @default(cuid())
+  timestamp DateTime @default(now())
+  action    String
+  entity    String?
+  entityId  String?
+  message   String?
+  metadata  Json?
+  ip        String?
+  userAgent String?
+
+  userId String?
+  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
+
+  @@index([organizationId, timestamp(sort: Desc)])
+  @@index([userId, timestamp(sort: Desc)])
+  @@index([action, timestamp(sort: Desc)])
+  @@map("audit_logs")
+}

+ 131 - 0
prisma/schema/auth.prisma

@@ -0,0 +1,131 @@
+model User {
+  id            String   @id @default(cuid())
+  name          String
+  email         String   @unique
+  emailVerified Boolean  @default(false)
+  /// The mobile a technician signs in with, and the number a workshop holds
+  /// for whoever this is.
+  ///
+  /// Deliberately not unique. When a desk creates an account for a mechanic
+  /// standing at the counter, a second workshop may create another for the
+  /// same human, and neither should be able to block the other. Every lookup
+  /// reaches it through a technician row and so is already scoped to one
+  /// workshop; the number alone is never the question being asked.
+  phone         String?
+  image         String?
+  isSuperAdmin  Boolean  @default(false)
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+
+  twoFactorEnabled Boolean   @default(false)
+  termsAcceptedAt  DateTime?
+  lastLogin        DateTime?
+  lastSeen         DateTime?
+  /// App version this user last saw/dismissed the update banner for.
+  /// Null = never seeded (seeded silently on first load, no banner).
+  lastSeenVersion  String?
+  /// Per-user dashboard grid layout (card positions/sizes/hidden set).
+  /// Null = default layout. Shape validated in dashboardLayoutActions.
+  dashboardLayout  Json?
+
+  sessions               Session[]
+  accounts               Account[]
+  pushDevices            PushDevice[]
+  appSetupCodes          TechnicianSetupCode[]
+  vehicles               Vehicle[]
+  customers              Customer[]
+  settings               AppSetting[]
+  inventoryParts         InventoryPart[]
+  quotes                 Quote[]
+  customFieldDefinitions CustomFieldDefinition[]
+  twoFactor              TwoFactor?
+  passkeys               Passkey[]
+  organizationMembers    OrganizationMember[]
+  dashboardWidgets       DashboardWidget[]
+  auditLogs              AuditLog[]
+  technicians            Technician[]
+  stockMovements         StockMovement[]
+  tireWarehouses         TireWarehouse[]
+  tireSets               TireSet[]
+  tireSetAttachments     TireSetAttachment[]
+  tireMeasurements       TireMeasurement[]
+  tireMovements          TireMovement[]
+  tireTreatments         TireTreatment[]
+
+  @@map("users")
+}
+
+model Session {
+  id        String   @id @default(cuid())
+  expiresAt DateTime
+  token     String   @unique
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+  ipAddress String?
+  userAgent String?
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@index([userId])
+  @@map("sessions")
+}
+
+model Account {
+  id                    String    @id @default(cuid())
+  accountId             String
+  providerId            String
+  userId                String
+  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  accessToken           String?
+  refreshToken          String?
+  idToken               String?
+  accessTokenExpiresAt  DateTime?
+  refreshTokenExpiresAt DateTime?
+  scope                 String?
+  password              String?
+  createdAt             DateTime  @default(now())
+  updatedAt             DateTime  @updatedAt
+
+  @@index([userId])
+  @@map("accounts")
+}
+
+model Verification {
+  id         String    @id @default(cuid())
+  identifier String    @unique
+  value      String
+  expiresAt  DateTime
+  createdAt  DateTime? @default(now())
+  updatedAt  DateTime? @updatedAt
+
+  @@map("verifications")
+}
+
+model TwoFactor {
+  id          String @id @default(cuid())
+  secret      String
+  backupCodes String
+  userId      String @unique
+  user        User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@map("two_factor")
+}
+
+model Passkey {
+  id           String   @id @default(cuid())
+  name         String?
+  publicKey    String
+  userId       String
+  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  credentialID String   @unique
+  counter      Int
+  deviceType   String
+  backedUp     Boolean
+  transports   String?
+  aaguid       String?
+  createdAt    DateTime @default(now())
+
+  @@index([userId])
+  @@map("passkeys")
+}

+ 8 - 0
prisma/schema/base.prisma

@@ -0,0 +1,8 @@
+generator client {
+  provider = "prisma-client"
+  output   = "../../src/generated/prisma"
+}
+
+datasource db {
+  provider = "postgresql"
+}

+ 36 - 0
prisma/schema/billing.prisma

@@ -0,0 +1,36 @@
+model SubscriptionPlan {
+  id            String   @id @default(cuid())
+  name          String
+  stripePriceId String?  @unique
+  price         Float    @default(0)
+  interval      String   @default("month") // "month" | "year"
+  maxMembers    Int      @default(1)
+  features      String? // JSON feature flags
+  isActive      Boolean  @default(true)
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+
+  subscriptions Subscription[]
+
+  @@map("subscription_plans")
+}
+
+model Subscription {
+  id                   String    @id @default(cuid())
+  status               String    @default("active") // active | canceled | past_due | trialing
+  stripeSubscriptionId String?   @unique
+  stripeCustomerId     String?
+  currentPeriodStart   DateTime?
+  currentPeriodEnd     DateTime?
+  cancelAtPeriodEnd    Boolean   @default(false)
+  createdAt            DateTime  @default(now())
+  updatedAt            DateTime  @updatedAt
+
+  organizationId String       @unique
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  planId String
+  plan   SubscriptionPlan @relation(fields: [planId], references: [id])
+
+  @@map("subscriptions")
+}

+ 40 - 0
prisma/schema/custom-fields.prisma

@@ -0,0 +1,40 @@
+model CustomFieldDefinition {
+  id           String   @id @default(cuid())
+  name         String
+  label        String
+  fieldType    String   @default("text")
+  options      String?
+  defaultValue String?
+  required     Boolean  @default(false)
+  entityType   String
+  sortOrder    Int      @default(0)
+  isActive     Boolean  @default(true)
+  createdAt    DateTime @default(now())
+  updatedAt    DateTime @updatedAt
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  values CustomFieldValue[]
+
+  @@unique([organizationId, name, entityType])
+  @@index([organizationId])
+  @@map("custom_field_definitions")
+}
+
+model CustomFieldValue {
+  id         String @id @default(cuid())
+  value      String
+  entityId   String
+  entityType String
+
+  fieldId String
+  field   CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)
+
+  @@unique([fieldId, entityId])
+  @@index([fieldId])
+  @@map("custom_field_values")
+}

+ 116 - 0
prisma/schema/customers.prisma

@@ -0,0 +1,116 @@
+model Customer {
+  id             String   @id @default(cuid())
+  /// Human-facing customer number: auto-assigned sequentially on create,
+  /// editable afterwards. Unique per organization (nulls allowed for
+  /// legacy customers).
+  customerNumber String?
+  name           String
+  email          String?
+  phone          String?
+  address        String?
+  company        String?
+  taxId          String?
+  taxExempt      Boolean  @default(false)
+  notes          String?
+  telegramChatId String?
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  vehicles          Vehicle[]
+  quotes            Quote[]
+  smsMessages       SmsMessage[]
+  telegramMessages  TelegramMessage[]
+  whatsappMessages  WhatsappMessage[]
+  customerSessions  CustomerSession[]
+  scheduledMessages ScheduledMessage[]
+  serviceRequests   ServiceRequest[]
+
+  serviceRecords ServiceRecord[]
+  reminders      Reminder[]
+  tireSets       TireSet[]
+
+  @@unique([organizationId, customerNumber])
+  @@index([organizationId])
+  @@map("customers")
+}
+
+model CustomerSession {
+  id        String   @id @default(cuid())
+  token     String   @unique
+  expiresAt DateTime
+  createdAt DateTime @default(now())
+
+  customerId     String
+  customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
+  organizationId String
+
+  @@index([token])
+  @@index([customerId, organizationId])
+  @@map("customer_sessions")
+}
+
+model CustomerMagicLink {
+  id             String    @id @default(cuid())
+  token          String    @unique
+  email          String
+  organizationId String
+  expiresAt      DateTime
+  usedAt         DateTime?
+  createdAt      DateTime  @default(now())
+
+  @@index([token])
+  @@map("customer_magic_links")
+}
+
+model CustomerSmsCode {
+  id String @id @default(cuid())
+
+  /// SHA-256 of the digits, not the digits.
+  ///
+  /// The code reaches the customer in a text message and lives in their head
+  /// for a minute. It has no business also sitting in a table where reading a
+  /// row hands over somebody's portal session.
+  code String
+
+  /// Wrong guesses so far. Six digits is a million, which sounds like plenty
+  /// and is not: limiting by address does nothing about a hundred machines
+  /// sharing the work. Five attempts against one code makes the space
+  /// irrelevant, because the code is gone long before the guessing arrives.
+  attempts Int @default(0)
+
+  phone          String
+  organizationId String
+  expiresAt      DateTime
+  usedAt         DateTime?
+  createdAt      DateTime  @default(now())
+
+  @@index([phone, organizationId])
+  @@index([organizationId])
+  @@map("customer_sms_codes")
+}
+
+model ServiceRequest {
+  id            String    @id @default(cuid())
+  description   String
+  preferredDate DateTime?
+  status        String    @default("pending")
+  adminNotes    String?
+  createdAt     DateTime  @default(now())
+  updatedAt     DateTime  @updatedAt
+
+  customerId     String
+  customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
+  vehicleId      String
+  vehicle        Vehicle  @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+  organizationId String
+
+  @@index([organizationId, status])
+  @@index([customerId])
+  @@map("service_requests")
+}

+ 194 - 0
prisma/schema/inspections.prisma

@@ -0,0 +1,194 @@
+model InspectionTemplate {
+  id          String   @id @default(cuid())
+  name        String
+  description String?
+  isDefault   Boolean  @default(false)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  // Regulatory profile. `country` is an ISO-3166 alpha-2 code (or null for a
+  // shop-specific checklist), `standard` names the regime the template follows
+  // (e.g. "eu-2014-45"), and `severityScale` picks which defect vocabulary the
+  // technician grades against — see Lib/conditions.ts.
+  country       String?
+  standard      String? @default("custom")
+  severityScale String  @default("eu")
+
+  /// Provenance, for templates that came from a package rather than being
+  /// written here. `packageId` is namespaced and stable across renames
+  /// ("torqvoice/eu-roadworthiness"), which is what lets the library tell an
+  /// update from a duplicate. Null on a template the workshop wrote itself.
+  packageId      String?
+  packageVersion String?
+  /// Where it came from: "builtin", "file" or "catalogue".
+  packageSource  String?
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  sections    InspectionTemplateSection[]
+  inspections Inspection[]
+
+  @@index([organizationId])
+  @@index([organizationId, packageId])
+  @@map("inspection_templates")
+}
+
+model InspectionTemplateSection {
+  id          String  @id @default(cuid())
+  name        String
+  description String?
+  // Regulation reference for the section, e.g. "1" for Braking equipment in
+  // Directive 2014/45/EU Annex I.
+  code        String?
+  sortOrder   Int     @default(0)
+
+  templateId String
+  template   InspectionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
+
+  items InspectionTemplateItem[]
+
+  @@index([templateId])
+  @@map("inspection_template_sections")
+}
+
+model InspectionTemplateItem {
+  id          String  @id @default(cuid())
+  name        String
+  description String?
+  // Regulation reference for the check, e.g. "1.1.13".
+  code        String?
+  sortOrder   Int     @default(0)
+
+  // How the technician records the result: "condition" (defect grading only),
+  // "measurement" (numeric value in `unit`, graded against min/max),
+  // "text" (free text) or "choice" (one of `choices`).
+  inputType String   @default("condition")
+  unit      String?
+  minValue  Float?
+  maxValue  Float?
+  choices   String[] @default([])
+
+  required          Boolean  @default(false)
+  photoRequired     Boolean  @default(false)
+  // Defect severity applied when a measurement falls outside min/max.
+  defaultSeverity   String?
+  // Phrases this workshop wants offered when a defect is recorded against the
+  // check, on top of the built-in catalogue. See Lib/defectCatalogue.ts.
+  defectSuggestions String[] @default([])
+
+  sectionId String
+  section   InspectionTemplateSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
+
+  @@index([sectionId])
+  @@map("inspection_template_items")
+}
+
+model Inspection {
+  id            String    @id @default(cuid())
+  status        String    @default("in_progress")
+  mileage       Int?
+  notes         String?
+  startDateTime DateTime?
+  endDateTime   DateTime?
+  publicToken   String?   @unique
+  completedAt   DateTime?
+  createdAt     DateTime  @default(now())
+  updatedAt     DateTime  @updatedAt
+
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  templateId String
+  template   InspectionTemplate @relation(fields: [templateId], references: [id])
+
+  technicianId String?
+  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
+
+  /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
+  /// repair does, and the work board lanes both the same way.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
+
+  // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
+  // `inspectorName` and `certificateNumber` are snapshots taken at completion so
+  // the issued certificate stays stable even if staff or settings change later.
+  /// Snapshot of how this inspection was graded, taken when it was created.
+  /// Read in preference to the template's current setting: a workshop that
+  /// later switches a template to the EU scale must not relabel a certificate
+  /// it already issued. Null on inspections that predate the snapshot; those
+  /// fall back to the template, which the migration pinned to its old scale.
+  severityScale String?
+  country       String?
+
+  vehicleCategory   String?
+  nextTestDue       DateTime?
+  certificateNumber String?
+  inspectorName     String?
+  testLocation      String?
+
+  organizationId String
+
+  items          InspectionItem[]
+  quoteRequests  InspectionQuoteRequest[]
+  quotes         Quote[]
+  serviceRecords ServiceRecord[]
+
+  @@index([vehicleId])
+  @@index([organizationId])
+  @@index([technicianId])
+  @@index([workBayId])
+  @@map("inspections")
+}
+
+model InspectionItem {
+  id        String   @id @default(cuid())
+  name      String
+  section   String
+  sortOrder Int      @default(0)
+  condition String   @default("not_inspected")
+  notes     String?
+  imageUrls String[] @default([])
+
+  // Copied from the template at creation so an inspection stays readable even
+  // after its template is edited or deleted.
+  description       String?
+  code              String?
+  sectionCode       String?
+  inputType         String   @default("condition")
+  unit              String?
+  minValue          Float?
+  maxValue          Float?
+  choices           String[] @default([])
+  required          Boolean  @default(false)
+  photoRequired     Boolean  @default(false)
+  defaultSeverity   String?
+  defectSuggestions String[] @default([])
+
+  measuredValue Float?
+  textValue     String?
+
+  inspectionId String
+  inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
+
+  @@index([inspectionId])
+  @@map("inspection_items")
+}
+
+model InspectionQuoteRequest {
+  id              String   @id @default(cuid())
+  status          String   @default("pending")
+  message         String?
+  selectedItemIds String[]
+  createdAt       DateTime @default(now())
+
+  inspectionId String
+  inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
+
+  organizationId String
+
+  @@index([organizationId])
+  @@index([inspectionId])
+  @@map("inspection_quote_requests")
+}

+ 130 - 0
prisma/schema/integrations.prisma

@@ -0,0 +1,130 @@
+/// One installed connector for one organisation: the account it is linked
+/// to, its sealed credentials, and the settings the workshop chose. Rows in
+/// the other integration tables hang off this one.
+model IntegrationConnection {
+  id                  String       @id @default(cuid())
+  organizationId      String
+  organization        Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  /// Connector id from the registry, for example 'google-calendar'.
+  connectorId         String
+  /// pending | active | error | disconnected
+  status              String       @default("pending")
+  label               String?
+  /// Tokens or keys as one sealed string; only the vault reads it.
+  credentials         String?
+  settings            Json         @default("{}")
+  /// Connector-owned state such as sync cursors and last-run times.
+  state               Json         @default("{}")
+  externalAccountId   String?
+  externalAccountName String?
+  scopes              String?
+  /// OAuth handshake nonce, set while the connection is pending.
+  oauthState          String?      @unique
+  lastHealthAt        DateTime?
+  lastSyncAt          DateTime?
+  lastError           String?
+  createdById         String
+  createdAt           DateTime     @default(now())
+  updatedAt           DateTime     @updatedAt
+
+  links          IntegrationLink[]
+  jobs           IntegrationJob[]
+  logs           IntegrationLog[]
+  externalEvents ExternalCalendarEvent[]
+
+  @@unique([organizationId, connectorId])
+  @@index([organizationId])
+  @@map("integration_connections")
+}
+
+/// The remote id a local record has in one connected system, which is what
+/// makes every push an update instead of a duplicate.
+model IntegrationLink {
+  id              String                @id @default(cuid())
+  connectionId    String
+  connection      IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  /// Local model name, for example 'ServiceRecord'.
+  entityType      String
+  entityId        String
+  remoteId        String
+  remoteUrl       String?
+  /// Connector-owned extras, such as a meeting link.
+  metadata        Json?
+  checksum        String?
+  remoteUpdatedAt DateTime?
+  lastSyncedAt    DateTime              @default(now())
+  createdAt       DateTime              @default(now())
+
+  @@unique([connectionId, entityType, entityId])
+  @@index([connectionId, remoteId])
+  @@index([entityType, entityId])
+  @@map("integration_links")
+}
+
+/// A unit of work for a connector, claimed and retried by the cron.
+model IntegrationJob {
+  id             String                @id @default(cuid())
+  connectionId   String
+  connection     IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  organizationId String
+  kind           String
+  payload        Json?
+  /// Set while the job is pending so the same work is queued once; cleared when it runs.
+  idempotencyKey String?
+  /// queued | running | done | failed | dead
+  status         String                @default("queued")
+  attempts       Int                   @default(0)
+  maxAttempts    Int                   @default(5)
+  runAfter       DateTime              @default(now())
+  lockedAt       DateTime?
+  startedAt      DateTime?
+  finishedAt     DateTime?
+  error          String?
+  createdAt      DateTime              @default(now())
+  updatedAt      DateTime              @updatedAt
+
+  @@unique([connectionId, idempotencyKey])
+  @@index([status, runAfter])
+  @@index([connectionId, createdAt])
+  @@index([organizationId, createdAt])
+  @@map("integration_jobs")
+}
+
+/// What a connector did and why it failed, per connection, kept for 30 days.
+model IntegrationLog {
+  id           String                @id @default(cuid())
+  connectionId String
+  connection   IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  jobId        String?
+  /// info | warn | error
+  level        String                @default("info")
+  message      String
+  details      Json?
+  createdAt    DateTime              @default(now())
+
+  @@index([connectionId, createdAt])
+  @@map("integration_logs")
+}
+
+/// Busy time pulled from a connected calendar, shown read-only on the
+/// workshop calendar. A cache: rebuilt by the pull job, never edited.
+model ExternalCalendarEvent {
+  id              String                @id @default(cuid())
+  connectionId    String
+  connection      IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  organizationId  String
+  organization    Organization          @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  remoteId        String
+  calendarId      String
+  title           String
+  startAt         DateTime
+  endAt           DateTime
+  allDay          Boolean               @default(false)
+  remoteUpdatedAt DateTime?
+  createdAt       DateTime              @default(now())
+  updatedAt       DateTime              @updatedAt
+
+  @@unique([connectionId, remoteId])
+  @@index([organizationId, startAt])
+  @@map("external_calendar_events")
+}

+ 159 - 0
prisma/schema/inventory.prisma

@@ -0,0 +1,159 @@
+model LaborPreset {
+  id             String            @id @default(cuid())
+  name           String
+  description    String?
+  isArchived     Boolean           @default(false)
+  createdAt      DateTime          @default(now())
+  updatedAt      DateTime          @updatedAt
+  userId         String
+  organizationId String
+  organization   Organization      @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  items          LaborPresetItem[]
+  parts          LaborPresetPart[]
+
+  @@index([organizationId])
+  @@map("labor_presets")
+}
+
+model LaborPresetItem {
+  id          String      @id @default(cuid())
+  description String
+  hours       Float       @default(0)
+  rate        Float       @default(0)
+  pricingType String      @default("hourly")
+  sortOrder   Int         @default(0)
+  presetId    String
+  preset      LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
+
+  @@index([presetId])
+  @@map("labor_preset_items")
+}
+
+model LaborPresetPart {
+  id              String  @id @default(cuid())
+  name            String
+  partNumber      String?
+  quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time.
+  unit            String?
+  unitPrice       Float   @default(0)
+  inventoryPartId String?
+  sortOrder       Int     @default(0)
+
+  presetId String
+  preset   LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
+
+  @@index([presetId])
+  @@map("labor_preset_parts")
+}
+
+model InventoryPart {
+  id            String  @id @default(cuid())
+  partNumber    String?
+  barcode       String?
+  name          String
+  description   String?
+  category      String?
+  quantity      Float   @default(0)
+  minQuantity   Float   @default(0)
+  /// Unit of measure for `quantity` (e.g. "pcs", "l", "kg"). Free text,
+  /// snapshotted onto part lines at pick time so invoices keep the unit the
+  /// part had when it was billed.
+  unit          String?
+  unitCost      Float   @default(0)
+  sellPrice     Float   @default(0)
+  supplier      String?
+  supplierPhone String?
+  supplierEmail String?
+  supplierUrl   String?
+  imageUrl      String?
+  location      String?
+  isArchived    Boolean @default(false)
+
+  /// Set when a low-stock alert has been sent for the current dip below
+  /// `minQuantity`, and cleared once stock climbs back above it. This
+  /// hysteresis is what stops a part that sits at zero from re-alerting on
+  /// every check — it alerts once per descent, not once per run.
+  lowStockAlertedAt DateTime?
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  gallery   StoredImage[]   @relation("InventoryPartImages")
+  movements StockMovement[]
+
+  @@unique([organizationId, barcode])
+  @@index([organizationId])
+  @@map("inventory_parts")
+}
+
+/// Append-only audit trail of every change to InventoryPart.quantity.
+///
+/// Written inside the same transaction as the stock mutation itself, so the
+/// ledger can never drift from the balance it describes. `quantityAfter` is
+/// captured from the atomic `UPDATE ... RETURNING`, giving an exact running
+/// balance even under concurrent writes.
+model StockMovement {
+  id String @id @default(cuid())
+
+  /// Signed change in stock units (fractional allowed, e.g. 0.5 l).
+  /// Negative = consumed, positive = restocked.
+  delta         Float
+  /// Part balance immediately after this movement was applied.
+  quantityAfter Float
+  /// Why the stock moved — see StockMovementReason in src/features/inventory/Lib/stockMovement.ts
+  reason        String
+  note          String?
+
+  inventoryPartId String
+  inventoryPart   InventoryPart @relation(fields: [inventoryPartId], references: [id], onDelete: Cascade)
+
+  /// The job the part was used on. Kept on SetNull so history survives
+  /// deletion of the service record — "where was this used" outlives the job.
+  serviceRecordId String?
+  serviceRecord   ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: SetNull)
+
+  /// Denormalised so history still reads correctly after a service record is
+  /// deleted and serviceRecordId goes null.
+  serviceRecordLabel String?
+
+  userId String?
+  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+
+  @@index([inventoryPartId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([serviceRecordId])
+  @@map("stock_movements")
+}
+
+model StoredImage {
+  id          String   @id @default(cuid())
+  url         String
+  fileName    String?
+  description String?
+  sortOrder   Int      @default(0)
+  createdAt   DateTime @default(now())
+
+  inventoryPartId String?
+  inventoryPart   InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
+
+  /// Condition photos taken when a tire is measured. Second parent on this
+  /// model — an image belongs to exactly one of them.
+  tireMeasurementId String?
+  tireMeasurement   TireMeasurement? @relation("TireMeasurementImages", fields: [tireMeasurementId], references: [id], onDelete: Cascade)
+
+  @@index([inventoryPartId])
+  @@index([tireMeasurementId])
+  @@map("stored_images")
+}

+ 155 - 0
prisma/schema/messaging.prisma

@@ -0,0 +1,155 @@
+model Notification {
+  id             String   @id @default(cuid())
+  type           String
+  title          String
+  message        String
+  entityType     String
+  entityId       String
+  entityUrl      String
+  read           Boolean  @default(false)
+  organizationId String
+  createdAt      DateTime @default(now())
+
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([organizationId, read])
+  @@map("notifications")
+}
+
+model SmsMessage {
+  id                String   @id @default(cuid())
+  direction         String // "inbound" | "outbound"
+  fromNumber        String
+  toNumber          String
+  body              String
+  status            String   @default("queued") // queued | sent | delivered | failed | received
+  providerMsgId     String?
+  errorMessage      String?
+  relatedEntityType String?
+  relatedEntityId   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)
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([toNumber, organizationId])
+  @@map("sms_messages")
+}
+
+model TelegramMessage {
+  id                String   @id @default(cuid())
+  direction         String // "inbound" | "outbound"
+  chatId            String // Telegram chat ID
+  body              String
+  status            String   @default("queued") // queued | sent | delivered | failed | received
+  telegramMessageId String? // Telegram's message ID for tracking
+  errorMessage      String?
+  relatedEntityType String?
+  relatedEntityId   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)
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, chatId, createdAt(sort: Desc)])
+  @@map("telegram_messages")
+}
+
+/// One WhatsApp message in either direction.
+///
+/// `provider` records which adapter carried it, because a workshop can switch
+/// provider without its history becoming unreadable, and a status callback
+/// arriving later has to find the row it belongs to.
+model WhatsappMessage {
+  id        String @id @default(cuid())
+  direction String // "inbound" | "outbound"
+  provider  String // "meta" | "twilio" | any adapter registered later
+
+  /// E.164 without the "whatsapp:" prefix a provider may use on the wire.
+  fromNumber String
+  toNumber   String
+
+  body String?
+
+  /// Media the message carried. Inbound URLs may need the provider's
+  /// credentials to fetch, so they are not necessarily public.
+  mediaUrl      String?
+  mediaType     String? // image | document | video | audio | sticker
+  mediaFilename String?
+
+  /// Set when the message had to go out as an approved template, which is the
+  /// only thing WhatsApp allows outside the 24 hour service window.
+  templateName String?
+
+  status            String  @default("queued") // queued | sent | delivered | read | failed | received
+  providerMessageId String?
+  errorMessage      String?
+
+  relatedEntityType String?
+  relatedEntityId   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)
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([providerMessageId])
+  @@map("whatsapp_messages")
+}
+
+/// 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")
+}

+ 155 - 0
prisma/schema/organization.prisma

@@ -0,0 +1,155 @@
+model AppSetting {
+  id    String @id @default(cuid())
+  key   String
+  value String
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  @@unique([organizationId, key])
+  @@index([organizationId])
+  @@map("app_settings")
+}
+
+/// A user-defined dashboard card: a named, filtered table over one entity.
+/// The config shape (entity, filters, columns, limit) is validated against
+/// the field registry in customCardActions — nothing here reaches SQL raw.
+model DashboardWidget {
+  id             String   @id @default(cuid())
+  name           String
+  config         Json
+  userId         String
+  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  organizationId String
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  @@index([userId, organizationId])
+  @@map("dashboard_widgets")
+}
+
+model Organization {
+  id         String   @id @default(cuid())
+  name       String
+  portalSlug String?  @unique
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @updatedAt
+
+  members                OrganizationMember[]
+  roles                  Role[]
+  vehicles               Vehicle[]
+  serviceRecords         ServiceRecord[]
+  reminders              Reminder[]
+  customers              Customer[]
+  quotes                 Quote[]
+  inventoryParts         InventoryPart[]
+  laborPresets           LaborPreset[]
+  customFieldDefinitions CustomFieldDefinition[]
+  settings               AppSetting[]
+  subscription           Subscription?
+  invitations            TeamInvitation[]
+  inspectionTemplates    InspectionTemplate[]
+  smsMessages            SmsMessage[]
+  telegramMessages       TelegramMessage[]
+  whatsappMessages       WhatsappMessage[]
+  technicians            Technician[]
+  workBays               WorkBay[]
+  timeEntries            TimeEntry[]
+  pushDevices            PushDevice[]
+  appSetupCodes          TechnicianSetupCode[]
+  technicianLoginCodes   TechnicianLoginCode[]
+  auditLogs              AuditLog[]
+  statusReports          StatusReport[]
+  reportSchedules        ReportSchedule[]
+  scheduledMessages      ScheduledMessage[]
+  webhooks               Webhook[]
+  stockMovements         StockMovement[]
+  tireWarehouses         TireWarehouse[]
+  tireLocations          TireLocation[]
+  tireSets               TireSet[]
+  tireSetAttachments     TireSetAttachment[]
+  tireMovements          TireMovement[]
+  tireTreatments         TireTreatment[]
+  integrationConnections IntegrationConnection[]
+  externalCalendarEvents ExternalCalendarEvent[]
+
+  @@map("organizations")
+}
+
+model OrganizationMember {
+  id   String @id @default(cuid())
+  role String @default("member")
+
+  userId         String
+  user           User         @relation(fields: [userId], references: [id], onDelete: Cascade)
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  roleId     String?
+  customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
+
+  @@unique([userId, organizationId])
+  @@map("organization_members")
+}
+
+model Role {
+  id        String   @id @default(cuid())
+  name      String
+  isAdmin   Boolean  @default(false)
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  permissions     Permission[]
+  members         OrganizationMember[]
+  teamInvitations TeamInvitation[]
+
+  @@unique([organizationId, name])
+  @@map("roles")
+}
+
+model Permission {
+  id      String @id @default(cuid())
+  action  String
+  subject String
+
+  roleId String
+  role   Role   @relation(fields: [roleId], references: [id], onDelete: Cascade)
+
+  @@unique([roleId, action, subject])
+  @@index([roleId])
+  @@map("permissions")
+}
+
+model SystemSetting {
+  id    String @id @default(cuid())
+  key   String @unique
+  value String
+
+  @@map("system_settings")
+}
+
+model TeamInvitation {
+  id             String       @id @default(cuid())
+  email          String
+  role           String       @default("member")
+  token          String       @unique
+  status         String       @default("pending") // pending | accepted | cancelled
+  expiresAt      DateTime
+  createdAt      DateTime     @default(now())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  invitedById    String
+
+  roleId     String?
+  customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
+
+  @@unique([email, organizationId])
+  @@index([token])
+  @@map("team_invitations")
+}

+ 124 - 0
prisma/schema/quotes.prisma

@@ -0,0 +1,124 @@
+model Quote {
+  id              String    @id @default(cuid())
+  quoteNumber     String?
+  title           String
+  description     String?
+  status          String    @default("draft")
+  validUntil      DateTime?
+  subtotal        Float     @default(0)
+  taxRate         Float     @default(0)
+  taxAmount       Float     @default(0)
+  taxInclusive    Boolean   @default(false)
+  discountType    String?
+  discountValue   Float     @default(0)
+  discountAmount  Float     @default(0)
+  totalAmount     Float     @default(0)
+  notes           String?
+  publicToken     String?
+  sharedAt        DateTime?
+  viewCount       Int       @default(0)
+  lastViewedAt    DateTime?
+  customerMessage String?
+  convertedToId   String?
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+
+  /// Most recent time this quote reached the customer, by email or share
+  /// link. Exists so an unlock is spent when the quote is issued again; the
+  /// status alone cannot show that, because re-sending an accepted quote does
+  /// not change it.
+  sentAt DateTime?
+
+  /// See ServiceRecord.editUnlockedAt.
+  editUnlockedAt   DateTime?
+  editUnlockedById String?
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  /// The stored set this quote is about, when it came out of the tire hotel.
+  /// SetNull rather than Cascade: a quote already sent is a document the
+  /// customer holds, and deleting the set must not erase it.
+  tireSetId String?
+  tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
+
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
+
+  inspectionId String?
+  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
+
+  partItems   QuotePart[]
+  laborItems  QuoteLabor[]
+  attachments QuoteAttachment[]
+
+  @@index([organizationId])
+  @@map("quotes")
+}
+
+model QuoteAttachment {
+  id               String   @id @default(cuid())
+  fileName         String
+  fileUrl          String
+  fileType         String
+  fileSize         Int
+  category         String   @default("image")
+  description      String?
+  includeInInvoice Boolean  @default(true)
+  createdAt        DateTime @default(now())
+
+  quoteId String
+  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
+
+  @@index([quoteId])
+  @@map("quote_attachments")
+}
+
+model QuotePart {
+  id            String  @id @default(cuid())
+  partNumber    String?
+  name          String
+  quantity      Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit          String?
+  /// Internal purchase cost — never shown to customers (quote PDF/share
+  /// render unitPrice only)
+  unitCost      Float   @default(0)
+  markupPercent Float   @default(0)
+  unitPrice     Float   @default(0)
+  total         Float   @default(0)
+  excluded      Boolean @default(false)
+
+  /// Optional link to the stocked part this line was picked from. Carried into
+  /// ServicePart on conversion so the job deducts the right inventory item.
+  inventoryPartId String?
+
+  quoteId String
+  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
+
+  @@index([quoteId])
+  @@map("quote_parts")
+}
+
+model QuoteLabor {
+  id          String  @id @default(cuid())
+  description String
+  hours       Float   @default(0)
+  rate        Float   @default(0)
+  total       Float   @default(0)
+  pricingType String  @default("hourly")
+  excluded    Boolean @default(false)
+
+  quoteId String
+  quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
+
+  @@index([quoteId])
+  @@map("quote_labor")
+}

+ 57 - 0
prisma/schema/reports.prisma

@@ -0,0 +1,57 @@
+model StatusReport {
+  id               String    @id @default(cuid())
+  title            String?
+  message          String?   @db.Text
+  videoUrl         String?
+  videoFileName    String?
+  publicToken      String    @unique
+  status           String    @default("draft") // draft | published | sent | viewed
+  sentVia          String? // email, sms, telegram (comma-separated)
+  sentAt           DateTime?
+  viewedAt         DateTime?
+  customerFeedback String?   @db.Text
+  feedbackAt       DateTime?
+  expiresAt        DateTime?
+  createdAt        DateTime  @default(now())
+  updatedAt        DateTime  @updatedAt
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  technicianId String?
+  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
+
+  @@index([serviceRecordId])
+  @@index([organizationId])
+  @@index([publicToken])
+  @@index([expiresAt])
+  @@map("status_reports")
+}
+
+model ReportSchedule {
+  id          String    @id @default(cuid())
+  name        String    @default("Scheduled Report")
+  frequency   String // "daily" | "weekly" | "monthly" etc.
+  dateRange   String    @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
+  sections    String // JSON array of section keys
+  recipients  String // JSON array of user IDs
+  nextRunDate DateTime
+  endDate     DateTime?
+  isActive    Boolean   @default(true)
+  lastRunAt   DateTime?
+  runCount    Int       @default(0)
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdById String
+
+  @@index([organizationId])
+  @@index([nextRunDate, isActive])
+  @@map("report_schedules")
+}

+ 274 - 0
prisma/schema/service-records.prisma

@@ -0,0 +1,274 @@
+model ServiceRecord {
+  id              String    @id @default(cuid())
+  title           String
+  description     String?
+  type            String    @default("maintenance")
+  status          String    @default("completed")
+  cost            Float     @default(0)
+  mileage         Int?
+  serviceDate     DateTime  @default(now())
+  startDateTime   DateTime?
+  endDateTime     DateTime?
+  shopName        String?
+  techName        String?
+  parts           String?
+  laborHours      Float?
+  diagnosticNotes String?
+  invoiceNotes    String?
+  subtotal        Float     @default(0)
+  taxRate         Float     @default(0)
+  taxAmount       Float     @default(0)
+  taxInclusive    Boolean   @default(false)
+  totalAmount     Float     @default(0)
+  invoiceNumber   String?
+  invoiceDate     DateTime?
+  invoiceDueDate  DateTime?
+  discountType    String?
+  discountValue   Float     @default(0)
+  discountAmount  Float     @default(0)
+  manuallyPaid    Boolean   @default(false)
+
+  // Warranty tracking
+  warrantyMonths    Int?
+  warrantyMileage   Int?
+  warrantyExpiresAt DateTime?
+  warrantyNotes     String?
+
+  publicToken  String?   @unique
+  sharedAt     DateTime?
+  viewCount    Int       @default(0)
+  lastViewedAt DateTime?
+  createdAt    DateTime  @default(now())
+  updatedAt    DateTime  @updatedAt
+
+  /// First time this invoice reached the customer, by email or share link.
+  /// Distinct from sharedAt, which tracks only the link and is cleared when
+  /// the link is revoked; sending is not undone by withdrawing the link.
+  sentAt DateTime?
+
+  /// Set when an owner or admin deliberately reopens a locked document.
+  /// Nullable rather than a boolean so the audit trail can show when, and the
+  /// screen can say the document was reopened rather than never locked.
+  editUnlockedAt   DateTime?
+  editUnlockedById String?
+
+  /// The stored set this job is about, when it came out of the tire hotel.
+  /// Without it a tire-change work order says nothing about which tires, and
+  /// the technician has to go and ask.
+  tireSetId String?
+  tireSet   TireSet? @relation(fields: [tireSetId], references: [id], onDelete: SetNull)
+
+  /// Null for parts-only / counter sales; such records use customerId
+  /// (or neither, for anonymous walk-in sales)
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  /// Direct customer for records without a vehicle. Vehicle-linked records
+  /// leave this null and resolve the customer through the vehicle, so
+  /// reassigning a vehicle keeps invoices following the vehicle's customer.
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  /// Set when the job came straight from an inspection, so the inspection can
+  /// show what was raised from it. SetNull: deleting the inspection must not
+  /// take the job — and its invoice — with it.
+  inspectionId String?
+  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
+
+  /// Denormalized from the vehicle historically; now the canonical scope so
+  /// records exist without a vehicle. Nullable to mirror Vehicle.organizationId.
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  technicianId String?
+  technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
+
+  /// Where in the shop the job is booked in: a lift, a ramp, an alignment
+  /// stand. Independent of the technician, because a shop plans its bays and
+  /// its people separately and the work board can lane by either.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
+
+  partItems      ServicePart[]
+  laborItems     ServiceLabor[]
+  attachments    ServiceAttachment[]
+  payments       Payment[]
+  statusReports  StatusReport[]
+  stockMovements StockMovement[]
+  timeEntries    TimeEntry[]
+
+  concerns           ServiceConcern[]
+  discoveredFindings VehicleFinding[] @relation("FindingDiscoveredIn")
+  resolvedFindings   VehicleFinding[] @relation("FindingResolvedIn")
+
+  @@index([vehicleId])
+  @@index([technicianId])
+  @@index([workBayId])
+  @@index([organizationId])
+  @@index([customerId])
+  @@index([inspectionId])
+  @@map("service_records")
+}
+
+/// One thing the customer asked about, in their words.
+///
+/// A job is a list of these rather than a single note, because that is how the
+/// work actually divides: "it pulls right when braking" and "the aircon smells"
+/// are two conversations, two diagnoses and two lines on the bill. Storing them
+/// as one blob means the shop can answer one and quietly drop the other, which
+/// is the single most common reason a car comes back.
+///
+/// This is the first of the trade's three Cs. The cause is a VehicleFinding
+/// pointing back here, carrying its own notes and photographs; the correction
+/// is the labour and parts on the job. Together they let the invoice explain
+/// itself: you said this, we found that, we did this about it.
+model ServiceConcern {
+  id          String   @id @default(cuid())
+  /// The customer's own words, kept as they said them.
+  description String
+  /// The order the customer raised them in, which is the order they care about.
+  sortOrder   Int      @default(0)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  /// What the shop found when it went looking. Empty means nobody has answered
+  /// this one yet, which is worth being able to ask about before closing a job.
+  findings VehicleFinding[]
+
+  @@index([serviceRecordId])
+  @@map("service_concerns")
+}
+
+model Payment {
+  id         String   @id @default(cuid())
+  amount     Float
+  date       DateTime @default(now())
+  method     String   @default("other")
+  note       String?
+  provider   String?
+  externalId String?
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @updatedAt
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  @@index([serviceRecordId])
+  @@map("payments")
+}
+
+model ServiceAttachment {
+  id               String   @id @default(cuid())
+  fileName         String
+  fileUrl          String
+  fileType         String
+  fileSize         Int
+  category         String   @default("diagnostic")
+  description      String?
+  includeInInvoice Boolean  @default(true)
+  createdAt        DateTime @default(now())
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  @@index([serviceRecordId])
+  @@map("service_attachments")
+}
+
+model ServicePart {
+  id              String  @id @default(cuid())
+  partNumber      String?
+  name            String
+  quantity        Float   @default(1)
+  /// Unit of measure snapshotted from InventoryPart at pick time; null for
+  /// free-text lines and legacy rows.
+  unit            String?
+  unitPrice       Float   @default(0)
+  total           Float   @default(0)
+  unitCost        Float   @default(0)
+  markupPercent   Float   @default(0)
+  inventoryPartId String?
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  @@index([serviceRecordId])
+  @@map("service_parts")
+}
+
+model ServiceLabor {
+  id          String @id @default(cuid())
+  description String
+  hours       Float  @default(0)
+  rate        Float  @default(0)
+  total       Float  @default(0)
+  pricingType String @default("hourly")
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  @@index([serviceRecordId])
+  @@map("service_labor")
+}
+
+model RecurringInvoice {
+  id          String    @id @default(cuid())
+  title       String
+  description String?
+  frequency   String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
+  nextRunDate DateTime
+  endDate     DateTime?
+  isActive    Boolean   @default(true)
+  lastRunAt   DateTime?
+  runCount    Int       @default(0)
+
+  // Template fields (copied to ServiceRecord on generation)
+  type         String  @default("maintenance")
+  cost         Float   @default(0)
+  taxRate      Float   @default(0)
+  taxInclusive Boolean @default(false)
+  invoiceNotes String?
+
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  templateParts RecurringPart[]
+  templateLabor RecurringLabor[]
+
+  @@index([vehicleId])
+  @@index([nextRunDate, isActive])
+  @@map("recurring_invoices")
+}
+
+model RecurringPart {
+  id         String  @id @default(cuid())
+  name       String
+  partNumber String?
+  quantity   Float   @default(1)
+  unitPrice  Float   @default(0)
+
+  recurringInvoiceId String
+  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
+
+  @@map("recurring_parts")
+}
+
+model RecurringLabor {
+  id          String @id @default(cuid())
+  description String
+  hours       Float  @default(0)
+  rate        Float  @default(0)
+  pricingType String @default("hourly")
+
+  recurringInvoiceId String
+  recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
+
+  @@map("recurring_labor")
+}

+ 229 - 0
prisma/schema/technicians.prisma

@@ -0,0 +1,229 @@
+model Technician {
+  id            String  @id @default(cuid())
+  name          String
+  color         String  @default("#3b82f6")
+  isActive      Boolean @default(true)
+  sortOrder     Int     @default(0)
+  dailyCapacity Int     @default(480)
+
+  userId String?
+  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  loginCodes     TechnicianLoginCode[]
+  serviceRecords ServiceRecord[]
+  inspections    Inspection[]
+  statusReports  StatusReport[]
+  timeEntries    TimeEntry[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("technicians")
+}
+
+/// One stretch of a technician actually working on a job, as clocked from the
+/// technician app.
+///
+/// Deliberately separate from `ServiceRecord.startDateTime` / `endDateTime`:
+/// those are the work board's planned slot, one pair per job, owned by whoever
+/// schedules the day. This is what happened, many rows per job, one per person
+/// per stretch. A job worked on Monday, parked for parts and finished Thursday
+/// by someone else is two entries, and neither of them is the booking.
+///
+/// `endedAt` null means the clock is still running. At most one open entry per
+/// technician is enforced in the API rather than the schema, because "open"
+/// is not a value a partial unique index can express portably.
+model TimeEntry {
+  id String @id @default(cuid())
+
+  startedAt DateTime  @default(now())
+  endedAt   DateTime?
+
+  /// Cached on stop so reports do not recompute across millions of rows, and
+  /// so a correction to the timestamps stays visible against what was billed.
+  durationMinutes Int?
+
+  /// What the technician typed when stopping, if anything.
+  note String?
+
+  /// "app" | "web" | "manual". A manually corrected entry is not the same
+  /// evidence as one clocked live, and Arbeitszeiterfassung cares about that.
+  source String @default("app")
+
+  /// Set when someone edited the times after the fact, so the audit trail can
+  /// show the entry is no longer purely as-clocked.
+  editedAt       DateTime?
+  editedByUserId String?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  serviceRecordId String
+  serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([technicianId, startedAt])
+  @@index([serviceRecordId])
+  @@index([organizationId, startedAt])
+  /// Finding the technician's open entry is the hottest query in the app: it
+  /// runs on every launch and every job screen.
+  @@index([technicianId, endedAt])
+  @@map("time_entries")
+}
+
+/// A phone that has asked to be told about its technician's work.
+///
+/// Keyed on the Expo push token rather than on the user, because the unit that
+/// receives a notification is a device: one technician may carry a personal
+/// phone and a shared bench tablet, and a shared tablet may be signed into by
+/// several people over a week. Both cases have to work.
+///
+/// The token is a routing address, not a secret, but it is still deleted on
+/// sign-out: a device that has signed out must stop receiving a workshop's job
+/// details, and expiry alone would leave a window where it still did.
+/// A one-time code that puts a technician's phone onto a workshop.
+///
+/// Typing a URL and a password is the worst moment in the app, and it lands on
+/// the person least equipped for it: a technician with dirty hands, on a
+/// phone, being watched. The desk shows a QR instead and the phone reads it.
+///
+/// It is a bearer credential in a picture, so it is deliberately short-lived,
+/// single-use, and bound to one person in one workshop. It exchanges for a
+/// normal session and is then dead; it is never itself a way to authenticate.
+/// A one-time code that signs a technician back in.
+///
+/// Modelled on CustomerSmsCode, which does the same job for the customer
+/// portal: org-scoped, single use, short-lived. The organisation is not
+/// optional and is never inferred from the phone number. A technician's phone
+/// is only ever looked up inside the workshop their app already belongs to,
+/// so nothing here can reach across workshops even if a person works at two.
+model TechnicianLoginCode {
+  id String @id @default(cuid())
+
+  /// SHA-256 of the digits. The code exists in a text message and in the
+  /// technician's head, and nowhere that can be read back out of a database.
+  codeHash String
+
+  /// How it was sent, so the app can say "check your messages" or "check your
+  /// email" rather than guessing on the technician's behalf.
+  channel String
+
+  /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
+  /// does not protect: this burns the code after a handful of attempts.
+  attempts Int @default(0)
+
+  expiresAt DateTime
+  usedAt    DateTime?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+
+  @@index([organizationId, technicianId])
+  @@index([expiresAt])
+  @@map("technician_login_codes")
+}
+
+model TechnicianSetupCode {
+  id String @id @default(cuid())
+
+  /// SHA-256 of the code. The code itself is shown once, on the screen that
+  /// asked for it, and is never written down anywhere it could be read back.
+  codeHash String @unique
+
+  expiresAt DateTime
+  /// Set on redemption. Kept rather than deleted so a second scan can say the
+  /// code was already used, which is a different problem from a wrong code.
+  usedAt    DateTime?
+
+  /// Who the code signs in.
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  /// Who issued it, for the audit trail. Not a relation: the issuer may be
+  /// removed from the workshop later and that must not delete the record.
+  issuedByUserId String
+
+  createdAt DateTime @default(now())
+
+  @@index([organizationId, expiresAt])
+  @@index([userId])
+  @@map("technician_setup_codes")
+}
+
+model PushDevice {
+  id String @id @default(cuid())
+
+  /// Expo push token, unique across the estate. A reinstall issues a new one
+  /// and the old becomes invalid, which is why sends prune on rejection.
+  token String @unique
+
+  /// "ios" | "android". Only used to explain failures in logs.
+  platform String
+
+  /// Set false when Expo reports the token as dead, rather than deleting, so
+  /// a flapping device does not churn rows.
+  isActive Boolean @default(true)
+
+  /// Last time the app confirmed this token still belongs to this user, so a
+  /// device that stops checking in can be aged out.
+  lastSeenAt DateTime @default(now())
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([userId, isActive])
+  @@index([organizationId])
+  @@map("push_devices")
+}
+
+/// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
+/// plan by bay as often as by person — the bay is the scarce resource, and a
+/// technician moves between them during a day. The work board can group its
+/// lanes by either, so both are first-class.
+model WorkBay {
+  id            String  @id @default(cuid())
+  name          String
+  color         String  @default("#64748b")
+  isActive      Boolean @default(true)
+  /// Ordering on the board, left to right. Shops name bays by position
+  /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
+  sortOrder     Int     @default(0)
+  /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
+  /// can show utilisation for whichever lane grouping is on.
+  dailyCapacity Int     @default(480)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  serviceRecords ServiceRecord[]
+  inspections    Inspection[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("work_bays")
+}

+ 302 - 0
prisma/schema/tire-hotel.prisma

@@ -0,0 +1,302 @@
+/// A physical building or room holding tire storage. Most workshops have one;
+/// multi-site operators have several.
+model TireWarehouse {
+  id         String  @id @default(cuid())
+  name       String
+  address    String?
+  notes      String?
+  /// Preselected when checking tires in. Exactly one per organization.
+  isDefault  Boolean @default(false)
+  isArchived Boolean @default(false)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  locations TireLocation[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("tire_warehouses")
+}
+
+/// One addressable storage slot: a shelf, a rack level, a pallet, a floor
+/// square. The workshop names its own geography, so `zone`/`rack`/`shelf` are
+/// free text rather than a fixed hierarchy — "Room B / Rack 4 / Shelf 2" and
+/// "Container 1 / -- / Pallet 7" both fit.
+model TireLocation {
+  id String @id @default(cuid())
+
+  /// Short human code used on labels and when directing a technician,
+  /// e.g. "B-04-2". Unique within a warehouse.
+  code String
+
+  zone     String?
+  rack     String?
+  shelf    String?
+  position String?
+
+  /// How many individual tires fit here. The shelf overview divides stored
+  /// tires by this to show how full the location is.
+  capacity Int @default(8)
+
+  notes      String?
+  isArchived Boolean @default(false)
+
+  warehouseId String
+  warehouse   TireWarehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  tireSets  TireSet[]
+  movesFrom TireMovement[] @relation("TireMovementFrom")
+  movesTo   TireMovement[] @relation("TireMovementTo")
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@unique([warehouseId, code])
+  @@index([organizationId])
+  @@index([warehouseId, isArchived])
+  @@map("tire_locations")
+}
+
+/// A group of tires stored together for one vehicle. Usually four, but the
+/// count is explicit so a pair or a five-wheel set works the same way.
+model TireSet {
+  id String @id @default(cuid())
+
+  /// Sequential per organization, shown to staff and printed on labels.
+  reference String?
+
+  /// summer | winter | all_season | other — see TIRE_SEASONS in
+  /// src/features/tire-hotel/Lib/tireConstants.ts
+  season  String  @default("summer")
+  /// Studded winter tires are legal in some markets and banned in others, so
+  /// this is a flag on the season rather than a season of its own.
+  studded Boolean @default(false)
+
+  brand          String?
+  model          String?
+  /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
+  /// "31x10.5R15".
+  size           String?
+  /// DOT / TIN week-year code used to judge tire age.
+  dotCode        String?
+  loadSpeedIndex String?
+
+  /// Mounted on rims, which changes both handling and what a shelf holds.
+  withRims Boolean @default(false)
+  rimType  String?
+  /// Tire pressure monitoring sensors present — they need care on refit.
+  hasTpms  Boolean @default(false)
+
+  /// Individual tires in this set. Drives every capacity calculation.
+  quantity Int @default(4)
+
+  /// stored | released | disposed — see TIRE_SET_STATUSES
+  status String @default("stored")
+
+  notes String?
+
+  /// Where it physically sits. Null once released to the customer.
+  locationId String?
+  location   TireLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
+
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  checkedInAt  DateTime?
+  checkedOutAt DateTime?
+
+  measurements   TireMeasurement[]
+  movements      TireMovement[]
+  treatments     TireTreatment[]
+  attachments    TireSetAttachment[]
+  quotes         Quote[]
+  serviceRecords ServiceRecord[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@unique([organizationId, reference])
+  @@index([organizationId, status])
+  @@index([locationId])
+  @@index([vehicleId])
+  @@index([customerId])
+  @@map("tire_sets")
+}
+
+/// Condition of one tire at one moment. Kept as history rather than current
+/// state so wear between seasons is visible, and so a damage dispute has a
+/// dated record behind it.
+/// A photo or document held against a stored set: a picture of a kerbed rim,
+/// a damage report, the customer's written agreement to store them.
+///
+/// Deliberately the same shape as ServiceAttachment, because that is where
+/// these end up when the set is billed. Copying a row between two models with
+/// the same fields cannot lose anything in translation, and the invoice PDF
+/// already knows how to render one.
+model TireSetAttachment {
+  id       String @id @default(cuid())
+  fileName String
+  fileUrl  String
+  fileType String
+  fileSize Int
+
+  /// What the technician wants said about it, e.g. "kerbing on the near side".
+  /// Travels onto the invoice as the caption under the image.
+  description String?
+
+  /// Whether it should reach the customer's invoice when this set is billed.
+  /// On by default: a photo taken at check-in is usually taken to be shown.
+  includeInInvoice Boolean @default(true)
+
+  sortOrder Int @default(0)
+
+  tireSetId String
+  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  uploadedById String?
+  uploadedBy   User?   @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
+
+  createdAt DateTime @default(now())
+
+  @@index([tireSetId])
+  @@index([organizationId])
+  @@map("tire_set_attachments")
+}
+
+model TireMeasurement {
+  id String @id @default(cuid())
+
+  /// front_left | front_right | rear_left | rear_right | spare | unspecified
+  position String @default("unspecified")
+
+  /// Always stored in millimetres. The UI converts to 32nds of an inch for
+  /// workshops on imperial units, so one number means the same thing in
+  /// every market.
+  treadDepthMm Float?
+  /// Always stored in bar. Converted to psi/kPa for display.
+  pressureBar  Float?
+
+  /// good | fair | replace — see TIRE_CONDITIONS
+  condition String  @default("good")
+  damage    String?
+  notes     String?
+
+  tireSetId String
+  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
+
+  /// Groups the four readings taken during one check-in or inspection so the
+  /// history reads as events rather than loose numbers.
+  movementId String?
+  movement   TireMovement? @relation(fields: [movementId], references: [id], onDelete: SetNull)
+
+  measuredById String?
+  measuredBy   User?   @relation(fields: [measuredById], references: [id], onDelete: SetNull)
+
+  images StoredImage[] @relation("TireMeasurementImages")
+
+  measuredAt DateTime @default(now())
+
+  @@index([tireSetId, measuredAt(sort: Desc)])
+  @@index([movementId])
+  @@map("tire_measurements")
+}
+
+/// Every physical handling of a set: arrival, departure, a move between
+/// shelves, disposal. This is the storage audit trail — "where has this been
+/// and who touched it" has to outlive the set's current row.
+model TireMovement {
+  id String @id @default(cuid())
+
+  /// check_in | check_out | relocate | dispose — see TIRE_MOVEMENT_TYPES
+  type String
+
+  /// Denormalised location codes so history still reads correctly after a
+  /// shelf is renamed or deleted.
+  fromCode String?
+  toCode   String?
+
+  fromLocationId String?
+  fromLocation   TireLocation? @relation("TireMovementFrom", fields: [fromLocationId], references: [id], onDelete: SetNull)
+
+  toLocationId String?
+  toLocation   TireLocation? @relation("TireMovementTo", fields: [toLocationId], references: [id], onDelete: SetNull)
+
+  note String?
+
+  tireSetId String
+  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  performedById String?
+  performedBy   User?   @relation(fields: [performedById], references: [id], onDelete: SetNull)
+
+  measurements TireMeasurement[]
+
+  createdAt DateTime @default(now())
+
+  @@index([tireSetId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@map("tire_movements")
+}
+
+/// Work a set needs before it goes back on the car, or before it goes on the
+/// shelf: washing, balancing, a TPMS service.
+///
+/// A task with a state rather than a flag on the set, because the tire
+/// department's real question is "what is left to do on this one", and a
+/// boolean cannot answer that once someone has started. Recording who
+/// finished it also settles the "was this washed?" conversation at pickup.
+model TireTreatment {
+  id String @id @default(cuid())
+
+  /// wash_tires | wash_rims | balance | tpms_service | new_valves | repair
+  /// — see TREATMENT_TYPES
+  type String
+
+  /// pending | done | skipped — see TREATMENT_STATUSES
+  status String @default("pending")
+
+  notes String?
+
+  tireSetId String
+  tireSet   TireSet @relation(fields: [tireSetId], references: [id], onDelete: Cascade)
+
+  completedAt   DateTime?
+  completedById String?
+  completedBy   User?     @relation(fields: [completedById], references: [id], onDelete: SetNull)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  /// One row per kind of work per set: asking for a wash twice is the same
+  /// request, not two.
+  @@unique([tireSetId, type])
+  @@index([organizationId, status])
+  @@map("tire_treatments")
+}

+ 150 - 0
prisma/schema/vehicles.prisma

@@ -0,0 +1,150 @@
+model Vehicle {
+  id                     String               @id @default(cuid())
+  make                   String
+  model                  String
+  year                   Int
+  vin                    String?
+  licensePlate           String?
+  color                  String?
+  mileage                Int                  @default(0)
+  fuelType               String?              @default("gasoline")
+  transmission           String?              @default("automatic")
+  engineSize             String?
+  engineCode             String?
+  purchaseDate           DateTime?
+  purchasePrice          Float?
+  imageUrl               String?
+  isArchived             Boolean              @default(false)
+  archiveReason          String?
+  maintenanceDismissed   Boolean              @default(false)
+  maintenanceDismissedAt DateTime?
+  aiMessages             AiGeneratedMessage[]
+  createdAt              DateTime             @default(now())
+  updatedAt              DateTime             @updatedAt
+
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  serviceRecords    ServiceRecord[]
+  notes             Note[]
+  fuelLogs          FuelLog[]
+  reminders         Reminder[]
+  quotes            Quote[]
+  recurringInvoices RecurringInvoice[]
+  inspections       Inspection[]
+  serviceRequests   ServiceRequest[]
+  findings          VehicleFinding[]
+  scheduledMessages ScheduledMessage[]
+  tireSets          TireSet[]
+
+  @@index([organizationId])
+  @@map("vehicles")
+}
+
+model Note {
+  id        String   @id @default(cuid())
+  title     String
+  content   String
+  isPinned  Boolean  @default(false)
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  @@index([vehicleId])
+  @@map("notes")
+}
+
+model FuelLog {
+  id             String   @id @default(cuid())
+  date           DateTime @default(now())
+  mileage        Int
+  gallons        Float
+  pricePerGallon Float
+  totalCost      Float
+  isFillUp       Boolean  @default(true)
+  station        String?
+  notes          String?
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  @@index([vehicleId])
+  @@map("fuel_logs")
+}
+
+model Reminder {
+  id          String    @id @default(cuid())
+  title       String
+  description String?
+  dueDate     DateTime?
+  dueMileage  Int?
+  isCompleted Boolean   @default(false)
+  /// Per-reminder notification channels, chosen when creating the reminder
+  notifyInApp Boolean   @default(true)
+  notifyEmail Boolean   @default(false)
+  /// Set by the due-reminder cron once notified; cleared when dueDate changes
+  notifiedAt  DateTime?
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  /// A reminder relates to a vehicle, a customer, or just the workshop
+  /// (both null). organizationId is the canonical scope for all three.
+  vehicleId String?
+  vehicle   Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
+
+  organizationId String?
+  organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  @@index([vehicleId])
+  @@index([customerId])
+  @@index([organizationId])
+  @@map("reminders")
+}
+
+model VehicleFinding {
+  id          String   @id @default(cuid())
+  description String
+  severity    String   @default("needs_work")
+  status      String   @default("open")
+  notes       String?
+  imageUrls   String[] @default([])
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+
+  serviceRecordId String?
+  serviceRecord   ServiceRecord? @relation("FindingDiscoveredIn", fields: [serviceRecordId], references: [id], onDelete: SetNull)
+
+  resolvedServiceRecordId String?
+  resolvedServiceRecord   ServiceRecord? @relation("FindingResolvedIn", fields: [resolvedServiceRecordId], references: [id], onDelete: SetNull)
+
+  /// The customer concern this finding answers, if it answers one.
+  ///
+  /// Nullable because plenty of findings are things nobody asked about: the
+  /// perished bush somebody noticed while the car was on the lift. Those are
+  /// worth just as much, they just are not part of the story the customer
+  /// started. SetNull rather than Cascade because a finding is a fact about
+  /// the vehicle and outlives the concern that prompted the look.
+  concernId String?
+  concern   ServiceConcern? @relation(fields: [concernId], references: [id], onDelete: SetNull)
+
+  @@index([vehicleId])
+  @@index([serviceRecordId])
+  @@index([concernId])
+  @@map("vehicle_findings")
+}

+ 50 - 0
prisma/schema/webhooks.prisma

@@ -0,0 +1,50 @@
+model Webhook {
+  id              String    @id @default(cuid())
+  name            String
+  url             String
+  secret          String
+  events          String // JSON array of event names; "*" matches all
+  isActive        Boolean   @default(true)
+  description     String?
+  lastTriggeredAt DateTime?
+  lastSuccessAt   DateTime?
+  lastFailureAt   DateTime?
+  failureCount    Int       @default(0)
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdById String
+
+  deliveries WebhookDelivery[]
+
+  @@index([organizationId])
+  @@index([organizationId, isActive])
+  @@map("webhooks")
+}
+
+model WebhookDelivery {
+  id           String    @id @default(cuid())
+  event        String
+  payload      String // JSON-stringified event payload as sent
+  status       String    @default("pending") // pending | success | failed | retrying
+  statusCode   Int?
+  responseBody String?
+  errorMessage String?
+  attempt      Int       @default(0)
+  maxAttempts  Int       @default(5)
+  nextRetryAt  DateTime?
+  deliveredAt  DateTime?
+  durationMs   Int?
+  createdAt    DateTime  @default(now())
+  updatedAt    DateTime  @updatedAt
+
+  webhookId String
+  webhook   Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
+
+  @@index([webhookId, createdAt(sort: Desc)])
+  @@index([status, nextRetryAt])
+  @@map("webhook_deliveries")
+}

+ 1 - 0
public/images/integrations/google-calendar.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect x="6" y="6" width="36" height="36" rx="6" fill="#fff"/><rect x="6" y="6" width="36" height="9" rx="4" fill="#4285F4"/><rect x="6" y="15" width="36" height="27" rx="0" fill="#fff" stroke="#DADCE0"/><rect x="6" y="33" width="9" height="9" fill="#34A853"/><rect x="33" y="33" width="9" height="9" fill="#FBBC04"/><rect x="33" y="15" width="9" height="9" fill="#EA4335"/><text x="24" y="31" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700" fill="#4285F4">31</text></svg>

+ 1 - 0
public/images/integrations/microsoft-365.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect x="7" y="7" width="16" height="16" fill="#F25022"/><rect x="25" y="7" width="16" height="16" fill="#7FBA00"/><rect x="7" y="25" width="16" height="16" fill="#00A4EF"/><rect x="25" y="25" width="16" height="16" fill="#FFB900"/></svg>

+ 1 - 0
public/images/integrations/zoom.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="10" fill="#0B5CFF"/><rect x="9" y="16" width="21" height="16" rx="4" fill="#fff"/><path d="M31 22.5l8-5v13l-8-5z" fill="#fff"/></svg>

+ 2 - 1
src/__tests__/features/backup/part-restore-fields.test.ts

@@ -1,6 +1,7 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import { describe, expect, it } from 'vitest'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
 
 /**
  * Restore completeness for part lines.
@@ -19,7 +20,7 @@ const importSource = fs.readFileSync(
   path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
   'utf-8'
 )
-const schema = fs.readFileSync(path.join(ROOT, 'prisma/schema.prisma'), 'utf-8')
+const schema = readPrismaSchema(ROOT)
 
 /** Scalar (persisted, non-relation) columns of a model, from the schema. */
 function scalarColumns(model: string): string[] {

+ 100 - 0
src/__tests__/features/integrations/calendar-sync.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest'
+import {
+  draftCalendarEvent,
+  pullWindow,
+  type ServiceForCalendar,
+  serviceUrl,
+} from '@/features/integrations/Lib/calendar-sync'
+
+const record: ServiceForCalendar = {
+  id: 'svc1',
+  title: 'Brake service',
+  status: 'pending',
+  startDateTime: new Date('2026-09-10T08:00:00Z'),
+  endDateTime: new Date('2026-09-10T09:30:00Z'),
+  invoiceNumber: 'INV-42',
+  vehicleId: 'veh1',
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'AB 12345' },
+  customer: { name: 'Anna Berg', phone: '+4791234567' },
+}
+
+/**
+ * A work order becomes the same calendar event whichever vendor it goes to.
+ * The draft carries what a mechanic needs at a glance and a checksum so an
+ * unchanged record is not pushed again.
+ */
+describe('calendar event drafting', () => {
+  it('builds title, description and times from the work order', () => {
+    const draft = draftCalendarEvent(record, {
+      appUrl: 'https://shop.example.com',
+      includeCustomer: true,
+    })
+    expect(draft).not.toBeNull()
+    expect(draft?.title).toBe('Brake service · 2018 Toyota Corolla · Anna Berg')
+    expect(draft?.description).toContain('Customer: Anna Berg')
+    expect(draft?.description).toContain('Phone: +4791234567')
+    expect(draft?.description).toContain('Vehicle: 2018 Toyota Corolla (AB 12345)')
+    expect(draft?.description).toContain('Invoice: INV-42')
+    expect(draft?.description).toContain('https://shop.example.com/vehicles/veh1/service/svc1')
+    expect(draft?.start.toISOString()).toBe('2026-09-10T08:00:00.000Z')
+    expect(draft?.end.toISOString()).toBe('2026-09-10T09:30:00.000Z')
+  })
+
+  it('leaves the customer out when asked', () => {
+    const draft = draftCalendarEvent(record, { appUrl: 'https://x', includeCustomer: false })
+    expect(draft?.title).toBe('Brake service · 2018 Toyota Corolla')
+    expect(draft?.description).not.toContain('Phone:')
+    // The name still helps the mechanic; only the phone is private.
+    expect(draft?.description).toContain('Customer: Anna Berg')
+  })
+
+  it('defaults the end to an hour after the start', () => {
+    const draft = draftCalendarEvent(
+      { ...record, endDateTime: null },
+      { appUrl: 'https://x', includeCustomer: true }
+    )
+    expect(draft?.end.toISOString()).toBe('2026-09-10T09:00:00.000Z')
+    const backwards = draftCalendarEvent(
+      { ...record, endDateTime: new Date('2026-09-10T07:00:00Z') },
+      { appUrl: 'https://x', includeCustomer: true }
+    )
+    expect(backwards?.end.toISOString()).toBe('2026-09-10T09:00:00.000Z')
+  })
+
+  it('has no event for unscheduled or cancelled work', () => {
+    expect(
+      draftCalendarEvent(
+        { ...record, startDateTime: null },
+        { appUrl: 'https://x', includeCustomer: true }
+      )
+    ).toBeNull()
+    expect(
+      draftCalendarEvent(
+        { ...record, status: 'cancelled' },
+        { appUrl: 'https://x', includeCustomer: true }
+      )
+    ).toBeNull()
+  })
+
+  it('changes the checksum only when something visible changes', () => {
+    const a = draftCalendarEvent(record, { appUrl: 'https://x', includeCustomer: true })
+    const b = draftCalendarEvent({ ...record }, { appUrl: 'https://x', includeCustomer: true })
+    const c = draftCalendarEvent(
+      { ...record, title: 'Brake service and MOT' },
+      { appUrl: 'https://x', includeCustomer: true }
+    )
+    expect(a?.checksum).toBe(b?.checksum)
+    expect(a?.checksum).not.toBe(c?.checksum)
+  })
+
+  it('links sales without a vehicle to the sales page', () => {
+    expect(serviceUrl('https://x', { id: 's', vehicleId: null })).toBe('https://x/sales/s')
+  })
+
+  it('pulls a week back and two months ahead', () => {
+    const now = new Date('2026-09-02T12:00:00Z')
+    const w = pullWindow(now)
+    expect(w.from.toISOString()).toBe('2026-08-26T12:00:00.000Z')
+    expect(w.to.toISOString()).toBe('2026-11-01T12:00:00.000Z')
+  })
+})

+ 155 - 0
src/__tests__/features/integrations/oauth.test.ts

@@ -0,0 +1,155 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  buildAuthorizeUrl,
+  exchangeCode,
+  needsRefresh,
+  newCodeVerifier,
+  newState,
+  platformClient,
+  redirectUriFor,
+  refreshToken,
+  resolveClient,
+} from '@/features/integrations/Lib/oauth'
+import { manifest as google } from '@/integrations/google-calendar/manifest'
+
+const spec = google.auth.type === 'oauth2' ? google.auth : null
+if (!spec) throw new Error('google manifest is not oauth2')
+
+/**
+ * One OAuth implementation serves every connector. Who owns the vendor app
+ * decides which client id is used: the platform's from the environment, or
+ * the workshop's own from its stored credentials.
+ */
+describe('integration oauth', () => {
+  const env = { ...process.env }
+  beforeEach(() => {
+    delete process.env.GOOGLE_INTEGRATION_CLIENT_ID
+    delete process.env.GOOGLE_INTEGRATION_CLIENT_SECRET
+  })
+  afterEach(() => {
+    process.env = { ...env }
+    vi.restoreAllMocks()
+  })
+
+  it('prefers the platform app and falls back to the tenant app', () => {
+    expect(platformClient(spec)).toBeNull()
+    expect(resolveClient(spec, {})).toBeNull()
+    expect(resolveClient(spec, { clientId: 'ws', clientSecret: 's' })).toMatchObject({
+      ownership: 'tenant',
+      clientId: 'ws',
+    })
+    process.env.GOOGLE_INTEGRATION_CLIENT_ID = 'platform'
+    process.env.GOOGLE_INTEGRATION_CLIENT_SECRET = 'ps'
+    expect(resolveClient(spec, { clientId: 'ws', clientSecret: 's' })).toMatchObject({
+      ownership: 'platform',
+      clientId: 'platform',
+    })
+  })
+
+  it('builds an authorize URL with scopes, state, extra params and PKCE', () => {
+    const state = newState()
+    const verifier = newCodeVerifier()
+    const url = new URL(
+      buildAuthorizeUrl({
+        spec,
+        client: { clientId: 'cid', clientSecret: 'x', ownership: 'platform' },
+        redirectUri: redirectUriFor('https://shop.example.com/', 'google-calendar'),
+        state,
+        codeVerifier: verifier,
+      })
+    )
+    expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth')
+    expect(url.searchParams.get('client_id')).toBe('cid')
+    expect(url.searchParams.get('redirect_uri')).toBe(
+      'https://shop.example.com/api/integrations/google-calendar/oauth/callback'
+    )
+    expect(url.searchParams.get('scope')).toContain('calendar.events')
+    expect(url.searchParams.get('state')).toBe(state)
+    expect(url.searchParams.get('access_type')).toBe('offline')
+    expect(url.searchParams.get('code_challenge_method')).toBe('S256')
+    expect(url.searchParams.get('code_challenge')).not.toBe(verifier)
+  })
+
+  it('exchanges a code and keeps the refresh token across refreshes', async () => {
+    const fetchMock = vi.spyOn(globalThis, 'fetch')
+    fetchMock.mockResolvedValueOnce(
+      new Response(
+        JSON.stringify({
+          access_token: 'a1',
+          refresh_token: 'r1',
+          expires_in: 3600,
+          token_type: 'Bearer',
+          scope: 's',
+        }),
+        { status: 200 }
+      )
+    )
+    const client = { clientId: 'cid', clientSecret: 'sec', ownership: 'tenant' as const }
+    const creds = await exchangeCode({
+      spec,
+      client,
+      code: 'code',
+      redirectUri: 'https://x/cb',
+      codeVerifier: 'v',
+    })
+    expect(creds.accessToken).toBe('a1')
+    expect(creds.refreshToken).toBe('r1')
+    expect(creds.expiresAt).toBeGreaterThan(Date.now() + 3_000_000)
+    expect(creds.codeVerifier).toBeUndefined()
+    const sent = new URLSearchParams(String(fetchMock.mock.calls[0][1]?.body))
+    expect(sent.get('grant_type')).toBe('authorization_code')
+    expect(sent.get('code_verifier')).toBe('v')
+
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ access_token: 'a2', expires_in: 3600 }), { status: 200 })
+    )
+    const refreshed = await refreshToken({ spec, client, credentials: creds })
+    expect(refreshed.accessToken).toBe('a2')
+    expect(refreshed.refreshToken).toBe('r1')
+  })
+
+  it('surfaces the vendor error message on a failed exchange', async () => {
+    vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+      new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Bad code' }), {
+        status: 400,
+      })
+    )
+    await expect(
+      exchangeCode({
+        spec,
+        client: { clientId: 'c', clientSecret: 's', ownership: 'tenant' },
+        code: 'x',
+        redirectUri: 'https://x',
+      })
+    ).rejects.toThrow('Bad code')
+  })
+
+  it('sends client credentials as Basic auth when the vendor wants that', async () => {
+    const fetchMock = vi.spyOn(globalThis, 'fetch')
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ access_token: 'z1', refresh_token: 'zr', expires_in: 3600 }), {
+        status: 200,
+      })
+    )
+    const basicSpec = { ...spec, tokenAuth: 'basic' as const }
+    await exchangeCode({
+      spec: basicSpec,
+      client: { clientId: 'zid', clientSecret: 'zsec', ownership: 'platform' },
+      code: 'c',
+      redirectUri: 'https://x/cb',
+    })
+    const init = fetchMock.mock.calls[0][1]
+    const headers = new Headers(init?.headers)
+    expect(headers.get('authorization')).toBe(`Basic ${Buffer.from('zid:zsec').toString('base64')}`)
+    const sent = new URLSearchParams(String(init?.body))
+    expect(sent.get('client_secret')).toBeNull()
+    expect(sent.get('grant_type')).toBe('authorization_code')
+  })
+
+  it('knows when a token is about to expire', () => {
+    expect(needsRefresh({ accessToken: '' })).toBe(true)
+    expect(needsRefresh({ accessToken: 'a' })).toBe(false)
+    expect(needsRefresh({ accessToken: 'a', expiresAt: Date.now() + 30_000 })).toBe(true)
+    expect(needsRefresh({ accessToken: 'a', expiresAt: Date.now() + 600_000 })).toBe(false)
+  })
+})

+ 109 - 0
src/__tests__/features/integrations/registry.test.ts

@@ -0,0 +1,109 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { getConnector, getManifest, listManifests } from '@/integrations/registry'
+
+const ROOT = process.cwd()
+const messages = JSON.parse(
+  fs.readFileSync(path.join(ROOT, 'messages/en/integrations.json'), 'utf-8')
+)
+
+/**
+ * A connector is a manifest plus a server module, and the catalog, the
+ * settings page and the translations all read the manifest. These checks
+ * catch the drift that would otherwise show up as a blank label or a card
+ * that cannot be opened.
+ */
+describe('integration registry', () => {
+  it('lists every connector folder and nothing else', () => {
+    const dir = path.join(ROOT, 'src/integrations')
+    const folders = fs
+      .readdirSync(dir, { withFileTypes: true })
+      .filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'manifest.ts')))
+      .map((d) => d.name)
+      .sort()
+    const ids = listManifests()
+      .map((m) => m.id)
+      .sort()
+    expect(ids).toEqual(folders)
+  })
+
+  it('has well-formed manifests', () => {
+    for (const m of listManifests()) {
+      expect(m.id).toMatch(/^[a-z0-9-]+$/)
+      expect(fs.existsSync(path.join(ROOT, 'public', m.logo))).toBe(true)
+      expect(m.docs.startsWith('/docs/')).toBe(true)
+      expect(m.docs).not.toContain('#')
+      expect(m.capabilities.length).toBeGreaterThan(0)
+      const settingKeys = m.settings.map((s) => s.key)
+      expect(new Set(settingKeys).size).toBe(settingKeys.length)
+      for (const s of m.settings) {
+        if (s.showWhen) expect(settingKeys).toContain(s.showWhen.key)
+        if (s.type === 'remote-select') expect(s.source).toBeTruthy()
+      }
+      if (m.auth.type === 'oauth2') {
+        expect(m.auth.scopes.length).toBeGreaterThan(0)
+        expect(m.auth.authorizeUrl).toMatch(/^https:\/\//)
+        expect(m.auth.tokenUrl).toMatch(/^https:\/\//)
+      }
+    }
+  })
+
+  it('has English text for every label a manifest points at', () => {
+    for (const m of listManifests()) {
+      const block = messages.connectors[m.id]
+      expect(block, `${m.id} has no connectors block`).toBeTruthy()
+      expect(typeof block.description).toBe('string')
+      for (const s of m.settings) {
+        expect(block.settings?.[s.label], `${m.id} settings.${s.label}`).toBeTruthy()
+        if (s.help) expect(block.settings?.[s.help], `${m.id} settings.${s.help}`).toBeTruthy()
+      }
+      const fields = m.auth.type === 'oauth2' ? (m.auth.tenantFields ?? []) : m.auth.fields
+      for (const f of fields)
+        expect(block.fields?.[f.label], `${m.id} fields.${f.label}`).toBeTruthy()
+      if (m.auth.type === 'oauth2' && m.auth.tenantHelp)
+        expect(block[m.auth.tenantHelp]).toBeTruthy()
+      for (const cap of m.capabilities) {
+        // Capability ids are dotted ('calendar.push') and resolve as nested keys.
+        const label = cap
+          .split('.')
+          .reduce<unknown>((o, k) => (o as Record<string, unknown>)?.[k], messages.capabilities)
+        expect(label, `capability ${cap}`).toBeTruthy()
+      }
+      expect(messages.categories[m.category]).toBeTruthy()
+      for (const c of m.also ?? []) expect(messages.categories[c]).toBeTruthy()
+    }
+  })
+
+  it('loads a server module whose jobs match the manifest', async () => {
+    for (const m of listManifests()) {
+      const server = await getConnector(m.id)
+      expect(server.manifest.id).toBe(m.id)
+      const declared = new Set([
+        ...(m.subscriptions ?? []).map((s) => s.job),
+        ...(m.schedules ?? []).map((s) => s.job),
+      ])
+      for (const job of declared)
+        expect(server.jobs[job], `${m.id} implements ${job}`).toBeTypeOf('function')
+      for (const s of m.settings) {
+        if (s.type === 'remote-select' && s.source) {
+          expect(server.remoteOptions?.[s.source], `${m.id} provides ${s.source}`).toBeTypeOf(
+            'function'
+          )
+        }
+      }
+      expect(server.test).toBeTypeOf('function')
+    }
+    expect(getManifest('nope')).toBeNull()
+    await expect(getConnector('nope')).rejects.toThrow()
+  })
+
+  it('subscribes only to events the webhook dispatcher knows', async () => {
+    const { WEBHOOK_EVENTS } = await import('@/features/webhooks/Schema/webhookSchema')
+    const known = new Set<string>(WEBHOOK_EVENTS)
+    for (const m of listManifests()) {
+      for (const s of m.subscriptions ?? [])
+        expect(known.has(s.event), `${m.id} subscribes to ${s.event}`).toBe(true)
+    }
+  })
+})

+ 73 - 0
src/__tests__/features/integrations/vault.test.ts

@@ -0,0 +1,73 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+  openCredentials,
+  resetVaultKeyForTests,
+  sealCredentials,
+} from '@/features/integrations/Lib/vault'
+
+/**
+ * Third-party tokens never sit in the database in the clear. The vault seals
+ * them with a dedicated key, or, on an install that has not set one, a key
+ * derived from the auth secret so nothing breaks silently.
+ */
+describe('integration vault', () => {
+  const env = { ...process.env }
+  beforeEach(() => {
+    resetVaultKeyForTests()
+    process.env.INTEGRATIONS_ENCRYPTION_KEY = 'a'.repeat(64)
+    process.env.BETTER_AUTH_SECRET = 'auth-secret'
+  })
+  afterEach(() => {
+    process.env = { ...env }
+    resetVaultKeyForTests()
+  })
+
+  it('round-trips credentials and leaves nothing readable in the sealed string', () => {
+    const sealed = sealCredentials({ accessToken: 'ya29.secret', refreshToken: '1//refresh' })
+    expect(sealed.startsWith('v1.')).toBe(true)
+    expect(sealed).not.toContain('secret')
+    expect(sealed).not.toContain('refresh')
+    expect(openCredentials(sealed)).toEqual({
+      accessToken: 'ya29.secret',
+      refreshToken: '1//refresh',
+    })
+  })
+
+  it('produces a different ciphertext every time', () => {
+    const a = sealCredentials({ token: 'x' })
+    const b = sealCredentials({ token: 'x' })
+    expect(a).not.toBe(b)
+    expect(openCredentials(a)).toEqual(openCredentials(b))
+  })
+
+  it('refuses tampered data and a different key', () => {
+    const sealed = sealCredentials({ token: 'x' })
+    const tampered = `${sealed.slice(0, -2)}AA`
+    expect(() => openCredentials(tampered)).toThrow()
+    resetVaultKeyForTests()
+    process.env.INTEGRATIONS_ENCRYPTION_KEY = 'b'.repeat(64)
+    expect(() => openCredentials(sealed)).toThrow()
+  })
+
+  it('treats an empty value as no credentials', () => {
+    expect(openCredentials(null)).toEqual({})
+    expect(openCredentials('')).toEqual({})
+  })
+
+  it('rejects a malformed key rather than silently using it', () => {
+    resetVaultKeyForTests()
+    process.env.INTEGRATIONS_ENCRYPTION_KEY = 'not-hex'
+    expect(() => sealCredentials({ token: 'x' })).toThrow(/64 hex/)
+  })
+
+  it('falls back to a key derived from the auth secret', () => {
+    resetVaultKeyForTests()
+    process.env.INTEGRATIONS_ENCRYPTION_KEY = ''
+    const sealed = sealCredentials({ token: 'x' })
+    expect(openCredentials(sealed)).toEqual({ token: 'x' })
+    // A different auth secret cannot open it.
+    resetVaultKeyForTests()
+    process.env.BETTER_AUTH_SECRET = 'other'
+    expect(() => openCredentials(sealed)).toThrow()
+  })
+})

+ 2 - 1
src/__tests__/features/tire-hotel/backup-coverage.test.ts

@@ -1,6 +1,7 @@
 import { describe, it, expect } from 'vitest'
 import fs from 'node:fs'
 import path from 'node:path'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
 
 /**
  * Backup coverage for the tire hotel.
@@ -21,7 +22,7 @@ const importSource = fs.readFileSync(
   path.join(ROOT, 'src/app/api/protected/backup/import/route.ts'),
   'utf-8'
 )
-const schema = fs.readFileSync(path.join(ROOT, 'prisma/schema.prisma'), 'utf-8')
+const schema = readPrismaSchema(ROOT)
 
 /** Every tire hotel model the schema declares. */
 function tireModels(): string[] {

+ 2 - 1
src/__tests__/lib/backup-manifest.test.ts

@@ -8,6 +8,7 @@ import {
   topLevelEntities,
   UPLOAD_CATEGORIES,
 } from '@/lib/backup/manifest'
+import { readPrismaSchema } from '@/__tests__/stubs/prisma-schema'
 
 /**
  * Ten features shipped before anyone noticed they were missing from backups,
@@ -15,7 +16,7 @@ import {
  * does, without needing a database.
  */
 
-const SCHEMA = fs.readFileSync(path.join('prisma', 'schema.prisma'), 'utf-8')
+const SCHEMA = readPrismaSchema()
 
 /**
  * model -> the models it is cascade-deleted by.

+ 16 - 0
src/__tests__/stubs/prisma-schema.ts

@@ -0,0 +1,16 @@
+import fs from 'node:fs'
+import path from 'node:path'
+
+/**
+ * The Prisma schema is split into one file per domain under prisma/schema.
+ * Tests that inspect models read them all as one text, in file order.
+ */
+export function readPrismaSchema(root = process.cwd()): string {
+  const dir = path.join(root, 'prisma', 'schema')
+  return fs
+    .readdirSync(dir)
+    .filter((name) => name.endsWith('.prisma'))
+    .sort()
+    .map((name) => fs.readFileSync(path.join(dir, name), 'utf-8'))
+    .join('\n')
+}

+ 675 - 0
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -0,0 +1,675 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import Image from 'next/image'
+import Link from 'next/link'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useFormatter, useTranslations } from 'next-intl'
+import {
+  AlertTriangle,
+  ArrowLeft,
+  Check,
+  Loader2,
+  Play,
+  Plug,
+  RefreshCw,
+  RotateCcw,
+  Unplug,
+  Upload,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { AppCard } from '@/components/app-card'
+import { DocsLink } from '@/components/docs-link'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { useConfirm } from '@/components/confirm-dialog'
+import {
+  backfillIntegrationCalendar,
+  type ConnectionView,
+  disconnectIntegration,
+  getIntegrationRemoteOptions,
+  retryIntegrationJob,
+  runIntegrationJob,
+  saveIntegrationCredentials,
+  testIntegration,
+  updateIntegrationSettings,
+} from '@/features/integrations/Actions/integrationActions'
+import type {
+  CredentialField,
+  SettingField,
+  SettingOption,
+} from '@/features/integrations/Lib/types'
+
+type Activity = {
+  jobs: {
+    id: string
+    kind: string
+    status: string
+    attempts: number
+    error: string | null
+    runAfter: string
+    finishedAt: string | null
+    createdAt: string
+  }[]
+  logs: { id: string; level: string; message: string; createdAt: string }[]
+}
+
+export function ConnectionSettings({
+  view,
+  activity,
+}: {
+  view: ConnectionView
+  activity: Activity
+}) {
+  const t = useTranslations('integrations')
+  const tc = useTranslations(`integrations.connectors.${view.manifest.id}`)
+  const format = useFormatter()
+  const router = useRouter()
+  const search = useSearchParams()
+  const confirm = useConfirm()
+  const { manifest, connection } = view
+  const [busy, setBusy] = useState<string | null>(null)
+
+  // Outcome of an OAuth round trip lands here as a query parameter.
+  useEffect(() => {
+    const error = search.get('error')
+    const connected = search.get('connected')
+    if (connected) toast.success(t('connection.connected', { name: manifest.name }))
+    if (error) toast.error(t.has(`errors.${error}`) ? t(`errors.${error}`) : t('errors.vendor'))
+    if (error || connected) router.replace(`/settings/integrations/${manifest.id}`)
+  }, [search, router, t, manifest])
+
+  const isOAuth = manifest.auth.type === 'oauth2'
+  const oauthNeedsTenantApp = isOAuth && !view.platformApp
+  const connected = connection && (connection.status === 'active' || connection.status === 'error')
+  const oauthStartUrl = `/api/integrations/${manifest.id}/oauth/start`
+
+  const run = useCallback(
+    async (
+      key: string,
+      fn: () => Promise<{ success: boolean; error?: string } | void>,
+      done?: string
+    ) => {
+      setBusy(key)
+      try {
+        const res = await fn()
+        if (res && !res.success) {
+          toast.error(res.error || t('errors.generic'))
+          return false
+        }
+        if (done) toast.success(done)
+        router.refresh()
+        return true
+      } finally {
+        setBusy(null)
+      }
+    },
+    [router, t]
+  )
+
+  const disconnect = async () => {
+    const ok = await confirm({
+      title: t('connection.disconnectTitle', { name: manifest.name }),
+      description: t('connection.disconnectDescription'),
+      confirmLabel: t('connection.disconnect'),
+      destructive: true,
+    })
+    if (!ok) return
+    const done = await run(
+      'disconnect',
+      () => disconnectIntegration(manifest.id),
+      t('connection.disconnected')
+    )
+    if (done) router.push('/settings/integrations')
+  }
+
+  const firstSchedule = manifest.schedules?.[0]?.job
+  const isCalendar = manifest.category === 'calendar'
+
+  return (
+    <div className="space-y-4">
+      <Link
+        href="/settings/integrations"
+        className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {t('connection.back')}
+      </Link>
+
+      <AppCard
+        icon={Plug}
+        title={
+          <span className="flex items-center gap-3">
+            <Image
+              src={manifest.logo}
+              alt=""
+              width={28}
+              height={28}
+              className="h-7 w-7 rounded"
+              unoptimized
+            />
+            {manifest.name}
+            {connection && <StatusBadge status={connection.status} />}
+          </span>
+        }
+        description={tc('description')}
+        action={<DocsLink href={manifest.docs} variant="hint" />}
+      >
+        <div className="mb-4 flex flex-wrap gap-1">
+          {manifest.capabilities.map((cap) => (
+            <Badge key={cap} variant="outline" className="text-[11px] font-normal">
+              {t(`capabilities.${cap}`)}
+            </Badge>
+          ))}
+        </div>
+
+        {connection?.lastError && connection.status === 'error' && (
+          <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
+            <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
+            <div>
+              <p className="font-medium">{t('connection.lastError')}</p>
+              <p className="text-muted-foreground">{connection.lastError}</p>
+            </div>
+          </div>
+        )}
+
+        {connected ? (
+          <div className="grid gap-4 text-sm sm:grid-cols-3">
+            <div>
+              <p className="text-xs text-muted-foreground">{t('connection.account')}</p>
+              <p className="font-medium">{connection.externalAccountName ?? '-'}</p>
+            </div>
+            <div>
+              <p className="text-xs text-muted-foreground">{t('connection.lastHealth')}</p>
+              <p className="font-medium">
+                {connection.lastHealthAt
+                  ? format.dateTime(new Date(connection.lastHealthAt), {
+                      dateStyle: 'medium',
+                      timeStyle: 'short',
+                    })
+                  : '-'}
+              </p>
+            </div>
+            <div>
+              <p className="text-xs text-muted-foreground">{t('connection.lastSync')}</p>
+              <p className="font-medium">
+                {connection.lastSyncAt
+                  ? format.dateTime(new Date(connection.lastSyncAt), {
+                      dateStyle: 'medium',
+                      timeStyle: 'short',
+                    })
+                  : '-'}
+              </p>
+            </div>
+          </div>
+        ) : (
+          <ConnectForm
+            manifest={manifest}
+            tenantClientId={connection?.tenantClientId ?? null}
+            needsTenantApp={oauthNeedsTenantApp}
+            redirectUri={view.redirectUri}
+            oauthStartUrl={oauthStartUrl}
+            enabled={view.enabled}
+            busy={busy}
+            onSave={(values) =>
+              run(
+                'credentials',
+                () => saveIntegrationCredentials(manifest.id, values),
+                isOAuth ? undefined : t('connection.connectedShort')
+              )
+            }
+          />
+        )}
+
+        {connected && (
+          <div className="mt-4 flex flex-wrap gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() =>
+                run('test', () => testIntegration(manifest.id), t('connection.testPassed'))
+              }
+              disabled={busy !== null}
+            >
+              {busy === 'test' ? (
+                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+              ) : (
+                <Check className="mr-1 h-3.5 w-3.5" />
+              )}
+              {t('connection.test')}
+            </Button>
+            {firstSchedule && (
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() =>
+                  run(
+                    'sync',
+                    () => runIntegrationJob(manifest.id, firstSchedule),
+                    t('connection.syncQueued')
+                  )
+                }
+                disabled={busy !== null}
+              >
+                {busy === 'sync' ? (
+                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <RefreshCw className="mr-1 h-3.5 w-3.5" />
+                )}
+                {t('connection.syncNow')}
+              </Button>
+            )}
+            {isCalendar && (
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() =>
+                  run(
+                    'backfill',
+                    () => backfillIntegrationCalendar(manifest.id),
+                    t('connection.backfillQueued')
+                  )
+                }
+                disabled={busy !== null}
+              >
+                {busy === 'backfill' ? (
+                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Upload className="mr-1 h-3.5 w-3.5" />
+                )}
+                {t('connection.backfill')}
+              </Button>
+            )}
+            {isOAuth && (
+              <Button variant="outline" size="sm" asChild>
+                <a href={oauthStartUrl}>
+                  <RotateCcw className="mr-1 h-3.5 w-3.5" />
+                  {t('connection.reconnect')}
+                </a>
+              </Button>
+            )}
+            <Button
+              variant="outline"
+              size="sm"
+              className="ml-auto text-destructive hover:text-destructive"
+              onClick={disconnect}
+              disabled={busy !== null}
+            >
+              <Unplug className="mr-1 h-3.5 w-3.5" />
+              {t('connection.disconnect')}
+            </Button>
+          </div>
+        )}
+      </AppCard>
+
+      {connected && manifest.settings.length > 0 && (
+        <SettingsForm
+          connectorId={manifest.id}
+          fields={manifest.settings}
+          initial={connection.settings}
+          onSave={(values) =>
+            run(
+              'settings',
+              () => updateIntegrationSettings(manifest.id, values),
+              t('connection.settingsSaved')
+            )
+          }
+          busy={busy === 'settings'}
+        />
+      )}
+
+      {connection && (activity.jobs.length > 0 || activity.logs.length > 0) && (
+        <ActivityCard
+          activity={activity}
+          onRetry={(id) =>
+            run(`retry:${id}`, () => retryIntegrationJob(id), t('connection.retryQueued'))
+          }
+          busy={busy}
+        />
+      )}
+    </div>
+  )
+}
+
+function StatusBadge({ status }: { status: string }) {
+  const t = useTranslations('integrations.statuses')
+  const cls =
+    status === 'active'
+      ? 'border-emerald-500/30 text-emerald-600'
+      : status === 'error'
+        ? 'border-destructive/30 text-destructive'
+        : 'text-muted-foreground'
+  return (
+    <Badge variant="outline" className={`text-[11px] font-normal ${cls}`}>
+      {t(status)}
+    </Badge>
+  )
+}
+
+function ConnectForm({
+  manifest,
+  tenantClientId,
+  needsTenantApp,
+  redirectUri,
+  oauthStartUrl,
+  enabled,
+  busy,
+  onSave,
+}: {
+  manifest: ConnectionView['manifest']
+  tenantClientId: string | null
+  needsTenantApp: boolean
+  redirectUri: string
+  oauthStartUrl: string
+  enabled: boolean
+  busy: string | null
+  onSave: (values: Record<string, string>) => Promise<boolean>
+}) {
+  const t = useTranslations('integrations')
+  const tc = useTranslations(`integrations.connectors.${manifest.id}`)
+  const fields: CredentialField[] =
+    manifest.auth.type === 'oauth2'
+      ? needsTenantApp
+        ? (manifest.auth.tenantFields ?? [])
+        : []
+      : manifest.auth.fields
+  const [values, setValues] = useState<Record<string, string>>(() => {
+    const initial: Record<string, string> = {}
+    if (tenantClientId) initial.clientId = tenantClientId
+    return initial
+  })
+  const tenantReady = Boolean(tenantClientId)
+  const tenantComplete = Boolean(values.clientId) && (Boolean(values.clientSecret) || tenantReady)
+  const tenantDirty = values.clientId !== (tenantClientId ?? '') || Boolean(values.clientSecret)
+
+  /**
+   * One step for the workshop's own app: store whatever was typed, then go
+   * to the vendor. Saving alone left people on a page where "Connect" did
+   * nothing until they found the save button first.
+   */
+  const saveAndConnect = async () => {
+    if (tenantDirty) {
+      const ok = await onSave(values)
+      if (!ok) return
+    }
+    window.location.assign(oauthStartUrl)
+  }
+
+  if (!enabled) {
+    return <p className="text-sm text-muted-foreground">{t('connection.planLocked')}</p>
+  }
+
+  return (
+    <div className="space-y-4">
+      {manifest.auth.type === 'oauth2' && needsTenantApp && (
+        <div className="rounded-lg border bg-muted/30 p-3 text-sm">
+          <p className="font-medium">{t('connection.ownAppTitle')}</p>
+          <p className="mt-1 text-muted-foreground">
+            {tc(manifest.auth.tenantHelp ?? 'tenantHelp')}
+          </p>
+          <p className="mt-2 text-xs text-muted-foreground">{t('connection.redirectUri')}</p>
+          <code className="block select-all break-all rounded bg-background px-2 py-1 text-xs">
+            {redirectUri}
+          </code>
+        </div>
+      )}
+
+      {fields.length > 0 && (
+        <div className="grid gap-3 sm:grid-cols-2">
+          {fields.map((f) => (
+            <div key={f.key} className="space-y-1">
+              <Label className="text-xs">{tc(`fields.${f.label}`)}</Label>
+              <Input
+                type={f.type === 'password' ? 'password' : 'text'}
+                value={values[f.key] ?? ''}
+                placeholder={f.key === 'clientSecret' && tenantReady ? '••••••••' : f.placeholder}
+                onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
+                autoComplete="off"
+              />
+              {f.help && <p className="text-xs text-muted-foreground">{tc(`fields.${f.help}`)}</p>}
+            </div>
+          ))}
+        </div>
+      )}
+
+      <div className="flex flex-wrap gap-2">
+        {manifest.auth.type === 'oauth2' ? (
+          needsTenantApp ? (
+            <Button onClick={saveAndConnect} disabled={busy !== null || !tenantComplete}>
+              {busy === 'credentials' ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Plug className="mr-2 h-4 w-4" />
+              )}
+              {t('connection.connectWith', { name: manifest.name })}
+            </Button>
+          ) : (
+            <Button asChild>
+              <a href={oauthStartUrl}>
+                <Plug className="mr-2 h-4 w-4" />
+                {t('connection.connectWith', { name: manifest.name })}
+              </a>
+            </Button>
+          )
+        ) : (
+          <Button
+            onClick={() => onSave(values)}
+            disabled={busy !== null || fields.some((f) => f.required && !values[f.key])}
+          >
+            {busy === 'credentials' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {t('connection.connect')}
+          </Button>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function SettingsForm({
+  connectorId,
+  fields,
+  initial,
+  onSave,
+  busy,
+}: {
+  connectorId: string
+  fields: SettingField[]
+  initial: Record<string, unknown>
+  onSave: (values: Record<string, string | number | boolean>) => Promise<boolean>
+  busy: boolean
+}) {
+  const t = useTranslations('integrations')
+  const tc = useTranslations(`integrations.connectors.${connectorId}`)
+  const [values, setValues] = useState<Record<string, string | number | boolean>>(() => {
+    const out: Record<string, string | number | boolean> = {}
+    for (const f of fields) {
+      const v = initial[f.key]
+      out[f.key] =
+        (v as string | number | boolean | undefined) ??
+        (f.default as string | number | boolean | undefined) ??
+        (f.type === 'boolean' ? false : '')
+    }
+    return out
+  })
+  const [remote, setRemote] = useState<Record<string, SettingOption[] | null>>({})
+
+  const remoteSources = useMemo(
+    () =>
+      fields.filter((f) => f.type === 'remote-select' && f.source).map((f) => f.source as string),
+    [fields]
+  )
+  useEffect(() => {
+    let cancelled = false
+    for (const source of remoteSources) {
+      getIntegrationRemoteOptions(connectorId, source).then((res) => {
+        if (cancelled) return
+        setRemote((r) => ({ ...r, [source]: res.success && res.data ? res.data : [] }))
+      })
+    }
+    return () => {
+      cancelled = true
+    }
+  }, [connectorId, remoteSources])
+
+  const visible = fields.filter((f) => !f.showWhen || values[f.showWhen.key] === f.showWhen.equals)
+  const missing = visible.some((f) => f.required && !values[f.key])
+
+  return (
+    <AppCard
+      title={t('connection.settingsTitle')}
+      description={t('connection.settingsDescription')}
+    >
+      <div className="space-y-4">
+        {visible.map((f) => (
+          <div
+            key={f.key}
+            className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
+          >
+            <div className="min-w-0">
+              <Label className="text-sm">{tc(`settings.${f.label}`)}</Label>
+              {f.help && (
+                <p className="text-xs text-muted-foreground">{tc(`settings.${f.help}`)}</p>
+              )}
+            </div>
+            <div className="sm:w-72 sm:shrink-0">
+              {f.type === 'boolean' && (
+                <Switch
+                  checked={Boolean(values[f.key])}
+                  onCheckedChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+                />
+              )}
+              {(f.type === 'text' || f.type === 'number') && (
+                <Input
+                  type={f.type}
+                  className="h-8"
+                  value={String(values[f.key] ?? '')}
+                  onChange={(e) =>
+                    setValues((s) => ({
+                      ...s,
+                      [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value,
+                    }))
+                  }
+                />
+              )}
+              {(f.type === 'select' || f.type === 'remote-select') && (
+                <Select
+                  value={String(values[f.key] ?? '')}
+                  onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+                >
+                  <SelectTrigger className="h-8">
+                    <SelectValue
+                      placeholder={
+                        f.type === 'remote-select' && remote[f.source ?? ''] === undefined
+                          ? t('connection.loading')
+                          : t('connection.choose')
+                      }
+                    />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {(f.type === 'select' ? (f.options ?? []) : (remote[f.source ?? ''] ?? [])).map(
+                      (o) => (
+                        <SelectItem key={o.value} value={o.value}>
+                          {o.label}
+                        </SelectItem>
+                      )
+                    )}
+                  </SelectContent>
+                </Select>
+              )}
+            </div>
+          </div>
+        ))}
+        <div className="flex justify-end">
+          <Button onClick={() => onSave(values)} disabled={busy || missing}>
+            {busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {t('connection.save')}
+          </Button>
+        </div>
+      </div>
+    </AppCard>
+  )
+}
+
+function ActivityCard({
+  activity,
+  onRetry,
+  busy,
+}: {
+  activity: Activity
+  onRetry: (jobId: string) => void
+  busy: string | null
+}) {
+  const t = useTranslations('integrations.activity')
+  const format = useFormatter()
+  const when = (iso: string) =>
+    format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })
+  return (
+    <AppCard title={t('title')} description={t('description')}>
+      {activity.jobs.length > 0 && (
+        <div className="mb-4 divide-y rounded-lg border">
+          {activity.jobs.map((j) => (
+            <div
+              key={j.id}
+              className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"
+            >
+              <div className="min-w-0">
+                <span className="font-medium">{j.kind}</span>
+                <span className="ml-2 text-xs text-muted-foreground">{when(j.createdAt)}</span>
+                {j.error && <p className="truncate text-xs text-destructive">{j.error}</p>}
+              </div>
+              <div className="flex items-center gap-2">
+                <Badge
+                  variant="outline"
+                  className={`text-[11px] ${j.status === 'dead' || j.status === 'failed' ? 'border-destructive/30 text-destructive' : j.status === 'done' ? 'border-emerald-500/30 text-emerald-600' : ''}`}
+                >
+                  {t(`jobStatus.${j.status}`)}
+                </Badge>
+                {(j.status === 'dead' || j.status === 'failed') && (
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    className="h-7 text-xs"
+                    onClick={() => onRetry(j.id)}
+                    disabled={busy !== null}
+                  >
+                    <Play className="mr-1 h-3 w-3" />
+                    {t('retry')}
+                  </Button>
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+      {activity.logs.length > 0 && (
+        <div className="max-h-72 overflow-auto rounded-lg border p-2 font-mono text-xs">
+          {activity.logs.map((l) => (
+            <div
+              key={l.id}
+              className={
+                l.level === 'error'
+                  ? 'text-destructive'
+                  : l.level === 'warn'
+                    ? 'text-amber-600'
+                    : 'text-muted-foreground'
+              }
+            >
+              <span className="mr-2 opacity-70">{when(l.createdAt)}</span>
+              {l.message}
+            </div>
+          ))}
+        </div>
+      )}
+    </AppCard>
+  )
+}

+ 47 - 0
src/app/(authenticated)/settings/integrations/[connector]/page.tsx

@@ -0,0 +1,47 @@
+import { notFound, redirect } from 'next/navigation'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getFeatures, isCloudMode } from '@/lib/features'
+import {
+  getIntegrationActivity,
+  getIntegrationConnection,
+} from '@/features/integrations/Actions/integrationActions'
+import { getManifest } from '@/integrations/registry'
+import { FeatureLockedMessage } from '../../feature-locked-message'
+import { ConnectionSettings } from './connection-settings'
+
+export default async function IntegrationConnectionPage({
+  params,
+}: {
+  params: Promise<{ connector: string }>
+}) {
+  const { connector } = await params
+  if (!getManifest(connector)) notFound()
+
+  const data = await getLayoutData()
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
+
+  const features = await getFeatures(data.organizationId)
+  if (!features.integrations) {
+    return (
+      <FeatureLockedMessage
+        feature="Integrations"
+        description="Connect calendars, video calls and other services to your workshop."
+        isCloud={isCloudMode()}
+      />
+    )
+  }
+
+  const [view, activity] = await Promise.all([
+    getIntegrationConnection(connector),
+    getIntegrationActivity(connector),
+  ])
+  if (!view.success || !view.data) notFound()
+
+  return (
+    <ConnectionSettings
+      view={view.data}
+      activity={activity.success && activity.data ? activity.data : { jobs: [], logs: [] }}
+    />
+  )
+}

+ 224 - 0
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -0,0 +1,224 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import Image from 'next/image'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { AlertTriangle, ArrowRight, Check, Plug, Settings2 } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import type { CatalogEntry } from '@/features/integrations/Actions/integrationActions'
+import type { IntegrationCategory } from '@/features/integrations/Lib/types'
+
+const CATEGORY_ORDER: IntegrationCategory[] = [
+  'calendar',
+  'conferencing',
+  'accounting',
+  'messaging',
+  'payments',
+  'automation',
+  'storage',
+  'registry',
+  'other',
+]
+
+/**
+ * Two cards: what the workshop has set up, then what it could add. The
+ * first is a plain list, since a connected service is something to check on
+ * rather than shop for; the second is the browsable grid with categories.
+ */
+export function IntegrationsCatalog({ entries }: { entries: CatalogEntry[] }) {
+  const t = useTranslations('integrations')
+  const [category, setCategory] = useState<IntegrationCategory | 'all'>('all')
+
+  const connected = useMemo(
+    () => entries.filter((e) => e.status && e.status !== 'disconnected'),
+    [entries]
+  )
+  const available = useMemo(
+    () => entries.filter((e) => !e.status || e.status === 'disconnected'),
+    [entries]
+  )
+
+  const categories = useMemo(() => {
+    const present = new Set<IntegrationCategory>()
+    for (const e of available) {
+      present.add(e.manifest.category)
+      for (const c of e.manifest.also ?? []) present.add(c)
+    }
+    return CATEGORY_ORDER.filter((c) => present.has(c))
+  }, [available])
+
+  const visible = useMemo(() => {
+    const list = available.filter(
+      (e) =>
+        category === 'all' ||
+        e.manifest.category === category ||
+        e.manifest.also?.includes(category)
+    )
+    // The ones that matter for this workshop's country first.
+    return list.sort((a, b) => Number(b.featured) - Number(a.featured))
+  }, [available, category])
+
+  return (
+    <div className="space-y-4">
+      <AppCard
+        icon={Check}
+        title={t('catalog.connectedTitle')}
+        badge={connected.length || undefined}
+        description={t('catalog.connectedDescription')}
+        contentClassName={connected.length ? 'p-0' : undefined}
+      >
+        {connected.length === 0 ? (
+          <div className="flex h-20 items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
+            {t('catalog.noneConnected')}
+          </div>
+        ) : (
+          <ul className="divide-y">
+            {connected.map((entry) => {
+              const m = entry.manifest
+              return (
+                <li key={m.id}>
+                  <Link
+                    href={`/settings/integrations/${m.id}`}
+                    className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/50"
+                  >
+                    <Image
+                      src={m.logo}
+                      alt=""
+                      width={36}
+                      height={36}
+                      className="h-9 w-9 shrink-0 rounded-lg"
+                      unoptimized
+                    />
+                    <div className="min-w-0 flex-1">
+                      <div className="flex flex-wrap items-center gap-2">
+                        <span className="font-medium">{m.name}</span>
+                        <StatusBadge entry={entry} />
+                      </div>
+                      <p className="truncate text-xs text-muted-foreground">
+                        {entry.status === 'error' && entry.lastError
+                          ? entry.lastError
+                          : (entry.externalAccountName ?? t(`categories.${m.category}`))}
+                      </p>
+                    </div>
+                    <span className="hidden items-center gap-1 text-xs font-medium text-primary sm:inline-flex">
+                      <Settings2 className="h-3.5 w-3.5" />
+                      {t('catalog.manage')}
+                    </span>
+                    <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground sm:hidden" />
+                  </Link>
+                </li>
+              )
+            })}
+          </ul>
+        )}
+      </AppCard>
+
+      <AppCard
+        icon={Plug}
+        title={t('catalog.availableTitle')}
+        description={t('catalog.availableDescription')}
+      >
+        {categories.length > 1 && (
+          <div className="-mx-1 mb-4 flex gap-2 overflow-x-auto px-1 sm:flex-wrap sm:overflow-visible">
+            <Button
+              variant={category === 'all' ? 'default' : 'outline'}
+              size="sm"
+              className="h-8 shrink-0"
+              onClick={() => setCategory('all')}
+            >
+              {t('catalog.all')}
+            </Button>
+            {categories.map((c) => (
+              <Button
+                key={c}
+                variant={category === c ? 'default' : 'outline'}
+                size="sm"
+                className="h-8 shrink-0"
+                onClick={() => setCategory(c)}
+              >
+                {t(`categories.${c}`)}
+              </Button>
+            ))}
+          </div>
+        )}
+
+        {visible.length === 0 ? (
+          <div className="flex h-24 items-center justify-center rounded-lg border text-sm text-muted-foreground">
+            {available.length === 0 ? t('catalog.allConnected') : t('catalog.empty')}
+          </div>
+        ) : (
+          <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+            {visible.map((entry) => {
+              const m = entry.manifest
+              return (
+                <Link
+                  key={m.id}
+                  href={`/settings/integrations/${m.id}`}
+                  className="group flex flex-col gap-3 rounded-xl border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
+                >
+                  <div className="flex items-start gap-3">
+                    <Image
+                      src={m.logo}
+                      alt=""
+                      width={40}
+                      height={40}
+                      className="h-10 w-10 shrink-0 rounded-lg"
+                      unoptimized
+                    />
+                    <div className="min-w-0 flex-1">
+                      <span className="font-medium">{m.name}</span>
+                      <p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
+                        {t(`connectors.${m.id}.description`)}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex flex-wrap gap-1">
+                    {m.capabilities.map((cap) => (
+                      <Badge key={cap} variant="outline" className="text-[11px] font-normal">
+                        {t(`capabilities.${cap}`)}
+                      </Badge>
+                    ))}
+                  </div>
+                  <div className="mt-auto flex items-center justify-between text-xs">
+                    <span className="text-muted-foreground">{t(`categories.${m.category}`)}</span>
+                    <span className="inline-flex items-center gap-1 font-medium text-primary">
+                      {t('catalog.connect')}
+                      <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
+                    </span>
+                  </div>
+                </Link>
+              )
+            })}
+          </div>
+        )}
+      </AppCard>
+    </div>
+  )
+}
+
+function StatusBadge({ entry }: { entry: CatalogEntry }) {
+  const t = useTranslations('integrations.statuses')
+  if (!entry.status) return null
+  if (entry.status === 'active')
+    return (
+      <Badge variant="outline" className="gap-1 border-emerald-500/30 text-[11px] text-emerald-600">
+        <Check className="h-3 w-3" />
+        {t('active')}
+      </Badge>
+    )
+  if (entry.status === 'error')
+    return (
+      <Badge variant="outline" className="gap-1 border-destructive/30 text-[11px] text-destructive">
+        <AlertTriangle className="h-3 w-3" />
+        {t('error')}
+      </Badge>
+    )
+  return (
+    <Badge variant="outline" className="text-[11px] text-muted-foreground">
+      {t(entry.status)}
+    </Badge>
+  )
+}

+ 30 - 0
src/app/(authenticated)/settings/integrations/page.tsx

@@ -0,0 +1,30 @@
+import { redirect } from 'next/navigation'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getFeatures, isCloudMode } from '@/lib/features'
+import { getIntegrationCatalog } from '@/features/integrations/Actions/integrationActions'
+import { FeatureLockedMessage } from '../feature-locked-message'
+import { IntegrationsCatalog } from './integrations-catalog'
+
+export default async function IntegrationsPage() {
+  const data = await getLayoutData()
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
+
+  const features = await getFeatures(data.organizationId)
+  if (!features.integrations) {
+    return (
+      <FeatureLockedMessage
+        feature="Integrations"
+        description="Connect calendars, video calls and other services to your workshop."
+        isCloud={isCloudMode()}
+      />
+    )
+  }
+
+  const result = await getIntegrationCatalog()
+  const catalog =
+    result.success && result.data
+      ? result.data
+      : { entries: [], enabled: true, isCloud: isCloudMode() }
+  return <IntegrationsCatalog entries={catalog.entries} />
+}

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