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

Add AI-powered vehicle insights (summary & common issues) (#65)

* AI Service Description Generator

* refactor page

* added ai

* ai notes

* ai notes

* ai

* vehicle summary

* ai chat

* permissions

* permissions

* tools

* disable ai chat

* common issues ai

* ai common issues
Bernt Christian Egeland 6 месяцев назад
Родитель
Сommit
e6132c556e
99 измененных файлов с 6511 добавлено и 1154 удалено
  1. 18 0
      messages/de/aiChat.json
  2. 4 2
      messages/de/navigation.json
  3. 12 1
      messages/de/service.json
  4. 30 0
      messages/de/settings.json
  5. 20 1
      messages/de/vehicles.json
  6. 18 0
      messages/en/aiChat.json
  7. 4 2
      messages/en/navigation.json
  8. 12 1
      messages/en/service.json
  9. 30 0
      messages/en/settings.json
  10. 20 1
      messages/en/vehicles.json
  11. 18 0
      messages/es/aiChat.json
  12. 4 2
      messages/es/navigation.json
  13. 12 1
      messages/es/service.json
  14. 30 0
      messages/es/settings.json
  15. 20 1
      messages/es/vehicles.json
  16. 18 0
      messages/fr/aiChat.json
  17. 4 2
      messages/fr/navigation.json
  18. 12 1
      messages/fr/service.json
  19. 30 0
      messages/fr/settings.json
  20. 20 1
      messages/fr/vehicles.json
  21. 18 0
      messages/it/aiChat.json
  22. 4 2
      messages/it/navigation.json
  23. 12 1
      messages/it/service.json
  24. 30 0
      messages/it/settings.json
  25. 20 1
      messages/it/vehicles.json
  26. 18 0
      messages/nb/aiChat.json
  27. 4 2
      messages/nb/navigation.json
  28. 12 1
      messages/nb/service.json
  29. 30 0
      messages/nb/settings.json
  30. 20 1
      messages/nb/vehicles.json
  31. 18 0
      messages/nl/aiChat.json
  32. 4 2
      messages/nl/navigation.json
  33. 12 1
      messages/nl/service.json
  34. 30 0
      messages/nl/settings.json
  35. 20 1
      messages/nl/vehicles.json
  36. 18 0
      messages/pl/aiChat.json
  37. 4 2
      messages/pl/navigation.json
  38. 12 1
      messages/pl/service.json
  39. 30 0
      messages/pl/settings.json
  40. 20 1
      messages/pl/vehicles.json
  41. 18 0
      messages/pt-BR/aiChat.json
  42. 4 2
      messages/pt-BR/navigation.json
  43. 12 1
      messages/pt-BR/service.json
  44. 30 0
      messages/pt-BR/settings.json
  45. 20 1
      messages/pt-BR/vehicles.json
  46. 18 0
      messages/tr/aiChat.json
  47. 4 2
      messages/tr/navigation.json
  48. 12 1
      messages/tr/service.json
  49. 30 0
      messages/tr/settings.json
  50. 20 1
      messages/tr/vehicles.json
  51. 1207 76
      package-lock.json
  52. 3 0
      package.json
  53. 49 0
      prisma/migrations/20260312190308_ai_integration/migration.sql
  54. 43 0
      prisma/schema.prisma
  55. 71 0
      src/app/(authenticated)/ai/_page_disabled.tsx
  56. 12 17
      src/app/(authenticated)/layout.tsx
  57. 26 38
      src/app/(authenticated)/quotes/[id]/page.tsx
  58. 20 22
      src/app/(authenticated)/quotes/page.tsx
  59. 136 127
      src/app/(authenticated)/quotes/quotes-client.tsx
  60. 30 0
      src/app/(authenticated)/settings/ai/page.tsx
  61. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx
  62. 64 2
      src/app/(authenticated)/settings/team/team-settings.tsx
  63. 20 0
      src/app/(authenticated)/vehicles/[id]/page.tsx
  64. 13 1
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  65. 346 0
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  66. 6 6
      src/app/(authenticated)/vehicles/vehicles-client.tsx
  67. 1 1
      src/app/api/protected/backup/import/route.ts
  68. 69 0
      src/app/globals.css
  69. 59 53
      src/components/app-sidebar.tsx
  70. 1 0
      src/components/page-header.tsx
  71. 3 1
      src/components/ui/command.tsx
  72. 166 0
      src/features/ai/Actions/aiActions.ts
  73. 278 0
      src/features/ai/Actions/aiChatActions.ts
  74. 195 0
      src/features/ai/Actions/aiSettingsActions.ts
  75. 353 0
      src/features/ai/Components/AiChatPage.tsx
  76. 308 0
      src/features/ai/Components/AiSettingsForm.tsx
  77. 64 0
      src/features/ai/Schema/aiSettingsSchema.ts
  78. 4 0
      src/features/ai/constants.ts
  79. 401 0
      src/features/ai/tools/workshop-tools.ts
  80. 94 0
      src/features/quotes/Components/QuoteLaborEditor.tsx
  81. 59 0
      src/features/quotes/Components/QuoteNotesEditor.tsx
  82. 201 724
      src/features/quotes/Components/QuotePageClient.tsx
  83. 100 0
      src/features/quotes/Components/QuotePartsEditor.tsx
  84. 400 0
      src/features/quotes/Components/QuoteRightColumn.tsx
  85. 73 0
      src/features/quotes/Components/quote-page-types.ts
  86. 329 0
      src/features/quotes/Components/useQuoteFormState.ts
  87. 65 31
      src/features/search/Actions/searchActions.ts
  88. 3 2
      src/features/search/Components/SearchCommand.tsx
  89. 4 0
      src/features/settings/Schema/settingsSchema.ts
  90. 3 0
      src/features/vehicles/Actions/vehicleActions.ts
  91. 145 11
      src/features/vehicles/Components/service-edit/NotesSection.tsx
  92. 14 1
      src/features/vehicles/Components/service-edit/RichTextEditor.tsx
  93. 8 1
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  94. 2 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  95. 1 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  96. 2 0
      src/i18n/request.ts
  97. 205 0
      src/lib/ai.ts
  98. 5 0
      src/lib/features.ts
  99. 8 0
      src/lib/permissions.ts

+ 18 - 0
messages/de/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "KI-Assistent",
+  "description": "Stellen Sie Fragen zu Ihren Werkstattdaten — Fahrzeuge, Services, Kunden, Umsätze und mehr.",
+  "placeholder": "Fragen Sie zu Ihrer Werkstatt...",
+  "clear": "Gespräch löschen",
+  "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
+  "notEnabled": "KI ist nicht aktiviert. Konfigurieren Sie es unter Einstellungen → KI.",
+  "suggestion1": "Welche Fahrzeuge sind bald fällig für den Service?",
+  "suggestion2": "Zeige mir unbezahlte Rechnungen",
+  "suggestion3": "Wie ist der Umsatz diesen Monat?",
+  "suggestion4": "Gib mir eine Werkstattübersicht",
+  "newChat": "Neuer Chat",
+  "noChats": "Noch keine Unterhaltungen",
+  "chatsPrivate": "Ihre Chats sind privat und nur für Sie sichtbar.",
+  "deleteChat": "Chat löschen",
+  "hideSidebar": "Seitenleiste ausblenden",
+  "showSidebar": "Seitenleiste einblenden"
+}

+ 4 - 2
messages/de/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Kundenportal",
     "reminders": "Erinnerungen",
     "allReminders": "Alle Erinnerungen",
-    "auditLog": "Aktivitätsprotokoll"
+    "auditLog": "Aktivitätsprotokoll",
+    "aiAssistant": "KI-Assistent"
   },
   "search": "Suchen...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Heller Modus",
     "darkMode": "Dunkler Modus",
     "signOut": "Abmelden",
-    "auditLog": "Aktivitätsprotokoll"
+    "auditLog": "Aktivitätsprotokoll",
+    "aiAssistant": "KI-Assistent"
   }
 }

+ 12 - 1
messages/de/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Notizen sichtbar auf der Rechnung...",
     "internalPlaceholder": "Interne Notizen (nicht auf Rechnung angezeigt)...",
     "publicHelper": "Auf der Rechnung und in gemeinsamen Dokumenten angezeigt",
-    "internalHelper": "Nur für Ihr Team sichtbar"
+    "internalHelper": "Nur für Ihr Team sichtbar",
+    "aiWrite": "AI Schreiben",
+    "aiGenerated": "AI-Notizen erfolgreich generiert",
+    "aiGenerateFailed": "AI-Notizen konnten nicht generiert werden",
+    "aiDialogTitle": "AI Schreiben",
+    "aiDialogDescription": "AI analysiert die Teile und Arbeiten in diesem Serviceeintrag und erstellt eine professionelle Beschreibung. Das Ergebnis wird in den aktuell ausgewählten Notiztyp geschrieben.",
+    "aiGenerate": "Generieren",
+    "aiOverwriteDescription": "Die aktuelle Notiz hat bereits Inhalt. Sie können ihn ersetzen oder den AI-generierten Text darunter anhängen.",
+    "aiOverwriteReplace": "Ersetzen",
+    "aiOverwriteAppend": "Anhängen",
+    "aiOverwriteCancel": "Abbrechen",
+    "aiDontShowAgain": "Nicht mehr anzeigen"
   },
   "invoice": {
     "title": "Rechnungszusammenfassung",

+ 30 - 0
messages/de/settings.json

@@ -66,6 +66,7 @@
         "title": "Wartung",
         "description": "Vorhergesagte Serviceintervalle"
       },
+      "ai": { "title": "AI", "description": "KI-Anbieter & Funktionen" },
       "appearance": {
         "title": "Darstellung",
         "description": "Design & Anzeige"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} Berechtigung",
     "permissionCountPlural": "{count} Berechtigungen",
     "noCustomRoles": "Noch keine benutzerdefinierten Rollen. Erstellen Sie eine, um granulare Berechtigungen fur Teammitglieder zu definieren.",
+    "presetAll": "Alle",
+    "presetViewOnly": "Nur Ansicht",
+    "presetWriteAll": "Lesen & Schreiben",
+    "presetNone": "Keine",
     "builtInRoles": "Integrierte Rollen",
     "ownerLabel": "Eigentumer",
     "ownerDescription": "Voller Zugriff. Kann Mitglieder verwalten, Rollen andern und die Organisation loschen.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Jetzt fälliger Betrag",
     "upgradeSuccess": "Erfolgreich auf Enterprise upgegradet!",
     "upgradeError": "Abonnement konnte nicht upgegradet werden"
+  },
+  "ai": {
+    "title": "KI-Assistent",
+    "description": "Konfigurieren Sie KI, um automatisch Servicenotizen zu erstellen und Fahrzeughistorien zusammenzufassen.",
+    "enableLabel": "KI aktivieren",
+    "enableHint": "KI-gestutzte Funktionen in der gesamten Anwendung nutzen",
+    "disabledInfo": "Aktivieren Sie KI, um Funktionen wie automatisch generierte Servicebeschreibungen und Fahrzeughistorien-Zusammenfassungen zu nutzen.",
+    "provider": "KI-Anbieter",
+    "apiKey": "API-Schlussel",
+    "apiKeyHint": "Ihr {provider} API-Schlussel. Sicher gespeichert und niemals weitergegeben.",
+    "model": "Modell",
+    "testConnection": "Verbindung testen",
+    "testSuccess": "KI-Verbindung erfolgreich!",
+    "testFailed": "Verbindung fehlgeschlagen. Uberprufen Sie Ihren API-Schlussel und das Modell.",
+    "saved": "KI-Einstellungen gespeichert",
+    "failedSave": "KI-Einstellungen konnten nicht gespeichert werden",
+    "saveSettings": "Einstellungen speichern",
+    "featuresTitle": "Enthaltene KI-gestutzte Funktionen:",
+    "featureServiceNotes": "Automatische Erstellung professioneller Servicebeschreibungen aus Teilen und Arbeit",
+    "featureHistorySummary": "Zusammenfassung der Fahrzeug-Servicehistorie mit einem Klick",
+    "cost": {
+      "high": "Hohe Kosten",
+      "medium": "Mittel",
+      "low": "Niedrige Kosten"
+    }
   }
 }

+ 20 - 1
messages/de/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Fehler beim L\u00f6schen des Fahrzeugs",
     "deleteTitle": "Fahrzeug l\u00f6schen?",
     "deleteDescription": "Dadurch werden {name} und alle zugeh\u00f6rigen Serviceeintr\u00e4ge, Notizen und Erinnerungen dauerhaft gel\u00f6scht. Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
-    "edit": "Bearbeiten"
+    "edit": "Bearbeiten",
+    "aiSummary": "KI-Zusammenfassung",
+    "aiSummaryTitle": "Servicehistorie-Zusammenfassung",
+    "aiSummaryGenerate": "Generieren",
+    "aiSummaryRegenerate": "Neu generieren",
+    "aiSummaryGenerated": "KI-Zusammenfassung generiert",
+    "aiSummaryUpdated": "Aktualisiert {date}",
+    "aiSummaryEmpty": "Klicken Sie auf Generieren, um eine KI-gestützte Zusammenfassung der Servicehistorie zu erstellen.",
+    "aiSummaryError": "Zusammenfassung konnte nicht generiert werden",
+    "summaryMajorWork": "Größere Arbeiten",
+    "summaryRecurringIssues": "Wiederkehrende Probleme",
+    "summaryUpcoming": "Anstehende Wartung",
+    "commonIssues": "Häufige Probleme",
+    "commonIssuesEmpty": "Klicken Sie auf Generieren, um häufige kritische Probleme für dieses Fahrzeug nachzuschlagen.",
+    "commonIssuesTitle": "Häufige kritische Probleme — {make} {model}",
+    "severityVeryCommon": "Sehr häufig — von einer großen Anzahl von Besitzern gemeldet",
+    "severityCommon": "Häufig — oft gemeldetes Problem",
+    "severityModerate": "Mäßig — von einigen Besitzern gemeldet",
+    "severityUncommon": "Ungewöhnlich — von wenigen Besitzern gemeldet",
+    "severityRare": "Selten — vereinzelte Berichte, aber kritisch wenn es auftritt"
   },
   "quotes": {
     "newQuote": "Neues Angebot",

+ 18 - 0
messages/en/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "AI Assistant",
+  "description": "Ask questions about your workshop data — vehicles, services, customers, revenue, and more.",
+  "placeholder": "Ask about your workshop...",
+  "clear": "Clear conversation",
+  "error": "Something went wrong. Please try again.",
+  "notEnabled": "AI is not enabled. Configure it in Settings → AI.",
+  "suggestion1": "Which vehicles are due for service?",
+  "suggestion2": "Show me unpaid invoices",
+  "suggestion3": "What's the revenue this month?",
+  "suggestion4": "Give me a workshop overview",
+  "newChat": "New chat",
+  "noChats": "No conversations yet",
+  "chatsPrivate": "Your chats are private and only visible to you.",
+  "deleteChat": "Delete chat",
+  "hideSidebar": "Hide sidebar",
+  "showSidebar": "Show sidebar"
+}

+ 4 - 2
messages/en/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Customer Portal",
     "reminders": "Reminders",
     "allReminders": "All Reminders",
-    "auditLog": "Audit Log"
+    "auditLog": "Audit Log",
+    "aiAssistant": "AI Assistant"
   },
   "search": "Search...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Light Mode",
     "darkMode": "Dark Mode",
     "signOut": "Sign Out",
-    "auditLog": "Audit Log"
+    "auditLog": "Audit Log",
+    "aiAssistant": "AI Assistant"
   }
 }

+ 12 - 1
messages/en/service.json

@@ -107,7 +107,18 @@
     "publicPlaceholder": "Notes visible on the invoice...",
     "internalPlaceholder": "Internal notes (not shown on invoice)...",
     "publicHelper": "Shown on the invoice and shared documents",
-    "internalHelper": "Only visible to your team"
+    "internalHelper": "Only visible to your team",
+    "aiWrite": "AI Write",
+    "aiGenerated": "AI notes generated successfully",
+    "aiGenerateFailed": "Failed to generate AI notes",
+    "aiDialogTitle": "AI Write",
+    "aiDialogDescription": "AI will analyze the parts and labor in this service record and generate a professional description. The result will be written to the currently selected note type.",
+    "aiGenerate": "Generate",
+    "aiOverwriteDescription": "The current note already has content. You can replace it or append the AI-generated text below it.",
+    "aiOverwriteReplace": "Replace",
+    "aiOverwriteAppend": "Append",
+    "aiOverwriteCancel": "Cancel",
+    "aiDontShowAgain": "Don't show this again"
   },
   "invoice": {
     "title": "Invoice Summary",

+ 30 - 0
messages/en/settings.json

@@ -24,6 +24,7 @@
       "customerPortal": { "title": "Customer Portal", "description": "Customer self-service portal" },
       "workshop": { "title": "Workshop", "description": "Technician & labor defaults" },
       "maintenance": { "title": "Maintenance", "description": "Predicted service intervals" },
+      "ai": { "title": "AI", "description": "AI provider & features" },
       "appearance": { "title": "Appearance", "description": "Theme & display" },
       "data": { "title": "Data", "description": "Export & import backup" },
       "license": { "title": "License", "description": "White-label license" },
@@ -250,6 +251,10 @@
     "permissionCount": "{count} permission",
     "permissionCountPlural": "{count} permissions",
     "noCustomRoles": "No custom roles yet. Create one to define granular permissions for team members.",
+    "presetAll": "All",
+    "presetViewOnly": "View Only",
+    "presetWriteAll": "Read & Write",
+    "presetNone": "None",
     "builtInRoles": "Built-in Roles",
     "ownerLabel": "Owner",
     "ownerDescription": "Full access. Can manage members, change roles, and delete the organization.",
@@ -900,5 +905,30 @@
     "upgradeAmountDue": "Amount due now",
     "upgradeSuccess": "Successfully upgraded to Enterprise!",
     "upgradeError": "Failed to upgrade subscription"
+  },
+  "ai": {
+    "title": "AI Assistant",
+    "description": "Configure AI to auto-generate service notes and summarize vehicle history.",
+    "enableLabel": "Enable AI",
+    "enableHint": "Use AI-powered features throughout the application",
+    "disabledInfo": "Enable AI to use features like auto-generated service descriptions and vehicle history summaries.",
+    "provider": "AI Provider",
+    "apiKey": "API Key",
+    "apiKeyHint": "Your {provider} API key. Stored securely and never shared.",
+    "model": "Model",
+    "testConnection": "Test Connection",
+    "testSuccess": "AI connection successful!",
+    "testFailed": "Connection failed. Check your API key and model.",
+    "saved": "AI settings saved",
+    "failedSave": "Failed to save AI settings",
+    "saveSettings": "Save Settings",
+    "featuresTitle": "AI-powered features included:",
+    "featureServiceNotes": "Auto-generate professional service descriptions from parts and labor",
+    "featureHistorySummary": "One-click vehicle service history summary",
+    "cost": {
+      "high": "High Cost",
+      "medium": "Medium",
+      "low": "Low Cost"
+    }
   }
 }

+ 20 - 1
messages/en/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Failed to delete vehicle",
     "deleteTitle": "Delete vehicle?",
     "deleteDescription": "This will permanently delete {name} and all associated service records, notes, and reminders. This action cannot be undone.",
-    "edit": "Edit"
+    "edit": "Edit",
+    "aiSummary": "AI Summary",
+    "aiSummaryTitle": "Service History Summary",
+    "aiSummaryGenerate": "Generate",
+    "aiSummaryRegenerate": "Regenerate",
+    "aiSummaryGenerated": "AI summary generated",
+    "aiSummaryUpdated": "Updated {date}",
+    "aiSummaryEmpty": "Click Generate to create an AI-powered summary of this vehicle's service history.",
+    "aiSummaryError": "Failed to generate summary",
+    "summaryMajorWork": "Major Work",
+    "summaryRecurringIssues": "Recurring Issues",
+    "summaryUpcoming": "Upcoming Maintenance",
+    "commonIssues": "Common Issues",
+    "commonIssuesEmpty": "Click Generate to look up common critical issues for this vehicle.",
+    "commonIssuesTitle": "Common Critical Issues — {make} {model}",
+    "severityVeryCommon": "Very common — reported by a large number of owners",
+    "severityCommon": "Common — frequently reported issue",
+    "severityModerate": "Moderate — reported by some owners",
+    "severityUncommon": "Uncommon — reported by few owners",
+    "severityRare": "Rare — isolated reports but critical when it occurs"
   },
   "quotes": {
     "newQuote": "New Quote",

+ 18 - 0
messages/es/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "Asistente IA",
+  "description": "Haga preguntas sobre los datos de su taller — vehículos, servicios, clientes, ingresos y más.",
+  "placeholder": "Pregunte sobre su taller...",
+  "clear": "Borrar conversación",
+  "error": "Algo salió mal. Por favor, inténtelo de nuevo.",
+  "notEnabled": "La IA no está habilitada. Configúrela en Ajustes → IA.",
+  "suggestion1": "¿Qué vehículos necesitan servicio pronto?",
+  "suggestion2": "Muéstrame facturas sin pagar",
+  "suggestion3": "¿Cuáles son los ingresos de este mes?",
+  "suggestion4": "Dame un resumen del taller",
+  "newChat": "Nuevo chat",
+  "noChats": "Sin conversaciones aún",
+  "chatsPrivate": "Tus chats son privados y solo visibles para ti.",
+  "deleteChat": "Eliminar chat",
+  "hideSidebar": "Ocultar panel",
+  "showSidebar": "Mostrar panel"
+}

+ 4 - 2
messages/es/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Portal del cliente",
     "reminders": "Recordatorios",
     "allReminders": "Todos los recordatorios",
-    "auditLog": "Registro de auditoría"
+    "auditLog": "Registro de auditoría",
+    "aiAssistant": "Asistente IA"
   },
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Modo claro",
     "darkMode": "Modo oscuro",
     "signOut": "Cerrar sesión",
-    "auditLog": "Registro de auditoría"
+    "auditLog": "Registro de auditoría",
+    "aiAssistant": "Asistente IA"
   }
 }

+ 12 - 1
messages/es/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Notas visibles en la factura...",
     "internalPlaceholder": "Notas internas (no mostradas en la factura)...",
     "publicHelper": "Se muestra en la factura y documentos compartidos",
-    "internalHelper": "Solo visible para su equipo"
+    "internalHelper": "Solo visible para su equipo",
+    "aiWrite": "AI Escribir",
+    "aiGenerated": "Notas AI generadas exitosamente",
+    "aiGenerateFailed": "Error al generar notas AI",
+    "aiDialogTitle": "IA Escribir",
+    "aiDialogDescription": "La IA analizará las piezas y la mano de obra en este registro de servicio y generará una descripción profesional. El resultado se escribirá en el tipo de nota seleccionado.",
+    "aiGenerate": "Generar",
+    "aiOverwriteDescription": "Esta nota ya tiene contenido. Puede reemplazarlo o agregar el texto generado por IA debajo.",
+    "aiOverwriteReplace": "Reemplazar",
+    "aiOverwriteAppend": "Agregar",
+    "aiOverwriteCancel": "Cancelar",
+    "aiDontShowAgain": "No mostrar de nuevo"
   },
   "invoice": {
     "title": "Resumen de Factura",

+ 30 - 0
messages/es/settings.json

@@ -66,6 +66,7 @@
         "title": "Mantenimiento",
         "description": "Intervalos de servicio predichos"
       },
+      "ai": { "title": "AI", "description": "Proveedor de IA y funciones" },
       "appearance": {
         "title": "Apariencia",
         "description": "Tema y visualizacion"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} permiso",
     "permissionCountPlural": "{count} permisos",
     "noCustomRoles": "Aun no hay roles personalizados. Cree uno para definir permisos granulares para los miembros del equipo.",
+    "presetAll": "Todos",
+    "presetViewOnly": "Solo lectura",
+    "presetWriteAll": "Lectura y escritura",
+    "presetNone": "Ninguno",
     "builtInRoles": "Roles integrados",
     "ownerLabel": "Propietario",
     "ownerDescription": "Acceso completo. Puede gestionar miembros, cambiar roles y eliminar la organizacion.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Monto a pagar ahora",
     "upgradeSuccess": "¡Mejorado a Enterprise con éxito!",
     "upgradeError": "Error al mejorar la suscripción"
+  },
+  "ai": {
+    "title": "Asistente de IA",
+    "description": "Configure la IA para generar automaticamente notas de servicio y resumir el historial del vehiculo.",
+    "enableLabel": "Habilitar IA",
+    "enableHint": "Usar funciones impulsadas por IA en toda la aplicacion",
+    "disabledInfo": "Habilite la IA para usar funciones como descripciones de servicio generadas automaticamente y resumenes del historial del vehiculo.",
+    "provider": "Proveedor de IA",
+    "apiKey": "Clave API",
+    "apiKeyHint": "Su clave API de {provider}. Almacenada de forma segura y nunca compartida.",
+    "model": "Modelo",
+    "testConnection": "Probar conexion",
+    "testSuccess": "Conexion de IA exitosa!",
+    "testFailed": "Conexion fallida. Verifique su clave API y modelo.",
+    "saved": "Configuracion de IA guardada",
+    "failedSave": "Error al guardar la configuracion de IA",
+    "saveSettings": "Guardar configuracion",
+    "featuresTitle": "Funciones impulsadas por IA incluidas:",
+    "featureServiceNotes": "Generar automaticamente descripciones profesionales de servicio a partir de piezas y mano de obra",
+    "featureHistorySummary": "Resumen del historial de servicio del vehiculo con un clic",
+    "cost": {
+      "high": "Alto costo",
+      "medium": "Medio",
+      "low": "Bajo costo"
+    }
   }
 }

+ 20 - 1
messages/es/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Error al eliminar vehiculo",
     "deleteTitle": "Eliminar vehiculo?",
     "deleteDescription": "Esto eliminara permanentemente {name} y todos los registros de servicio, notas y recordatorios asociados. Esta accion no se puede deshacer.",
-    "edit": "Editar"
+    "edit": "Editar",
+    "aiSummary": "Resumen IA",
+    "aiSummaryTitle": "Resumen del historial de servicio",
+    "aiSummaryGenerate": "Generar",
+    "aiSummaryRegenerate": "Regenerar",
+    "aiSummaryGenerated": "Resumen IA generado",
+    "aiSummaryUpdated": "Actualizado {date}",
+    "aiSummaryEmpty": "Haga clic en Generar para crear un resumen impulsado por IA del historial de servicio.",
+    "aiSummaryError": "Error al generar el resumen",
+    "summaryMajorWork": "Trabajo mayor",
+    "summaryRecurringIssues": "Problemas recurrentes",
+    "summaryUpcoming": "Mantenimiento próximo",
+    "commonIssues": "Problemas comunes",
+    "commonIssuesEmpty": "Haz clic en Generar para buscar problemas críticos comunes de este vehículo.",
+    "commonIssuesTitle": "Problemas críticos comunes — {make} {model}",
+    "severityVeryCommon": "Muy común — reportado por un gran número de propietarios",
+    "severityCommon": "Común — problema reportado frecuentemente",
+    "severityModerate": "Moderado — reportado por algunos propietarios",
+    "severityUncommon": "Poco común — reportado por pocos propietarios",
+    "severityRare": "Raro — reportes aislados pero crítico cuando ocurre"
   },
   "quotes": {
     "newQuote": "Nueva cotización",

+ 18 - 0
messages/fr/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "Assistant IA",
+  "description": "Posez des questions sur les données de votre atelier — véhicules, services, clients, revenus et plus.",
+  "placeholder": "Demandez à propos de votre atelier...",
+  "clear": "Effacer la conversation",
+  "error": "Une erreur s'est produite. Veuillez réessayer.",
+  "notEnabled": "L'IA n'est pas activée. Configurez-la dans Paramètres → IA.",
+  "suggestion1": "Quels véhicules doivent bientôt être révisés ?",
+  "suggestion2": "Montrez-moi les factures impayées",
+  "suggestion3": "Quel est le chiffre d'affaires ce mois-ci ?",
+  "suggestion4": "Donnez-moi un aperçu de l'atelier",
+  "newChat": "Nouveau chat",
+  "noChats": "Aucune conversation",
+  "chatsPrivate": "Vos conversations sont privées et visibles uniquement par vous.",
+  "deleteChat": "Supprimer le chat",
+  "hideSidebar": "Masquer le panneau",
+  "showSidebar": "Afficher le panneau"
+}

+ 4 - 2
messages/fr/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Portail client",
     "reminders": "Rappels",
     "allReminders": "Tous les rappels",
-    "auditLog": "Journal d'audit"
+    "auditLog": "Journal d'audit",
+    "aiAssistant": "Assistant IA"
   },
   "search": "Rechercher\u2026",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Mode clair",
     "darkMode": "Mode sombre",
     "signOut": "Se déconnecter",
-    "auditLog": "Journal d'audit"
+    "auditLog": "Journal d'audit",
+    "aiAssistant": "Assistant IA"
   }
 }

+ 12 - 1
messages/fr/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Notes visibles sur la facture...",
     "internalPlaceholder": "Notes internes (non affichées sur la facture)...",
     "publicHelper": "Affichées sur la facture et les documents partagés",
-    "internalHelper": "Visibles uniquement pour votre équipe"
+    "internalHelper": "Visibles uniquement pour votre équipe",
+    "aiWrite": "AI Rédiger",
+    "aiGenerated": "Notes AI générées avec succès",
+    "aiGenerateFailed": "Échec de la génération des notes AI",
+    "aiDialogTitle": "IA Écrire",
+    "aiDialogDescription": "L'IA analysera les pièces et la main-d'œuvre de cet enregistrement de service et générera une description professionnelle. Le résultat sera écrit dans le type de note sélectionné.",
+    "aiGenerate": "Générer",
+    "aiOverwriteDescription": "Cette note a déjà du contenu. Vous pouvez le remplacer ou ajouter le texte généré par l'IA en dessous.",
+    "aiOverwriteReplace": "Remplacer",
+    "aiOverwriteAppend": "Ajouter",
+    "aiOverwriteCancel": "Annuler",
+    "aiDontShowAgain": "Ne plus afficher"
   },
   "invoice": {
     "title": "Résumé de la Facture",

+ 30 - 0
messages/fr/settings.json

@@ -66,6 +66,7 @@
         "title": "Maintenance",
         "description": "Intervalles de service predits"
       },
+      "ai": { "title": "AI", "description": "Fournisseur IA et fonctionnalites" },
       "appearance": {
         "title": "Apparence",
         "description": "Theme et affichage"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} permission",
     "permissionCountPlural": "{count} permissions",
     "noCustomRoles": "Pas encore de roles personnalises. Creez-en un pour definir des permissions granulaires pour les membres de l'equipe.",
+    "presetAll": "Tous",
+    "presetViewOnly": "Lecture seule",
+    "presetWriteAll": "Lecture et écriture",
+    "presetNone": "Aucun",
     "builtInRoles": "Roles integres",
     "ownerLabel": "Proprietaire",
     "ownerDescription": "Acces complet. Peut gerer les membres, changer les roles et supprimer l'organisation.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Montant dû maintenant",
     "upgradeSuccess": "Mis à niveau vers Enterprise avec succès !",
     "upgradeError": "Échec de la mise à niveau de l'abonnement"
+  },
+  "ai": {
+    "title": "Assistant IA",
+    "description": "Configurez l'IA pour generer automatiquement des notes de service et resumer l'historique du vehicule.",
+    "enableLabel": "Activer l'IA",
+    "enableHint": "Utiliser les fonctionnalites alimentees par l'IA dans toute l'application",
+    "disabledInfo": "Activez l'IA pour utiliser des fonctionnalites telles que les descriptions de service generees automatiquement et les resumes d'historique de vehicule.",
+    "provider": "Fournisseur IA",
+    "apiKey": "Cle API",
+    "apiKeyHint": "Votre cle API {provider}. Stockee en toute securite et jamais partagee.",
+    "model": "Modele",
+    "testConnection": "Tester la connexion",
+    "testSuccess": "Connexion IA reussie !",
+    "testFailed": "Echec de la connexion. Verifiez votre cle API et le modele.",
+    "saved": "Parametres IA enregistres",
+    "failedSave": "Echec de l'enregistrement des parametres IA",
+    "saveSettings": "Enregistrer les parametres",
+    "featuresTitle": "Fonctionnalites IA incluses :",
+    "featureServiceNotes": "Generation automatique de descriptions de service professionnelles a partir des pieces et de la main-d'oeuvre",
+    "featureHistorySummary": "Resume de l'historique de service du vehicule en un clic",
+    "cost": {
+      "high": "Coût élevé",
+      "medium": "Moyen",
+      "low": "Faible coût"
+    }
   }
 }

+ 20 - 1
messages/fr/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "\u00c9chec de la suppression du v\u00e9hicule",
     "deleteTitle": "Supprimer le v\u00e9hicule\u00a0?",
     "deleteDescription": "Cela supprimera d\u00e9finitivement {name} ainsi que tous les dossiers de service, notes et rappels associ\u00e9s. Cette action est irr\u00e9versible.",
-    "edit": "Modifier"
+    "edit": "Modifier",
+    "aiSummary": "Résumé IA",
+    "aiSummaryTitle": "Résumé de l'historique de service",
+    "aiSummaryGenerate": "Générer",
+    "aiSummaryRegenerate": "Régénérer",
+    "aiSummaryGenerated": "Résumé IA généré",
+    "aiSummaryUpdated": "Mis à jour {date}",
+    "aiSummaryEmpty": "Cliquez sur Générer pour créer un résumé IA de l'historique de service.",
+    "aiSummaryError": "Échec de la génération du résumé",
+    "summaryMajorWork": "Travaux majeurs",
+    "summaryRecurringIssues": "Problèmes récurrents",
+    "summaryUpcoming": "Entretien à venir",
+    "commonIssues": "Problèmes courants",
+    "commonIssuesEmpty": "Cliquez sur Générer pour rechercher les problèmes critiques courants de ce véhicule.",
+    "commonIssuesTitle": "Problèmes critiques courants — {make} {model}",
+    "severityVeryCommon": "Très courant — signalé par un grand nombre de propriétaires",
+    "severityCommon": "Courant — problème fréquemment signalé",
+    "severityModerate": "Modéré — signalé par certains propriétaires",
+    "severityUncommon": "Peu courant — signalé par peu de propriétaires",
+    "severityRare": "Rare — signalements isolés mais critique lorsqu'il survient"
   },
   "quotes": {
     "newQuote": "Nouveau devis",

+ 18 - 0
messages/it/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "Assistente IA",
+  "description": "Fai domande sui dati della tua officina — veicoli, servizi, clienti, ricavi e altro.",
+  "placeholder": "Chiedi della tua officina...",
+  "clear": "Cancella conversazione",
+  "error": "Qualcosa è andato storto. Riprova.",
+  "notEnabled": "L'IA non è abilitata. Configurala in Impostazioni → IA.",
+  "suggestion1": "Quali veicoli sono in scadenza per il tagliando?",
+  "suggestion2": "Mostrami le fatture non pagate",
+  "suggestion3": "Qual è il fatturato di questo mese?",
+  "suggestion4": "Dammi una panoramica dell'officina",
+  "newChat": "Nuova chat",
+  "noChats": "Nessuna conversazione",
+  "chatsPrivate": "Le tue chat sono private e visibili solo a te.",
+  "deleteChat": "Elimina chat",
+  "hideSidebar": "Nascondi pannello",
+  "showSidebar": "Mostra pannello"
+}

+ 4 - 2
messages/it/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Portale clienti",
     "reminders": "Promemoria",
     "allReminders": "Tutti i promemoria",
-    "auditLog": "Registro attività"
+    "auditLog": "Registro attività",
+    "aiAssistant": "Assistente IA"
   },
   "search": "Cerca...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Modalità chiara",
     "darkMode": "Modalità scura",
     "signOut": "Esci",
-    "auditLog": "Registro attività"
+    "auditLog": "Registro attività",
+    "aiAssistant": "Assistente IA"
   }
 }

+ 12 - 1
messages/it/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Note visibili sulla fattura...",
     "internalPlaceholder": "Note interne (non mostrate sulla fattura)...",
     "publicHelper": "Mostrato sulla fattura e nei documenti condivisi",
-    "internalHelper": "Visibile solo al tuo team"
+    "internalHelper": "Visibile solo al tuo team",
+    "aiWrite": "AI Scrivi",
+    "aiGenerated": "Note AI generate con successo",
+    "aiGenerateFailed": "Generazione note AI non riuscita",
+    "aiDialogTitle": "AI Scrivi",
+    "aiDialogDescription": "L'IA analizzerà le parti e la manodopera in questo record di servizio e genererà una descrizione professionale. Il risultato verrà scritto nel tipo di nota selezionato.",
+    "aiGenerate": "Genera",
+    "aiOverwriteDescription": "Questa nota ha già del contenuto. Puoi sostituirlo o aggiungere il testo generato dall'IA sotto.",
+    "aiOverwriteReplace": "Sostituisci",
+    "aiOverwriteAppend": "Aggiungi",
+    "aiOverwriteCancel": "Annulla",
+    "aiDontShowAgain": "Non mostrare più"
   },
   "invoice": {
     "title": "Riepilogo Fattura",

+ 30 - 0
messages/it/settings.json

@@ -66,6 +66,7 @@
         "title": "Manutenzione",
         "description": "Intervalli di servizio previsti"
       },
+      "ai": { "title": "AI", "description": "Provider IA e funzionalita" },
       "appearance": {
         "title": "Aspetto",
         "description": "Tema e visualizzazione"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} autorizzazione",
     "permissionCountPlural": "{count} autorizzazioni",
     "noCustomRoles": "Nessun ruolo personalizzato. Creane uno per definire autorizzazioni granulari per i membri del team.",
+    "presetAll": "Tutti",
+    "presetViewOnly": "Solo visualizzazione",
+    "presetWriteAll": "Lettura e scrittura",
+    "presetNone": "Nessuno",
     "builtInRoles": "Ruoli integrati",
     "ownerLabel": "Proprietario",
     "ownerDescription": "Accesso completo. Puo gestire i membri, cambiare ruoli ed eliminare l'organizzazione.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Importo dovuto ora",
     "upgradeSuccess": "Aggiornato a Enterprise con successo!",
     "upgradeError": "Aggiornamento dell'abbonamento non riuscito"
+  },
+  "ai": {
+    "title": "Assistente IA",
+    "description": "Configura l'IA per generare automaticamente note di servizio e riassumere la cronologia del veicolo.",
+    "enableLabel": "Abilita IA",
+    "enableHint": "Usa le funzionalita basate sull'IA in tutta l'applicazione",
+    "disabledInfo": "Abilita l'IA per utilizzare funzionalita come descrizioni di servizio generate automaticamente e riassunti della cronologia del veicolo.",
+    "provider": "Provider IA",
+    "apiKey": "Chiave API",
+    "apiKeyHint": "La tua chiave API {provider}. Conservata in modo sicuro e mai condivisa.",
+    "model": "Modello",
+    "testConnection": "Testa connessione",
+    "testSuccess": "Connessione IA riuscita!",
+    "testFailed": "Connessione fallita. Controlla la tua chiave API e il modello.",
+    "saved": "Impostazioni IA salvate",
+    "failedSave": "Salvataggio delle impostazioni IA non riuscito",
+    "saveSettings": "Salva impostazioni",
+    "featuresTitle": "Funzionalita IA incluse:",
+    "featureServiceNotes": "Generazione automatica di descrizioni di servizio professionali da ricambi e manodopera",
+    "featureHistorySummary": "Riepilogo della cronologia di servizio del veicolo con un clic",
+    "cost": {
+      "high": "Costo alto",
+      "medium": "Medio",
+      "low": "Costo basso"
+    }
   }
 }

+ 20 - 1
messages/it/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Impossibile eliminare il veicolo",
     "deleteTitle": "Eliminare il veicolo?",
     "deleteDescription": "Questo eliminerà definitivamente {name} e tutti gli interventi, le note e i promemoria associati. Questa azione non può essere annullata.",
-    "edit": "Modifica"
+    "edit": "Modifica",
+    "aiSummary": "Riepilogo IA",
+    "aiSummaryTitle": "Riepilogo cronologia assistenza",
+    "aiSummaryGenerate": "Genera",
+    "aiSummaryRegenerate": "Rigenera",
+    "aiSummaryGenerated": "Riepilogo IA generato",
+    "aiSummaryUpdated": "Aggiornato {date}",
+    "aiSummaryEmpty": "Clicca Genera per creare un riepilogo IA della cronologia di assistenza.",
+    "aiSummaryError": "Impossibile generare il riepilogo",
+    "summaryMajorWork": "Lavori importanti",
+    "summaryRecurringIssues": "Problemi ricorrenti",
+    "summaryUpcoming": "Manutenzione in programma",
+    "commonIssues": "Problemi comuni",
+    "commonIssuesEmpty": "Clicca su Genera per cercare i problemi critici comuni di questo veicolo.",
+    "commonIssuesTitle": "Problemi critici comuni — {make} {model}",
+    "severityVeryCommon": "Molto comune — segnalato da un gran numero di proprietari",
+    "severityCommon": "Comune — problema segnalato frequentemente",
+    "severityModerate": "Moderato — segnalato da alcuni proprietari",
+    "severityUncommon": "Non comune — segnalato da pochi proprietari",
+    "severityRare": "Raro — segnalazioni isolate ma critico quando si verifica"
   },
   "quotes": {
     "newQuote": "Nuovo preventivo",

+ 18 - 0
messages/nb/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "AI-assistent",
+  "description": "Still spørsmål om verksteddata — kjøretøy, tjenester, kunder, inntekter og mer.",
+  "placeholder": "Spør om verkstedet ditt...",
+  "clear": "Tøm samtale",
+  "error": "Noe gikk galt. Vennligst prøv igjen.",
+  "notEnabled": "AI er ikke aktivert. Konfigurer det i Innstillinger → AI.",
+  "suggestion1": "Hvilke kjøretøy trenger snart service?",
+  "suggestion2": "Vis meg ubetalte fakturaer",
+  "suggestion3": "Hva er inntekten denne måneden?",
+  "suggestion4": "Gi meg en verkstedoversikt",
+  "newChat": "Ny chat",
+  "noChats": "Ingen samtaler ennå",
+  "chatsPrivate": "Samtalene dine er private og kun synlige for deg.",
+  "deleteChat": "Slett chat",
+  "hideSidebar": "Skjul sidepanel",
+  "showSidebar": "Vis sidepanel"
+}

+ 4 - 2
messages/nb/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Kundeportal",
     "reminders": "Påminnelser",
     "allReminders": "Alle påminnelser",
-    "auditLog": "Aktivitetslogg"
+    "auditLog": "Aktivitetslogg",
+    "aiAssistant": "AI-assistent"
   },
   "search": "Søk...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Lyst modus",
     "darkMode": "Mørkt modus",
     "signOut": "Logg ut",
-    "auditLog": "Aktivitetslogg"
+    "auditLog": "Aktivitetslogg",
+    "aiAssistant": "AI-assistent"
   }
 }

+ 12 - 1
messages/nb/service.json

@@ -107,7 +107,18 @@
     "publicPlaceholder": "Notater synlig på fakturaen...",
     "internalPlaceholder": "Interne notater (vises ikke på fakturaen)...",
     "publicHelper": "Vist på fakturaen og delte dokumenter",
-    "internalHelper": "Bare synlig for ditt lag"
+    "internalHelper": "Bare synlig for ditt lag",
+    "aiWrite": "AI Skriv",
+    "aiGenerated": "AI-notater generert",
+    "aiGenerateFailed": "Kunne ikke generere AI-notater",
+    "aiDialogTitle": "AI Skriv",
+    "aiDialogDescription": "AI vil analysere delene og arbeidet i denne serviceposten og generere en profesjonell beskrivelse. Resultatet skrives til den valgte notattypen.",
+    "aiGenerate": "Generer",
+    "aiOverwriteDescription": "Dette notatet har allerede innhold. Du kan erstatte det eller legge til AI-generert tekst under.",
+    "aiOverwriteReplace": "Erstatt",
+    "aiOverwriteAppend": "Legg til",
+    "aiOverwriteCancel": "Avbryt",
+    "aiDontShowAgain": "Ikke vis dette igjen"
   },
   "invoice": {
     "title": "Fakturaoversikt",

+ 30 - 0
messages/nb/settings.json

@@ -24,6 +24,7 @@
       "customerPortal": { "title": "Kundeportal", "description": "Kundeselvbetjeningsportal" },
       "workshop": { "title": "Verksted", "description": "Tekniker- og arbeidsstandarder" },
       "maintenance": { "title": "Vedlikehold", "description": "Predikerte serviceintervaller" },
+      "ai": { "title": "AI", "description": "AI-leverandor og funksjoner" },
       "appearance": { "title": "Utseende", "description": "Tema og visning" },
       "data": { "title": "Data", "description": "Eksporter og importer sikkerhetskopi" },
       "license": { "title": "Lisens", "description": "White-label-lisens" },
@@ -250,6 +251,10 @@
     "permissionCount": "{count} tillatelse",
     "permissionCountPlural": "{count} tillatelser",
     "noCustomRoles": "Ingen egendefinerte roller enna. Opprett en for a definere detaljerte tillatelser for teammedlemmer.",
+    "presetAll": "Alle",
+    "presetViewOnly": "Kun visning",
+    "presetWriteAll": "Les og skriv",
+    "presetNone": "Ingen",
     "builtInRoles": "Innebygde roller",
     "ownerLabel": "Eier",
     "ownerDescription": "Full tilgang. Kan administrere medlemmer, endre roller og slette organisasjonen.",
@@ -900,5 +905,30 @@
     "upgradeAmountDue": "Beløp å betale nå",
     "upgradeSuccess": "Vellykket oppgradert til Enterprise!",
     "upgradeError": "Kunne ikke oppgradere abonnementet"
+  },
+  "ai": {
+    "title": "AI-assistent",
+    "description": "Konfigurer AI for automatisk generering av servicenotater og oppsummering av kjøretøyhistorikk.",
+    "enableLabel": "Aktiver AI",
+    "enableHint": "Bruk AI-drevne funksjoner i applikasjonen",
+    "disabledInfo": "Aktiver AI for å bruke funksjoner som automatisk genererte servicebeskrivelser og oppsummering av kjøretøyhistorikk.",
+    "provider": "AI-leverandør",
+    "apiKey": "API-nøkkel",
+    "apiKeyHint": "Din {provider} API-nøkkel. Lagres sikkert og deles aldri.",
+    "model": "Modell",
+    "testConnection": "Test tilkobling",
+    "testSuccess": "AI-tilkobling vellykket!",
+    "testFailed": "Tilkobling mislyktes. Sjekk API-nøkkelen og modellen din.",
+    "saved": "AI-innstillinger lagret",
+    "failedSave": "Kunne ikke lagre AI-innstillinger",
+    "saveSettings": "Lagre innstillinger",
+    "featuresTitle": "AI-drevne funksjoner inkludert:",
+    "featureServiceNotes": "Automatisk generering av profesjonelle servicebeskrivelser fra deler og arbeid",
+    "featureHistorySummary": "Oppsummering av kjøretøyets servicehistorikk med ett klikk",
+    "cost": {
+      "high": "Høy kostnad",
+      "medium": "Middels",
+      "low": "Lav kostnad"
+    }
   }
 }

+ 20 - 1
messages/nb/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Kunne ikke slette kjoretoy",
     "deleteTitle": "Slette kjoretoy?",
     "deleteDescription": "Dette vil permanent slette {name} og alle tilknyttede serviceoppforinger, notater og paminnelser. Denne handlingen kan ikke angres.",
-    "edit": "Rediger"
+    "edit": "Rediger",
+    "aiSummary": "AI-sammendrag",
+    "aiSummaryTitle": "Servicehistorikk-sammendrag",
+    "aiSummaryGenerate": "Generer",
+    "aiSummaryRegenerate": "Regenerer",
+    "aiSummaryGenerated": "AI-sammendrag generert",
+    "aiSummaryUpdated": "Oppdatert {date}",
+    "aiSummaryEmpty": "Klikk Generer for å lage et AI-drevet sammendrag av kjøretøyets servicehistorikk.",
+    "aiSummaryError": "Kunne ikke generere sammendrag",
+    "summaryMajorWork": "Større arbeid",
+    "summaryRecurringIssues": "Gjentakende problemer",
+    "summaryUpcoming": "Kommende vedlikehold",
+    "commonIssues": "Vanlige problemer",
+    "commonIssuesEmpty": "Klikk Generer for å slå opp vanlige kritiske problemer for dette kjøretøyet.",
+    "commonIssuesTitle": "Vanlige kritiske problemer — {make} {model}",
+    "severityVeryCommon": "Svært vanlig — rapportert av et stort antall eiere",
+    "severityCommon": "Vanlig — hyppig rapportert problem",
+    "severityModerate": "Moderat — rapportert av noen eiere",
+    "severityUncommon": "Uvanlig — rapportert av få eiere",
+    "severityRare": "Sjelden — isolerte rapporter, men kritisk når det oppstår"
   },
   "quotes": {
     "newQuote": "Nytt tilbud",

+ 18 - 0
messages/nl/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "AI-assistent",
+  "description": "Stel vragen over uw werkplaatsgegevens — voertuigen, diensten, klanten, omzet en meer.",
+  "placeholder": "Vraag over uw werkplaats...",
+  "clear": "Gesprek wissen",
+  "error": "Er ging iets mis. Probeer het opnieuw.",
+  "notEnabled": "AI is niet ingeschakeld. Configureer het in Instellingen → AI.",
+  "suggestion1": "Welke voertuigen moeten binnenkort onderhouden worden?",
+  "suggestion2": "Toon me onbetaalde facturen",
+  "suggestion3": "Wat is de omzet deze maand?",
+  "suggestion4": "Geef me een werkplaatsoverzicht",
+  "newChat": "Nieuw gesprek",
+  "noChats": "Nog geen gesprekken",
+  "chatsPrivate": "Je chats zijn privé en alleen zichtbaar voor jou.",
+  "deleteChat": "Gesprek verwijderen",
+  "hideSidebar": "Zijbalk verbergen",
+  "showSidebar": "Zijbalk tonen"
+}

+ 4 - 2
messages/nl/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Klantenportaal",
     "reminders": "Herinneringen",
     "allReminders": "Alle herinneringen",
-    "auditLog": "Activiteitenlog"
+    "auditLog": "Activiteitenlog",
+    "aiAssistant": "AI-assistent"
   },
   "search": "Zoeken...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Lichte modus",
     "darkMode": "Donkere modus",
     "signOut": "Uitloggen",
-    "auditLog": "Activiteitenlog"
+    "auditLog": "Activiteitenlog",
+    "aiAssistant": "AI-assistent"
   }
 }

+ 12 - 1
messages/nl/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Opmerkingen zichtbaar op de factuur...",
     "internalPlaceholder": "Interne opmerkingen (niet weergegeven op de factuur)...",
     "publicHelper": "Weergegeven op de factuur en gedeelde documenten",
-    "internalHelper": "Alleen zichtbaar voor uw team"
+    "internalHelper": "Alleen zichtbaar voor uw team",
+    "aiWrite": "AI Schrijven",
+    "aiGenerated": "AI-notities succesvol gegenereerd",
+    "aiGenerateFailed": "AI-notities genereren mislukt",
+    "aiDialogTitle": "AI Schrijven",
+    "aiDialogDescription": "AI analyseert de onderdelen en arbeid in dit servicerecord en genereert een professionele beschrijving. Het resultaat wordt geschreven naar het geselecteerde notitietype.",
+    "aiGenerate": "Genereren",
+    "aiOverwriteDescription": "Deze notitie heeft al inhoud. U kunt deze vervangen of de AI-gegenereerde tekst eronder toevoegen.",
+    "aiOverwriteReplace": "Vervangen",
+    "aiOverwriteAppend": "Toevoegen",
+    "aiOverwriteCancel": "Annuleren",
+    "aiDontShowAgain": "Niet meer tonen"
   },
   "invoice": {
     "title": "Factuursamenvatting",

+ 30 - 0
messages/nl/settings.json

@@ -66,6 +66,7 @@
         "title": "Onderhoud",
         "description": "Voorspelde service-intervallen"
       },
+      "ai": { "title": "AI", "description": "AI-provider & functies" },
       "appearance": {
         "title": "Weergave",
         "description": "Thema en weergave"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} machtiging",
     "permissionCountPlural": "{count} machtigingen",
     "noCustomRoles": "Nog geen aangepaste rollen. Maak er een aan om gedetailleerde machtigingen voor teamleden te definiëren.",
+    "presetAll": "Alle",
+    "presetViewOnly": "Alleen bekijken",
+    "presetWriteAll": "Lezen en schrijven",
+    "presetNone": "Geen",
     "builtInRoles": "Ingebouwde rollen",
     "ownerLabel": "Eigenaar",
     "ownerDescription": "Volledige toegang. Kan leden beheren, rollen wijzigen en de organisatie verwijderen.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Nu te betalen bedrag",
     "upgradeSuccess": "Succesvol geüpgraded naar Enterprise!",
     "upgradeError": "Upgrade van abonnement mislukt"
+  },
+  "ai": {
+    "title": "AI-assistent",
+    "description": "Configureer AI om automatisch servicenotities te genereren en voertuiggeschiedenis samen te vatten.",
+    "enableLabel": "AI inschakelen",
+    "enableHint": "Gebruik AI-functies in de hele applicatie",
+    "disabledInfo": "Schakel AI in om functies te gebruiken zoals automatisch gegenereerde servicebeschrijvingen en samenvattingen van voertuiggeschiedenis.",
+    "provider": "AI-provider",
+    "apiKey": "API-sleutel",
+    "apiKeyHint": "Uw {provider} API-sleutel. Veilig opgeslagen en nooit gedeeld.",
+    "model": "Model",
+    "testConnection": "Verbinding testen",
+    "testSuccess": "AI-verbinding geslaagd!",
+    "testFailed": "Verbinding mislukt. Controleer uw API-sleutel en model.",
+    "saved": "AI-instellingen opgeslagen",
+    "failedSave": "Opslaan van AI-instellingen mislukt",
+    "saveSettings": "Instellingen opslaan",
+    "featuresTitle": "Inbegrepen AI-functies:",
+    "featureServiceNotes": "Automatisch professionele servicebeschrijvingen genereren uit onderdelen en arbeid",
+    "featureHistorySummary": "Samenvatting van voertuig-servicegeschiedenis met een klik",
+    "cost": {
+      "high": "Hoge kosten",
+      "medium": "Gemiddeld",
+      "low": "Lage kosten"
+    }
   }
 }

+ 20 - 1
messages/nl/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Verwijderen van voertuig mislukt",
     "deleteTitle": "Voertuig verwijderen?",
     "deleteDescription": "Hiermee wordt {name} permanent verwijderd, inclusief alle bijbehorende servicegegevens, notities en herinneringen. Deze actie kan niet ongedaan worden gemaakt.",
-    "edit": "Bewerken"
+    "edit": "Bewerken",
+    "aiSummary": "AI-samenvatting",
+    "aiSummaryTitle": "Samenvatting servicehistorie",
+    "aiSummaryGenerate": "Genereren",
+    "aiSummaryRegenerate": "Opnieuw genereren",
+    "aiSummaryGenerated": "AI-samenvatting gegenereerd",
+    "aiSummaryUpdated": "Bijgewerkt {date}",
+    "aiSummaryEmpty": "Klik op Genereren om een AI-samenvatting van de servicehistorie te maken.",
+    "aiSummaryError": "Kan samenvatting niet genereren",
+    "summaryMajorWork": "Groot onderhoud",
+    "summaryRecurringIssues": "Terugkerende problemen",
+    "summaryUpcoming": "Gepland onderhoud",
+    "commonIssues": "Veelvoorkomende problemen",
+    "commonIssuesEmpty": "Klik op Genereren om veelvoorkomende kritieke problemen voor dit voertuig op te zoeken.",
+    "commonIssuesTitle": "Veelvoorkomende kritieke problemen — {make} {model}",
+    "severityVeryCommon": "Zeer veel voorkomend — gemeld door een groot aantal eigenaren",
+    "severityCommon": "Veel voorkomend — veelgemeld probleem",
+    "severityModerate": "Matig — gemeld door sommige eigenaren",
+    "severityUncommon": "Ongebruikelijk — gemeld door weinig eigenaren",
+    "severityRare": "Zeldzaam — geïsoleerde meldingen maar kritiek wanneer het optreedt"
   },
   "quotes": {
     "newQuote": "Nieuwe offerte",

+ 18 - 0
messages/pl/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "Asystent AI",
+  "description": "Zadawaj pytania o dane warsztatu — pojazdy, usługi, klientów, przychody i więcej.",
+  "placeholder": "Zapytaj o swój warsztat...",
+  "clear": "Wyczyść rozmowę",
+  "error": "Coś poszło nie tak. Spróbuj ponownie.",
+  "notEnabled": "AI nie jest włączone. Skonfiguruj w Ustawienia → AI.",
+  "suggestion1": "Które pojazdy wkrótce wymagają serwisu?",
+  "suggestion2": "Pokaż mi nieopłacone faktury",
+  "suggestion3": "Jaki jest przychód w tym miesiącu?",
+  "suggestion4": "Daj mi przegląd warsztatu",
+  "newChat": "Nowy czat",
+  "noChats": "Brak rozmów",
+  "chatsPrivate": "Twoje czaty są prywatne i widoczne tylko dla Ciebie.",
+  "deleteChat": "Usuń czat",
+  "hideSidebar": "Ukryj panel",
+  "showSidebar": "Pokaż panel"
+}

+ 4 - 2
messages/pl/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Portal klienta",
     "reminders": "Przypomnienia",
     "allReminders": "Wszystkie przypomnienia",
-    "auditLog": "Dziennik audytu"
+    "auditLog": "Dziennik audytu",
+    "aiAssistant": "Asystent AI"
   },
   "search": "Szukaj...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Tryb jasny",
     "darkMode": "Tryb ciemny",
     "signOut": "Wyloguj się",
-    "auditLog": "Dziennik audytu"
+    "auditLog": "Dziennik audytu",
+    "aiAssistant": "Asystent AI"
   }
 }

+ 12 - 1
messages/pl/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Notatki widoczne na fakturze...",
     "internalPlaceholder": "Notatki wewnętrzne (nie pokazywane na fakturze)...",
     "publicHelper": "Pokazane na fakturze i współdzielonych dokumentach",
-    "internalHelper": "Widoczne tylko dla Twojego zespołu"
+    "internalHelper": "Widoczne tylko dla Twojego zespołu",
+    "aiWrite": "AI Pisz",
+    "aiGenerated": "Notatki AI wygenerowane pomyślnie",
+    "aiGenerateFailed": "Nie udało się wygenerować notatek AI",
+    "aiDialogTitle": "AI Pisz",
+    "aiDialogDescription": "AI przeanalizuje części i robociznę w tym rekordzie serwisowym i wygeneruje profesjonalny opis. Wynik zostanie zapisany w wybranym typie notatki.",
+    "aiGenerate": "Generuj",
+    "aiOverwriteDescription": "Ta notatka ma już treść. Możesz ją zastąpić lub dołączyć tekst wygenerowany przez AI poniżej.",
+    "aiOverwriteReplace": "Zastąp",
+    "aiOverwriteAppend": "Dołącz",
+    "aiOverwriteCancel": "Anuluj",
+    "aiDontShowAgain": "Nie pokazuj ponownie"
   },
   "invoice": {
     "title": "Podsumowanie Faktury",

+ 30 - 0
messages/pl/settings.json

@@ -66,6 +66,7 @@
         "title": "Konserwacja",
         "description": "Przewidywane interwaly serwisowe"
       },
+      "ai": { "title": "AI", "description": "Dostawca AI i funkcje" },
       "appearance": {
         "title": "Wyglad",
         "description": "Motyw i wyswietlanie"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} uprawnienie",
     "permissionCountPlural": "{count} uprawnien",
     "noCustomRoles": "Brak ról niestandardowych. Utworz jedną, aby zdefiniować szczególowe uprawnienia dla czlonkow zespolu.",
+    "presetAll": "Wszystkie",
+    "presetViewOnly": "Tylko podgląd",
+    "presetWriteAll": "Odczyt i zapis",
+    "presetNone": "Brak",
     "builtInRoles": "Role wbudowane",
     "ownerLabel": "Wlasciciel",
     "ownerDescription": "Pelny dostep. Moze zarzadzac czlonkami, zmieniac role i usuwac organizacje.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Kwota do zapłaty teraz",
     "upgradeSuccess": "Pomyślnie ulepszono do Enterprise!",
     "upgradeError": "Nie udało się ulepszyć subskrypcji"
+  },
+  "ai": {
+    "title": "Asystent AI",
+    "description": "Skonfiguruj AI do automatycznego generowania notatek serwisowych i podsumowywania historii pojazdu.",
+    "enableLabel": "Wlacz AI",
+    "enableHint": "Korzystaj z funkcji opartych na AI w calej aplikacji",
+    "disabledInfo": "Wlacz AI, aby korzystac z funkcji takich jak automatycznie generowane opisy serwisowe i podsumowania historii pojazdu.",
+    "provider": "Dostawca AI",
+    "apiKey": "Klucz API",
+    "apiKeyHint": "Twoj klucz API {provider}. Przechowywany bezpiecznie i nigdy nieudostepniany.",
+    "model": "Model",
+    "testConnection": "Testuj polaczenie",
+    "testSuccess": "Polaczenie AI udane!",
+    "testFailed": "Polaczenie nieudane. Sprawdz klucz API i model.",
+    "saved": "Ustawienia AI zapisane",
+    "failedSave": "Nie udalo sie zapisac ustawien AI",
+    "saveSettings": "Zapisz ustawienia",
+    "featuresTitle": "Zawarte funkcje AI:",
+    "featureServiceNotes": "Automatyczne generowanie profesjonalnych opisow serwisowych z czesci i robocizny",
+    "featureHistorySummary": "Podsumowanie historii serwisowej pojazdu jednym kliknieciem",
+    "cost": {
+      "high": "Wysoki koszt",
+      "medium": "Średni",
+      "low": "Niski koszt"
+    }
   }
 }

+ 20 - 1
messages/pl/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Nie udało się usunąć pojazdu",
     "deleteTitle": "Usunąć pojazd?",
     "deleteDescription": "Spowoduje to trwałe usunięcie {name} wraz ze wszystkimi powiązanymi rekordami serwisowymi, notatkami i przypomnieniami. Tej operacji nie można cofnąć.",
-    "edit": "Edytuj"
+    "edit": "Edytuj",
+    "aiSummary": "Podsumowanie AI",
+    "aiSummaryTitle": "Podsumowanie historii serwisowej",
+    "aiSummaryGenerate": "Generuj",
+    "aiSummaryRegenerate": "Regeneruj",
+    "aiSummaryGenerated": "Podsumowanie AI wygenerowane",
+    "aiSummaryUpdated": "Zaktualizowano {date}",
+    "aiSummaryEmpty": "Kliknij Generuj, aby utworzyć podsumowanie AI historii serwisowej pojazdu.",
+    "aiSummaryError": "Nie udało się wygenerować podsumowania",
+    "summaryMajorWork": "Większe prace",
+    "summaryRecurringIssues": "Powtarzające się problemy",
+    "summaryUpcoming": "Nadchodzące przeglądy",
+    "commonIssues": "Częste problemy",
+    "commonIssuesEmpty": "Kliknij Generuj, aby wyszukać częste krytyczne problemy tego pojazdu.",
+    "commonIssuesTitle": "Częste krytyczne problemy — {make} {model}",
+    "severityVeryCommon": "Bardzo częste — zgłaszane przez dużą liczbę właścicieli",
+    "severityCommon": "Częste — często zgłaszany problem",
+    "severityModerate": "Umiarkowane — zgłaszane przez niektórych właścicieli",
+    "severityUncommon": "Rzadkie — zgłaszane przez niewielu właścicieli",
+    "severityRare": "Bardzo rzadkie — pojedyncze zgłoszenia, ale krytyczne gdy wystąpi"
   },
   "quotes": {
     "newQuote": "Nowa wycena",

+ 18 - 0
messages/pt-BR/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "Assistente IA",
+  "description": "Faça perguntas sobre os dados da sua oficina — veículos, serviços, clientes, receita e mais.",
+  "placeholder": "Pergunte sobre sua oficina...",
+  "clear": "Limpar conversa",
+  "error": "Algo deu errado. Tente novamente.",
+  "notEnabled": "A IA não está habilitada. Configure em Configurações → IA.",
+  "suggestion1": "Quais veículos precisam de serviço em breve?",
+  "suggestion2": "Mostre-me faturas não pagas",
+  "suggestion3": "Qual é a receita deste mês?",
+  "suggestion4": "Me dê uma visão geral da oficina",
+  "newChat": "Novo chat",
+  "noChats": "Nenhuma conversa ainda",
+  "chatsPrivate": "Seus chats são privados e visíveis apenas para você.",
+  "deleteChat": "Excluir chat",
+  "hideSidebar": "Ocultar painel",
+  "showSidebar": "Mostrar painel"
+}

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

@@ -49,7 +49,8 @@
     "customerPortal": "Portal do Cliente",
     "reminders": "Lembretes",
     "allReminders": "Todos os lembretes",
-    "auditLog": "Registro de auditoria"
+    "auditLog": "Registro de auditoria",
+    "aiAssistant": "Assistente IA"
   },
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Modo Claro",
     "darkMode": "Modo Escuro",
     "signOut": "Sair",
-    "auditLog": "Registro de auditoria"
+    "auditLog": "Registro de auditoria",
+    "aiAssistant": "Assistente IA"
   }
 }

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

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Notas visíveis na fatura...",
     "internalPlaceholder": "Notas internas (não mostradas na fatura)...",
     "publicHelper": "Mostrado na fatura e documentos compartilhados",
-    "internalHelper": "Visível apenas para seu time"
+    "internalHelper": "Visível apenas para seu time",
+    "aiWrite": "AI Escrever",
+    "aiGenerated": "Notas AI geradas com sucesso",
+    "aiGenerateFailed": "Falha ao gerar notas AI",
+    "aiDialogTitle": "IA Escrever",
+    "aiDialogDescription": "A IA analisará as peças e a mão de obra neste registro de serviço e gerará uma descrição profissional. O resultado será escrito no tipo de nota selecionado.",
+    "aiGenerate": "Gerar",
+    "aiOverwriteDescription": "Esta nota já possui conteúdo. Você pode substituí-lo ou adicionar o texto gerado pela IA abaixo.",
+    "aiOverwriteReplace": "Substituir",
+    "aiOverwriteAppend": "Adicionar",
+    "aiOverwriteCancel": "Cancelar",
+    "aiDontShowAgain": "Não mostrar novamente"
   },
   "invoice": {
     "title": "Resumo da Fatura",

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

@@ -66,6 +66,7 @@
         "title": "Manutencao",
         "description": "Intervalos de servico previstos"
       },
+      "ai": { "title": "AI", "description": "Provedor de IA e recursos" },
       "appearance": {
         "title": "Aparencia",
         "description": "Tema e exibicao"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} permissao",
     "permissionCountPlural": "{count} permissoes",
     "noCustomRoles": "Nenhuma funcao personalizada ainda. Crie uma para definir permissoes granulares para membros da equipe.",
+    "presetAll": "Todos",
+    "presetViewOnly": "Somente leitura",
+    "presetWriteAll": "Leitura e escrita",
+    "presetNone": "Nenhum",
     "builtInRoles": "Funcoes integradas",
     "ownerLabel": "Proprietario",
     "ownerDescription": "Acesso total. Pode gerenciar membros, alterar funcoes e excluir a organizacao.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Valor a pagar agora",
     "upgradeSuccess": "Atualizado para Enterprise com sucesso!",
     "upgradeError": "Falha ao atualizar a assinatura"
+  },
+  "ai": {
+    "title": "Assistente de IA",
+    "description": "Configure a IA para gerar automaticamente notas de servico e resumir o historico do veiculo.",
+    "enableLabel": "Ativar IA",
+    "enableHint": "Usar recursos de IA em toda a aplicacao",
+    "disabledInfo": "Ative a IA para usar recursos como descricoes de servico geradas automaticamente e resumos do historico do veiculo.",
+    "provider": "Provedor de IA",
+    "apiKey": "Chave API",
+    "apiKeyHint": "Sua chave API {provider}. Armazenada com seguranca e nunca compartilhada.",
+    "model": "Modelo",
+    "testConnection": "Testar conexao",
+    "testSuccess": "Conexao de IA bem-sucedida!",
+    "testFailed": "Conexao falhou. Verifique sua chave API e modelo.",
+    "saved": "Configuracoes de IA salvas",
+    "failedSave": "Falha ao salvar configuracoes de IA",
+    "saveSettings": "Salvar configuracoes",
+    "featuresTitle": "Recursos de IA incluidos:",
+    "featureServiceNotes": "Gerar automaticamente descricoes profissionais de servico a partir de pecas e mao de obra",
+    "featureHistorySummary": "Resumo do historico de servico do veiculo com um clique",
+    "cost": {
+      "high": "Alto custo",
+      "medium": "Médio",
+      "low": "Baixo custo"
+    }
   }
 }

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

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Falha ao excluir veículo",
     "deleteTitle": "Excluir veículo?",
     "deleteDescription": "Isso excluirá permanentemente {name} e todos os registros de serviço, notas e lembretes associados. Esta ação não pode ser desfeita.",
-    "edit": "Editar"
+    "edit": "Editar",
+    "aiSummary": "Resumo IA",
+    "aiSummaryTitle": "Resumo do histórico de serviço",
+    "aiSummaryGenerate": "Gerar",
+    "aiSummaryRegenerate": "Regenerar",
+    "aiSummaryGenerated": "Resumo IA gerado",
+    "aiSummaryUpdated": "Atualizado {date}",
+    "aiSummaryEmpty": "Clique em Gerar para criar um resumo com IA do histórico de serviço.",
+    "aiSummaryError": "Falha ao gerar resumo",
+    "summaryMajorWork": "Trabalhos principais",
+    "summaryRecurringIssues": "Problemas recorrentes",
+    "summaryUpcoming": "Manutenção futura",
+    "commonIssues": "Problemas comuns",
+    "commonIssuesEmpty": "Clique em Gerar para buscar problemas críticos comuns deste veículo.",
+    "commonIssuesTitle": "Problemas críticos comuns — {make} {model}",
+    "severityVeryCommon": "Muito comum — relatado por um grande número de proprietários",
+    "severityCommon": "Comum — problema relatado frequentemente",
+    "severityModerate": "Moderado — relatado por alguns proprietários",
+    "severityUncommon": "Incomum — relatado por poucos proprietários",
+    "severityRare": "Raro — relatos isolados, mas crítico quando ocorre"
   },
   "quotes": {
     "newQuote": "Novo orçamento",

+ 18 - 0
messages/tr/aiChat.json

@@ -0,0 +1,18 @@
+{
+  "title": "AI Asistanı",
+  "description": "Atölye verileriniz hakkında sorular sorun — araçlar, servisler, müşteriler, gelir ve daha fazlası.",
+  "placeholder": "Atölyeniz hakkında sorun...",
+  "clear": "Sohbeti temizle",
+  "error": "Bir şeyler yanlış gitti. Lütfen tekrar deneyin.",
+  "notEnabled": "AI etkin değil. Ayarlar → AI bölümünden yapılandırın.",
+  "suggestion1": "Hangi araçların yakında servise ihtiyacı var?",
+  "suggestion2": "Ödenmemiş faturaları göster",
+  "suggestion3": "Bu ayın geliri ne kadar?",
+  "suggestion4": "Bana bir atölye özeti ver",
+  "newChat": "Yeni sohbet",
+  "noChats": "Henüz konuşma yok",
+  "chatsPrivate": "Sohbetleriniz özeldir ve yalnızca size görünür.",
+  "deleteChat": "Sohbeti sil",
+  "hideSidebar": "Paneli gizle",
+  "showSidebar": "Paneli göster"
+}

+ 4 - 2
messages/tr/navigation.json

@@ -49,7 +49,8 @@
     "customerPortal": "Müşteri Portalı",
     "reminders": "Hatırlatmalar",
     "allReminders": "Tüm hatırlatmalar",
-    "auditLog": "Denetim günlüğü"
+    "auditLog": "Denetim günlüğü",
+    "aiAssistant": "AI Asistanı"
   },
   "search": "Ara...",
   "shortcut": "Ctrl+K",
@@ -85,6 +86,7 @@
     "lightMode": "Açık Mod",
     "darkMode": "Koyu Mod",
     "signOut": "Çıkış Yap",
-    "auditLog": "Denetim günlüğü"
+    "auditLog": "Denetim günlüğü",
+    "aiAssistant": "AI Asistanı"
   }
 }

+ 12 - 1
messages/tr/service.json

@@ -105,7 +105,18 @@
     "publicPlaceholder": "Faturada görünür notlar...",
     "internalPlaceholder": "İç notlar (faturada gösterilmez)...",
     "publicHelper": "Faturada gösterilir ve paylaşılan belgelerde yer alır",
-    "internalHelper": "Yalnızca takımınız tarafından görülebilir"
+    "internalHelper": "Yalnızca takımınız tarafından görülebilir",
+    "aiWrite": "AI Yaz",
+    "aiGenerated": "AI notları başarıyla oluşturuldu",
+    "aiGenerateFailed": "AI notları oluşturulamadı",
+    "aiDialogTitle": "AI Yaz",
+    "aiDialogDescription": "AI bu servis kaydındaki parçaları ve işçiliği analiz edecek ve profesyonel bir açıklama oluşturacaktır. Sonuç seçili not türüne yazılacaktır.",
+    "aiGenerate": "Oluştur",
+    "aiOverwriteDescription": "Mevcut not zaten içeriğe sahip. Değiştirebilir veya AI tarafından oluşturulan metni altına ekleyebilirsiniz.",
+    "aiOverwriteReplace": "Değiştir",
+    "aiOverwriteAppend": "Ekle",
+    "aiOverwriteCancel": "İptal",
+    "aiDontShowAgain": "Bir daha gösterme"
   },
   "invoice": {
     "title": "Fatura Özeti",

+ 30 - 0
messages/tr/settings.json

@@ -66,6 +66,7 @@
         "title": "Bakım",
         "description": "Öngörülen servis aralıkları"
       },
+      "ai": { "title": "AI", "description": "Yapay zeka saglayici ve ozellikler" },
       "appearance": {
         "title": "Görünüm",
         "description": "Tema & görüntüleme"
@@ -304,6 +305,10 @@
     "permissionCount": "{count} izin",
     "permissionCountPlural": "{count} izin",
     "noCustomRoles": "Henüz özel rol yok. Ekip üyeleri için ayrıntılı izinler tanımlamak üzere bir tane oluşturun.",
+    "presetAll": "Tümü",
+    "presetViewOnly": "Yalnızca görüntüleme",
+    "presetWriteAll": "Okuma ve yazma",
+    "presetNone": "Hiçbiri",
     "builtInRoles": "Yerleşik Roller",
     "ownerLabel": "Sahip",
     "ownerDescription": "Tam erişim. Üyeleri yönetebilir, rolleri değiştirebilir ve kuruluşu silebilir.",
@@ -954,5 +959,30 @@
     "upgradeAmountDue": "Şimdi ödenecek tutar",
     "upgradeSuccess": "Enterprise'a başarıyla yükseltildi!",
     "upgradeError": "Abonelik yükseltilemedi"
+  },
+  "ai": {
+    "title": "Yapay Zeka Asistani",
+    "description": "Servis notlarini otomatik olusturmak ve arac gecmisini ozetlemek icin yapay zekayi yapilandirin.",
+    "enableLabel": "Yapay zekayi etkinlestir",
+    "enableHint": "Uygulama genelinde yapay zeka destekli ozellikleri kullanin",
+    "disabledInfo": "Otomatik olusturulan servis aciklamalari ve arac gecmisi ozetleri gibi ozellikleri kullanmak icin yapay zekayi etkinlestirin.",
+    "provider": "Yapay Zeka Saglayici",
+    "apiKey": "API Anahtari",
+    "apiKeyHint": "{provider} API anahtariniz. Guvenli bir sekilde saklanir ve asla paylasilmaz.",
+    "model": "Model",
+    "testConnection": "Baglantiyi test et",
+    "testSuccess": "Yapay zeka baglantisi basarili!",
+    "testFailed": "Baglanti basarisiz. API anahtarinizi ve modeli kontrol edin.",
+    "saved": "Yapay zeka ayarlari kaydedildi",
+    "failedSave": "Yapay zeka ayarlari kaydedilemedi",
+    "saveSettings": "Ayarlari kaydet",
+    "featuresTitle": "Dahil edilen yapay zeka ozellikleri:",
+    "featureServiceNotes": "Parca ve iscilikten profesyonel servis aciklamalarini otomatik olusturma",
+    "featureHistorySummary": "Tek tikla arac servis gecmisi ozeti",
+    "cost": {
+      "high": "Yüksek maliyet",
+      "medium": "Orta",
+      "low": "Düşük maliyet"
+    }
   }
 }

+ 20 - 1
messages/tr/vehicles.json

@@ -62,7 +62,26 @@
     "vehicleDeleteError": "Araç silinemedi",
     "deleteTitle": "Araç silinsin mi?",
     "deleteDescription": "Bu işlem {name} aracını ve tüm ilişkili servis kayıtlarını, notları ve hatırlatıcıları kalıcı olarak silecektir. Bu işlem geri alınamaz.",
-    "edit": "Düzenle"
+    "edit": "Düzenle",
+    "aiSummary": "AI Özet",
+    "aiSummaryTitle": "Servis Geçmişi Özeti",
+    "aiSummaryGenerate": "Oluştur",
+    "aiSummaryRegenerate": "Yeniden oluştur",
+    "aiSummaryGenerated": "AI özeti oluşturuldu",
+    "aiSummaryUpdated": "{date} güncellendi",
+    "aiSummaryEmpty": "Aracın servis geçmişinin AI destekli bir özetini oluşturmak için Oluştur'a tıklayın.",
+    "aiSummaryError": "Özet oluşturulamadı",
+    "summaryMajorWork": "Büyük işler",
+    "summaryRecurringIssues": "Tekrarlayan sorunlar",
+    "summaryUpcoming": "Yaklaşan bakım",
+    "commonIssues": "Yaygın sorunlar",
+    "commonIssuesEmpty": "Bu araç için yaygın kritik sorunları aramak üzere Oluştur'a tıklayın.",
+    "commonIssuesTitle": "Yaygın kritik sorunlar — {make} {model}",
+    "severityVeryCommon": "Çok yaygın — çok sayıda araç sahibi tarafından bildirilmiş",
+    "severityCommon": "Yaygın — sıkça bildirilen sorun",
+    "severityModerate": "Orta düzey — bazı araç sahipleri tarafından bildirilmiş",
+    "severityUncommon": "Nadir — az sayıda araç sahibi tarafından bildirilmiş",
+    "severityRare": "Çok nadir — tek tük bildirimler ancak gerçekleştiğinde kritik"
   },
   "quotes": {
     "newQuote": "Yeni teklif",

Разница между файлами не показана из-за своего большого размера
+ 1207 - 76
package-lock.json


+ 3 - 0
package.json

@@ -49,6 +49,7 @@
     "next-themes": "^0.4.6",
     "next-ws": "^2.1.16",
     "nodemailer": "^8.0.1",
+    "openai": "^6.27.0",
     "pdf-lib": "^1.17.1",
     "postmark": "^4.0.5",
     "prisma": "^6.19.2",
@@ -58,8 +59,10 @@
     "react-day-picker": "^9.13.2",
     "react-dom": "19.2.3",
     "react-hook-form": "^7.71.1",
+    "react-markdown": "^10.1.0",
     "react-resizable-panels": "^4.6.4",
     "recharts": "^3.7.0",
+    "remark-gfm": "^4.0.1",
     "resend": "^6.9.2",
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",

+ 49 - 0
prisma/migrations/20260312190308_ai_integration/migration.sql

@@ -0,0 +1,49 @@
+-- CreateTable
+CREATE TABLE "ai_generated_messages" (
+    "id" TEXT NOT NULL,
+    "vehicleId" TEXT NOT NULL,
+    "type" TEXT NOT NULL,
+    "content" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "ai_generated_messages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ai_chats" (
+    "id" TEXT NOT NULL,
+    "title" TEXT NOT NULL DEFAULT 'New chat',
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "userId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+
+    CONSTRAINT "ai_chats_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ai_chat_messages" (
+    "id" TEXT NOT NULL,
+    "role" TEXT NOT NULL,
+    "content" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "chatId" TEXT NOT NULL,
+
+    CONSTRAINT "ai_chat_messages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ai_generated_messages_vehicleId_type_key" ON "ai_generated_messages"("vehicleId", "type");
+
+-- CreateIndex
+CREATE INDEX "ai_chats_organizationId_userId_updatedAt_idx" ON "ai_chats"("organizationId", "userId", "updatedAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "ai_chat_messages_chatId_createdAt_idx" ON "ai_chat_messages"("chatId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "ai_generated_messages" ADD CONSTRAINT "ai_generated_messages_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ai_chat_messages" ADD CONSTRAINT "ai_chat_messages_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "ai_chats"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 43 - 0
prisma/schema.prisma

@@ -148,6 +148,7 @@ model Vehicle {
   archiveReason          String?
   maintenanceDismissed   Boolean   @default(false)
   maintenanceDismissedAt DateTime?
+  aiMessages AiGeneratedMessage[]
   createdAt              DateTime  @default(now())
   updatedAt              DateTime  @updatedAt
 
@@ -975,3 +976,45 @@ model ServiceRequest {
   @@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")
+}

+ 71 - 0
src/app/(authenticated)/ai/_page_disabled.tsx

@@ -0,0 +1,71 @@
+import { getAuthContext } from "@/lib/get-auth-context";
+import { getFeatures } from "@/lib/features";
+import { db } from "@/lib/db";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { PageHeader } from "@/components/page-header";
+import { AiChatPage } from "@/features/ai/Components/AiChatPage";
+import { getTranslations } from "next-intl/server";
+import { getCachedMembership } from "@/lib/cached-session";
+import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export default async function AiAssistantPage() {
+  const authContext = await getAuthContext();
+  const orgId = authContext?.organizationId;
+  const t = await getTranslations("aiChat");
+
+  let aiEnabled = false;
+  if (orgId) {
+    const [features, aiSettings] = await Promise.all([
+      getFeatures(orgId),
+      db.appSetting.findMany({
+        where: {
+          organizationId: orgId,
+          key: { in: [SETTING_KEYS.AI_ENABLED, SETTING_KEYS.AI_API_KEY] },
+        },
+        select: { key: true, value: true },
+      }),
+    ]);
+    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
+    aiEnabled =
+      features?.ai === true &&
+      aiMap[SETTING_KEYS.AI_ENABLED] === "true" &&
+      !!aiMap[SETTING_KEYS.AI_API_KEY];
+  }
+
+  // Check permission for custom role users
+  if (aiEnabled && authContext?.userId) {
+    const role = authContext.role;
+    const isOwnerOrAdmin = role === "owner" || role === "admin" || role === "super_admin";
+    if (!isOwnerOrAdmin) {
+      const membership = await getCachedMembership(authContext.userId);
+      if (membership?.roleId) {
+        const userPermissions = membership?.customRole?.permissions ?? [];
+        const canAccessAi = hasPermission(userPermissions, {
+          action: PermissionAction.READ,
+          subject: PermissionSubject.AI_ASSISTANT,
+        });
+        if (!canAccessAi) {
+          aiEnabled = false;
+        }
+      }
+    }
+  }
+
+  if (!aiEnabled) {
+    return (
+      <div className="flex h-svh flex-col overflow-hidden">
+        <PageHeader />
+        <div className="flex h-[50vh] items-center justify-center">
+          <p className="text-muted-foreground">{t("notEnabled")}</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex h-svh flex-col overflow-hidden">
+      <PageHeader />
+      <AiChatPage />
+    </div>
+  );
+}

+ 12 - 17
src/app/(authenticated)/layout.tsx

@@ -38,26 +38,22 @@ export default async function DashboardLayout({
   const features = await getFeatures(data.organizationId);
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved;
 
-  // Determine if user can access settings and reports
+  // Determine which subjects the user can access (for sidebar visibility)
   const isOwnerOrAdmin = data.role === "owner" || data.role === "admin" || data.role === "super_admin";
-  let canAccessSettings = isOwnerOrAdmin;
-  let canAccessReports = isOwnerOrAdmin;
+  const allSubjects = Object.values(PermissionSubject);
+  let visibleSubjects: string[] = allSubjects;
+
   if (!isOwnerOrAdmin) {
     const membership = await getCachedMembership(data.userId);
     // Members without a custom role have full access
-    if (!membership?.roleId) {
-      canAccessSettings = true;
-      canAccessReports = true;
-    } else {
+    if (membership?.roleId) {
       const userPermissions = membership?.customRole?.permissions ?? [];
-      canAccessSettings = hasPermission(userPermissions, {
-        action: PermissionAction.READ,
-        subject: PermissionSubject.SETTINGS,
-      });
-      canAccessReports = hasPermission(userPermissions, {
-        action: PermissionAction.READ,
-        subject: PermissionSubject.REPORTS,
-      });
+      visibleSubjects = allSubjects.filter((subject) =>
+        hasPermission(userPermissions, {
+          action: PermissionAction.READ,
+          subject: subject as PermissionSubject,
+        }),
+      );
     }
   }
 
@@ -82,8 +78,7 @@ export default async function DashboardLayout({
           activeOrgId={data.organizationId}
           isSuperAdmin={data.isSuperAdmin}
           features={features}
-          canAccessSettings={canAccessSettings}
-          canAccessReports={canAccessReports}
+          visibleSubjects={visibleSubjects}
           isAdminOrOwner={isOwnerOrAdmin}
         />
         <SidebarInset>{children}</SidebarInset>

+ 26 - 38
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -1,17 +1,13 @@
-import { getQuote } from "@/features/quotes/Actions/quoteActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { getFeatures } from "@/lib/features";
-import { PageHeader } from "@/components/page-header";
-import { QuotePageClient } from "@/features/quotes/Components/QuotePageClient";
+import { getQuote } from '@/features/quotes/Actions/quoteActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { getFeatures } from '@/lib/features'
+import { PageHeader } from '@/components/page-header'
+import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 
-export default async function QuoteDetailPage({
-  params,
-}: {
-  params: Promise<{ id: string }>;
-}) {
-  const { id } = await params;
+export default async function QuoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params
   const [result, settingsResult, authContext] = await Promise.all([
     getQuote(id),
     getSettings([
@@ -21,45 +17,37 @@ export default async function QuoteDetailPage({
       SETTING_KEYS.DEFAULT_LABOR_RATE,
     ]),
     getAuthContext(),
-  ]);
+  ])
 
-  const features = authContext?.organizationId
-    ? await getFeatures(authContext.organizationId)
-    : null;
+  const orgId = authContext?.organizationId
+  const features = orgId ? await getFeatures(orgId) : null
 
   if (!result.success || !result.data) {
     return (
       <div className="flex h-svh flex-col overflow-hidden">
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Quote not found"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Quote not found'}</p>
         </div>
       </div>
-    );
+    )
   }
 
-  const settings =
-    settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== "false";
-  const defaultTaxRate = taxEnabled
-    ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0
-    : 0;
-  const defaultLaborRate =
-    Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
-  const organizationId = authContext?.organizationId || "";
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
+  const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
+  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
+  const organizationId = authContext?.organizationId || ''
 
   // Separate attachments by category
-  const allAttachments = result.data.attachments || [];
+  const allAttachments = result.data.attachments || []
   const imageAttachments = allAttachments.filter(
-    (a: { category: string }) => a.category === "image"
-  );
+    (a: { category: string }) => a.category === 'image'
+  )
   const documentAttachments = allAttachments.filter(
-    (a: { category: string }) => a.category === "document"
-  );
-
+    (a: { category: string }) => a.category === 'document'
+  )
   return (
     <div className="flex h-svh flex-col overflow-hidden">
       <PageHeader />
@@ -78,5 +66,5 @@ export default async function QuoteDetailPage({
         emailEnabled={features?.smtp ?? false}
       />
     </div>
-  );
+  )
 }

+ 20 - 22
src/app/(authenticated)/quotes/page.tsx

@@ -1,45 +1,43 @@
-import { getQuotesPaginated } from "@/features/quotes/Actions/quoteActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { QuotesClient } from "./quotes-client";
-import { PageHeader } from "@/components/page-header";
+import { getQuotesPaginated } from '@/features/quotes/Actions/quoteActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { QuotesClient } from './quotes-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function QuotesPage({
   searchParams,
 }: {
   searchParams: Promise<{
-    page?: string;
-    pageSize?: string;
-    search?: string;
-    status?: string;
-  }>;
+    page?: string
+    pageSize?: string
+    search?: string
+    status?: string
+  }>
 }) {
-  const params = await searchParams;
+  const params = await searchParams
   const [result, settingsResult] = await Promise.all([
     getQuotesPaginated({
       page: params.page ? parseInt(params.page) : 1,
       pageSize: params.pageSize ? parseInt(params.pageSize) : 20,
       search: params.search,
-      status: params.status || "all",
+      status: params.status || 'all',
     }),
     getSettings([SETTING_KEYS.CURRENCY_CODE]),
-  ]);
+  ])
 
   if (!result.success || !result.data) {
     return (
       <>
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Failed to load quotes"}
-          </p>
+          <p className="text-muted-foreground">{result.error || 'Failed to load quotes'}</p>
         </div>
       </>
-    );
+    )
   }
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
 
   return (
     <>
@@ -48,10 +46,10 @@ export default async function QuotesPage({
         <QuotesClient
           data={result.data}
           currencyCode={currencyCode}
-          search={params.search || ""}
-          statusFilter={params.status || "all"}
+          search={params.search || ''}
+          statusFilter={params.status || 'all'}
         />
       </div>
     </>
-  );
+  )
 }

+ 136 - 127
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -1,165 +1,174 @@
-"use client";
+'use client'
 
-import { useState, useCallback, useTransition } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { useFormatDate } from "@/lib/use-format-date";
-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 { useState, useCallback, useTransition } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { useFormatDate } from '@/lib/use-format-date'
+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 {
-  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
-} from "@/components/ui/table";
-import {
-  Dialog, DialogContent, DialogHeader, DialogTitle,
-} from "@/components/ui/dialog";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { Loader2, Plus, Search } from "lucide-react";
-import { formatCurrency } from "@/lib/format";
-import { toast } from "sonner";
-import { createQuote } from "@/features/quotes/Actions/quoteActions";
-import { VehicleCombobox } from "@/features/quotes/Components/VehicleCombobox";
-import { CustomerCombobox } from "@/features/quotes/Components/CustomerCombobox";
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import { Loader2, Plus, Search } from 'lucide-react'
+import { formatCurrency } from '@/lib/format'
+import { toast } from 'sonner'
+import { createQuote } from '@/features/quotes/Actions/quoteActions'
+import { VehicleCombobox } from '@/features/quotes/Components/VehicleCombobox'
+import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
 
 interface QuoteRecord {
-  id: string;
-  quoteNumber: string | null;
-  title: string;
-  status: string;
-  totalAmount: number;
-  createdAt: Date;
-  validUntil: Date | null;
-  customer: { id: string; name: string } | null;
-  vehicle: { id: string; make: string; model: string; year: number; licensePlate: string | null } | null;
+  id: string
+  quoteNumber: string | null
+  title: string
+  status: string
+  totalAmount: number
+  createdAt: Date
+  validUntil: Date | null
+  customer: { id: string; name: string } | null
+  vehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  } | null
 }
 
 interface PaginatedData {
-  records: QuoteRecord[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-  statusCounts: Record<string, number>;
+  records: QuoteRecord[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+  statusCounts: Record<string, number>
 }
 
 const statusTabs = [
-  { key: "all", titleKey: "list.statusAll" },
-  { key: "draft", titleKey: "list.statusDraft" },
-  { key: "sent", titleKey: "list.statusSent" },
-  { key: "accepted", titleKey: "list.statusAccepted" },
-  { key: "rejected", titleKey: "list.statusRejected" },
-  { key: "converted", titleKey: "list.statusConverted" },
-] as const;
+  { key: 'all', titleKey: 'list.statusAll' },
+  { key: 'draft', titleKey: 'list.statusDraft' },
+  { key: 'sent', titleKey: 'list.statusSent' },
+  { key: 'accepted', titleKey: 'list.statusAccepted' },
+  { key: 'rejected', titleKey: 'list.statusRejected' },
+  { key: 'converted', titleKey: 'list.statusConverted' },
+] as const
 
 const statusColors: Record<string, string> = {
-  draft: "bg-gray-500/10 text-gray-500 border-gray-500/20",
-  sent: "bg-blue-500/10 text-blue-500 border-blue-500/20",
-  accepted: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
-  rejected: "bg-red-500/10 text-red-500 border-red-500/20",
-  expired: "bg-amber-500/10 text-amber-600 border-amber-500/20",
-  converted: "bg-purple-500/10 text-purple-500 border-purple-500/20",
-};
+  draft: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
+  sent: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
+  accepted: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
+  rejected: 'bg-red-500/10 text-red-500 border-red-500/20',
+  expired: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+  converted: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+}
 
 export function QuotesClient({
   data,
-  currencyCode = "USD",
+  currencyCode = 'USD',
   search,
   statusFilter,
 }: {
-  data: PaginatedData;
-  currencyCode?: string;
-  search: string;
-  statusFilter: string;
+  data: PaginatedData
+  currencyCode?: string
+  search: string
+  statusFilter: string
 }) {
-  const router = useRouter();
-  const { formatDate } = useFormatDate();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
-  const t = useTranslations("quotes");
+  const router = useRouter()
+  const { formatDate } = useFormatDate()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const [isPending, startTransition] = useTransition()
+  const [searchInput, setSearchInput] = useState(search)
+  const t = useTranslations('quotes')
 
   // New quote dialog state
-  const [showNewDialog, setShowNewDialog] = useState(false);
-  const [newTitle, setNewTitle] = useState("");
-  const [newVehicleId, setNewVehicleId] = useState("");
-  const [newCustomerId, setNewCustomerId] = useState("");
-  const [creating, setCreating] = useState(false);
+  const [showNewDialog, setShowNewDialog] = useState(false)
+  const [newTitle, setNewTitle] = useState('')
+  const [newVehicleId, setNewVehicleId] = useState('')
+  const [newCustomerId, setNewCustomerId] = useState('')
+  const [creating, setCreating] = useState(false)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params)) newParams.delete("page");
+      if (!('page' in params)) newParams.delete('page')
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
     [router, pathname, searchParams]
-  );
+  )
 
   const handleSearch = useCallback(
     (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
+      e.preventDefault()
+      navigate({ search: searchInput || undefined })
     },
     [navigate, searchInput]
-  );
+  )
 
   const openNewDialog = () => {
-    setNewTitle("");
-    setNewVehicleId("");
-    setNewCustomerId("");
-    setShowNewDialog(true);
-  };
+    setNewTitle('')
+    setNewVehicleId('')
+    setNewCustomerId('')
+    setShowNewDialog(true)
+  }
 
   const handleCreateQuote = async (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!newTitle.trim()) return;
-    setCreating(true);
+    e.preventDefault()
+    if (!newTitle.trim()) return
+    setCreating(true)
 
     const result = await createQuote({
       title: newTitle.trim(),
       vehicleId: newVehicleId || undefined,
       customerId: newCustomerId || undefined,
-      status: "draft",
+      status: 'draft',
       subtotal: 0,
       taxRate: 0,
       taxAmount: 0,
       discountValue: 0,
       discountAmount: 0,
       totalAmount: 0,
-    });
+    })
 
     if (result.success && result.data) {
-      toast.success(t("form.created"));
-      setShowNewDialog(false);
-      router.push(`/quotes/${result.data.id}`);
+      toast.success(t('form.created'))
+      setShowNewDialog(false)
+      router.push(`/quotes/${result.data.id}`)
     } else {
-      toast.error(result.error || t("form.failedCreate"));
+      toast.error(result.error || t('form.failedCreate'))
     }
-    setCreating(false);
-  };
+    setCreating(false)
+  }
 
   return (
     <div className="space-y-4">
       <div className="flex flex-wrap gap-2">
         {statusTabs.map((tab) => {
-          const isActive = statusFilter === tab.key;
-          const count = tab.key === "all" ? undefined : data.statusCounts[tab.key] || 0;
+          const isActive = statusFilter === tab.key
+          const count = tab.key === 'all' ? undefined : data.statusCounts[tab.key] || 0
           return (
             <Button
               key={tab.key}
-              variant={isActive ? "default" : "outline"}
+              variant={isActive ? 'default' : 'outline'}
               size="sm"
-              onClick={() => navigate({ status: tab.key === "all" ? undefined : tab.key })}
+              onClick={() => navigate({ status: tab.key === 'all' ? undefined : tab.key })}
             >
               {t(tab.titleKey)}
               {count !== undefined && (
@@ -168,7 +177,7 @@ export function QuotesClient({
                 </Badge>
               )}
             </Button>
-          );
+          )
         })}
       </div>
 
@@ -177,7 +186,7 @@ export function QuotesClient({
           <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
             <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
             <Input
-              placeholder={t("list.searchPlaceholder")}
+              placeholder={t('list.searchPlaceholder')}
               value={searchInput}
               onChange={(e) => setSearchInput(e.target.value)}
               className="pl-9"
@@ -187,7 +196,7 @@ export function QuotesClient({
         </div>
         <Button size="sm" onClick={openNewDialog}>
           <Plus className="mr-1 h-3.5 w-3.5" />
-          {t("list.newQuote")}
+          {t('list.newQuote')}
         </Button>
       </div>
 
@@ -195,20 +204,20 @@ export function QuotesClient({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead className="w-25">{t("list.columnQuoteNumber")}</TableHead>
-              <TableHead>{t("list.columnTitle")}</TableHead>
-              <TableHead className="hidden md:table-cell">{t("list.columnCustomer")}</TableHead>
-              <TableHead className="hidden lg:table-cell">{t("list.columnVehicle")}</TableHead>
-              <TableHead className="w-27.5">{t("list.columnStatus")}</TableHead>
-              <TableHead className="w-22.5">{t("list.columnDate")}</TableHead>
-              <TableHead className="w-22.5 text-right">{t("list.columnTotal")}</TableHead>
+              <TableHead className="w-25">{t('list.columnQuoteNumber')}</TableHead>
+              <TableHead>{t('list.columnTitle')}</TableHead>
+              <TableHead className="hidden md:table-cell">{t('list.columnCustomer')}</TableHead>
+              <TableHead className="hidden lg:table-cell">{t('list.columnVehicle')}</TableHead>
+              <TableHead className="w-27.5">{t('list.columnStatus')}</TableHead>
+              <TableHead className="w-22.5">{t('list.columnDate')}</TableHead>
+              <TableHead className="w-22.5 text-right">{t('list.columnTotal')}</TableHead>
             </TableRow>
           </TableHeader>
           <TableBody>
             {data.records.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
-                  {t("list.noQuotes")}
+                  {t('list.noQuotes')}
                 </TableCell>
               </TableRow>
             ) : (
@@ -219,17 +228,17 @@ export function QuotesClient({
                   onClick={() => router.push(`/quotes/${q.id}`)}
                 >
                   <TableCell className="font-mono text-xs text-muted-foreground">
-                    {q.quoteNumber || "-"}
+                    {q.quoteNumber || '-'}
                   </TableCell>
                   <TableCell className="font-medium">{q.title}</TableCell>
                   <TableCell className="hidden md:table-cell text-muted-foreground">
-                    {q.customer?.name || "-"}
+                    {q.customer?.name || '-'}
                   </TableCell>
                   <TableCell className="hidden lg:table-cell text-muted-foreground">
-                    {q.vehicle ? `${q.vehicle.year} ${q.vehicle.make} ${q.vehicle.model}` : "-"}
+                    {q.vehicle ? `${q.vehicle.year} ${q.vehicle.make} ${q.vehicle.model}` : '-'}
                   </TableCell>
                   <TableCell>
-                    <Badge variant="outline" className={`text-xs ${statusColors[q.status] || ""}`}>
+                    <Badge variant="outline" className={`text-xs ${statusColors[q.status] || ''}`}>
                       {q.status}
                     </Badge>
                   </TableCell>
@@ -258,58 +267,58 @@ export function QuotesClient({
       <Dialog open={showNewDialog} onOpenChange={setShowNewDialog}>
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
-            <DialogTitle>{t("form.newQuote")}</DialogTitle>
+            <DialogTitle>{t('form.newQuote')}</DialogTitle>
           </DialogHeader>
           <form onSubmit={handleCreateQuote} className="space-y-4">
             <div className="space-y-2">
-              <Label htmlFor="new-quote-title">{t("details.titleLabel")}</Label>
+              <Label htmlFor="new-quote-title">{t('details.titleLabel')}</Label>
               <Input
                 id="new-quote-title"
                 value={newTitle}
                 onChange={(e) => setNewTitle(e.target.value)}
-                placeholder={t("details.titlePlaceholder")}
+                placeholder={t('details.titlePlaceholder')}
                 required
                 autoFocus
               />
             </div>
 
             <div className="space-y-2">
-              <Label>{t("details.vehicle")}</Label>
+              <Label>{t('details.vehicle')}</Label>
               <VehicleCombobox
                 value={newVehicleId}
-                placeholder={t("details.selectVehicle")}
-                noneLabel={t("details.none")}
+                placeholder={t('details.selectVehicle')}
+                noneLabel={t('details.none')}
                 onChange={(id, vehicle) => {
-                  setNewVehicleId(id);
+                  setNewVehicleId(id)
                   if (vehicle?.customerId) {
-                    setNewCustomerId(vehicle.customerId);
+                    setNewCustomerId(vehicle.customerId)
                   }
                 }}
               />
             </div>
 
             <div className="space-y-2">
-              <Label>{t("details.customer")}</Label>
+              <Label>{t('details.customer')}</Label>
               <CustomerCombobox
                 value={newCustomerId}
-                placeholder={t("details.selectCustomer")}
-                noneLabel={t("details.none")}
+                placeholder={t('details.selectCustomer')}
+                noneLabel={t('details.none')}
                 onChange={(id) => setNewCustomerId(id)}
               />
             </div>
 
             <div className="flex justify-end gap-3 pt-2">
               <Button type="button" variant="outline" onClick={() => setShowNewDialog(false)}>
-                {t("form.cancel")}
+                {t('form.cancel')}
               </Button>
               <Button type="submit" disabled={creating || !newTitle.trim()}>
                 {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {t("form.createQuote")}
+                {t('form.createQuote')}
               </Button>
             </div>
           </form>
         </DialogContent>
       </Dialog>
     </div>
-  );
+  )
 }

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

@@ -0,0 +1,30 @@
+import { getAiSettings } from "@/features/ai/Actions/aiSettingsActions";
+import { getLayoutData } from "@/lib/get-layout-data";
+import { getFeatures, isCloudMode } from "@/lib/features";
+import { AiSettingsForm } from "@/features/ai/Components/AiSettingsForm";
+import { FeatureLockedMessage } from "../feature-locked-message";
+import { redirect } from "next/navigation";
+
+export default async function AiSettingsPage() {
+  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.ai) {
+    return (
+      <FeatureLockedMessage
+        feature="AI Assistant"
+        description="Use AI to auto-generate service descriptions, summarize vehicle history, and build quotes from plain text."
+        isCloud={isCloudMode()}
+      />
+    );
+  }
+
+  const result = await getAiSettings();
+  const settings = result.success && result.data ? result.data : {};
+
+  return <AiSettingsForm initial={settings} />;
+}

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

@@ -28,6 +28,7 @@ import {
   Mail,
   MessageSquare,
   Palette,
+  Sparkles,
   UserCog,
   UsersRound,
   Wrench,
@@ -79,6 +80,7 @@ const settingsCategories: SettingsCategory[] = [
     items: [
       { key: "workshop", href: "/settings/workshop", icon: Wrench },
       { key: "maintenance", href: "/settings/maintenance", icon: Gauge },
+      { key: "ai", href: "/settings/ai", icon: Sparkles, gate: "ai" },
     ],
   },
   {

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

@@ -32,7 +32,7 @@ import { createRole } from "@/features/team/Actions/createRole";
 import { updateRole } from "@/features/team/Actions/updateRole";
 import { deleteRole } from "@/features/team/Actions/deleteRole";
 import { assignRole } from "@/features/team/Actions/assignRole";
-import { permissionGroups } from "@/lib/permissions";
+import { permissionGroups, PermissionAction } from "@/lib/permissions";
 import { Copy, Crown, Loader2, Mail, Pencil, Plus, Shield, ShieldCheck, Trash2, User, Users, X } from "lucide-react";
 
 interface Member {
@@ -589,7 +589,69 @@ export function TeamSettings({
                 {!roleIsAdmin && (
                   <div className="space-y-3">
                     <Label className="text-sm font-medium">{t('team.permissions')}</Label>
-                    <div className="grid gap-4 sm:grid-cols-2">
+                    <div className="flex flex-wrap gap-2">
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => {
+                          const all = new Set<string>();
+                          for (const g of permissionGroups) {
+                            for (const p of g.permissions) {
+                              all.add(`${p.action}:${g.subject}`);
+                            }
+                          }
+                          setSelectedPermissions(all);
+                        }}
+                      >
+                        {t('team.presetAll')}
+                      </Button>
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => {
+                          const keys = new Set<string>();
+                          for (const g of permissionGroups) {
+                            for (const p of g.permissions) {
+                              if (p.action === PermissionAction.READ) {
+                                keys.add(`${p.action}:${g.subject}`);
+                              }
+                            }
+                          }
+                          setSelectedPermissions(keys);
+                        }}
+                      >
+                        {t('team.presetViewOnly')}
+                      </Button>
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => {
+                          const keys = new Set<string>();
+                          for (const g of permissionGroups) {
+                            for (const p of g.permissions) {
+                              if (p.action === PermissionAction.READ || p.action === PermissionAction.CREATE || p.action === PermissionAction.UPDATE) {
+                                keys.add(`${p.action}:${g.subject}`);
+                              }
+                            }
+                          }
+                          setSelectedPermissions(keys);
+                        }}
+                      >
+                        {t('team.presetWriteAll')}
+                      </Button>
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => setSelectedPermissions(new Set())}
+                      >
+                        {t('team.presetNone')}
+                      </Button>
+                    </div>
+                    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
                       {permissionGroups.map((group) => (
                         <div key={group.subject} className="space-y-2 rounded-md border p-3">
                           <p className="text-sm font-medium">{group.name}</p>

+ 20 - 0
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -9,6 +9,9 @@ import { getVehiclePredictedMileage } from "@/features/vehicles/Actions/predicte
 import { getVehicleInspections } from "@/features/inspections/Actions/inspectionActions";
 import { getTemplates } from "@/features/inspections/Actions/templateActions";
 import { getVehicleQuotes } from "@/features/quotes/Actions/quoteActions";
+import { getFeatures } from "@/lib/features";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { db } from "@/lib/db";
 import { VehicleDetailClient } from "./vehicle-detail-client";
 import { PageHeader } from "@/components/page-header";
 
@@ -116,6 +119,22 @@ export default async function VehicleDetailPage({
     };
   }
 
+  // Check AI enabled
+  const authContext = await getAuthContext();
+  const orgId = authContext?.organizationId;
+  let aiEnabled = false;
+  if (orgId) {
+    const [features, aiSettings] = await Promise.all([
+      getFeatures(orgId),
+      db.appSetting.findMany({
+        where: { organizationId: orgId, key: { in: [SETTING_KEYS.AI_ENABLED, SETTING_KEYS.AI_API_KEY] } },
+        select: { key: true, value: true },
+      }),
+    ]);
+    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
+    aiEnabled = features?.ai === true && aiMap[SETTING_KEYS.AI_ENABLED] === "true" && !!aiMap[SETTING_KEYS.AI_API_KEY];
+  }
+
   return (
     <>
       <PageHeader />
@@ -133,6 +152,7 @@ export default async function VehicleDetailPage({
           inspections={inspectionsResult.success && inspectionsResult.data ? inspectionsResult.data : []}
           inspectionTemplates={templatesResult.success && templatesResult.data ? templatesResult.data : []}
           quotes={quotesResult.success && quotesResult.data ? quotesResult.data : []}
+          aiEnabled={aiEnabled}
         />
       </div>
     </>

+ 13 - 1
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -81,7 +81,7 @@ export default async function ServiceDetailPage({
     : null;
   const orgId = membership?.organizationId;
 
-  const [members, currentUser, features] = await Promise.all([
+  const [members, currentUser, features, aiSettings] = await Promise.all([
     orgId
       ? db.organizationMember.findMany({
           where: { organizationId: orgId },
@@ -95,10 +95,21 @@ export default async function ServiceDetailPage({
         })
       : Promise.resolve(null),
     orgId ? getFeatures(orgId) : Promise.resolve(null),
+    orgId
+      ? db.appSetting.findMany({
+          where: {
+            organizationId: orgId,
+            key: { in: [SETTING_KEYS.AI_ENABLED, SETTING_KEYS.AI_API_KEY] },
+          },
+          select: { key: true, value: true },
+        })
+      : Promise.resolve([]),
   ]);
 
   const teamMembers = members.map((m) => ({ id: m.id, name: m.user.name }));
   const currentUserName = currentUser?.name || "";
+  const aiSettingsMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
+  const aiEnabled = features?.ai === true && aiSettingsMap[SETTING_KEYS.AI_ENABLED] === "true" && !!aiSettingsMap[SETTING_KEYS.AI_API_KEY];
 
   const initialData = {
     id: record.id,
@@ -177,6 +188,7 @@ export default async function ServiceDetailPage({
         maxDocumentsPerService={features?.maxDocumentsPerService ?? 999999}
         smsEnabled={features?.sms ?? false}
         emailEnabled={features?.smtp ?? false}
+        aiEnabled={aiEnabled}
       />
     </div>
   );

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

@@ -34,6 +34,8 @@ import {
   undismissMaintenance,
 } from '@/features/vehicles/Actions/dismissMaintenance'
 import { ArchiveVehicleDialog } from '@/features/vehicles/Components/ArchiveVehicleDialog'
+import { aiSummarizeVehicleHistory, aiGetCommonIssues, aiClearMessage } from '@/features/ai/Actions/aiActions'
+import { AI_MESSAGE_TYPES } from '@/features/ai/constants'
 import { formatCurrency } from '@/lib/format'
 import {
   AlertTriangle,
@@ -48,6 +50,7 @@ import {
   DollarSign,
   EyeOff,
   FileText,
+  Sparkles,
   Gauge,
   Loader2,
   MoreVertical,
@@ -162,6 +165,7 @@ interface VehicleDetail {
     isCompleted: boolean
     createdAt: Date
   }[]
+  aiMessages: { type: string; content: string; updatedAt: Date }[]
   _count: {
     serviceRecords: number
     notes: number
@@ -202,6 +206,7 @@ export function VehicleDetailClient({
   inspections,
   inspectionTemplates,
   quotes = [],
+  aiEnabled = false,
 }: {
   vehicle: VehicleDetail
   customers: CustomerOption[]
@@ -231,6 +236,7 @@ export function VehicleDetailClient({
   }[]
   inspectionTemplates?: { id: string; name: string; isDefault: boolean }[]
   quotes?: QuoteRecord[]
+  aiEnabled?: boolean
 }) {
   const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
   const router = useRouter()
@@ -276,6 +282,12 @@ export function VehicleDetailClient({
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
   const [showNewInspection, setShowNewInspection] = useState(false)
   const [isDismissPending, startDismissTransition] = useTransition()
+  const [aiSummaryLoading, setAiSummaryLoading] = useState(false)
+  const [commonIssuesLoading, setCommonIssuesLoading] = useState(false)
+  const [activeAiPanel, setActiveAiPanel] = useState<'summary' | 'issues' | null>(null)
+
+  const storedSummary = vehicle.aiMessages.find((m) => m.type === AI_MESSAGE_TYPES.SUMMARY)
+  const storedCommonIssues = vehicle.aiMessages.find((m) => m.type === AI_MESSAGE_TYPES.COMMON_ISSUES)
 
   const handleDismissMaintenance = () => {
     startDismissTransition(async () => {
@@ -355,6 +367,52 @@ export function VehicleDetailClient({
     }
   }
 
+  const handleAiSummary = async () => {
+    setAiSummaryLoading(true)
+    try {
+      const result = await aiSummarizeVehicleHistory(vehicle.id)
+      if (result.success && result.data) {
+        toast.success(t('aiSummaryGenerated'))
+        router.refresh()
+      } else {
+        toast.error(result.error ?? t('aiSummaryError'))
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    } finally {
+      setAiSummaryLoading(false)
+    }
+  }
+
+  const handleCommonIssues = async () => {
+    setActiveAiPanel('issues')
+    setCommonIssuesLoading(true)
+    try {
+      const result = await aiGetCommonIssues(vehicle.id)
+      if (result.success && result.data) {
+        router.refresh()
+      } else {
+        toast.error(result.error ?? t('aiSummaryError'))
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    } finally {
+      setCommonIssuesLoading(false)
+    }
+  }
+
+  const handleClearAiMessage = async (type: string) => {
+    try {
+      const result = await aiClearMessage(vehicle.id, type)
+      if (result.success) {
+        setActiveAiPanel(null)
+        router.refresh()
+      }
+    } catch {
+      toast.error(t('aiSummaryError'))
+    }
+  }
+
   const handleDelete = async () => {
     const result = await deleteVehicle(vehicle.id)
     if (result.success) {
@@ -620,9 +678,296 @@ export function VehicleDetailClient({
               </span>
             </div>
           )}
+          {aiEnabled && (
+            <div className="flex items-center rounded-md border">
+              {vehicle._count.serviceRecords > 0 && (
+                <Button
+                  variant={activeAiPanel === 'summary' ? 'secondary' : 'ghost'}
+                  size="sm"
+                  className="h-7 gap-1.5 text-xs rounded-r-none border-r"
+                  onClick={() => setActiveAiPanel(activeAiPanel === 'summary' ? null : 'summary')}
+                  disabled={aiSummaryLoading}
+                >
+                  {aiSummaryLoading ? (
+                    <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Sparkles className="h-3.5 w-3.5" />
+                  )}
+                  {t('aiSummary')}
+                </Button>
+              )}
+              <Button
+                variant={activeAiPanel === 'issues' ? 'secondary' : 'ghost'}
+                size="sm"
+                className={`h-7 gap-1.5 text-xs ${vehicle._count.serviceRecords > 0 ? 'rounded-l-none' : ''}`}
+                onClick={() => setActiveAiPanel(activeAiPanel === 'issues' ? null : 'issues')}
+                disabled={commonIssuesLoading}
+              >
+                {commonIssuesLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <AlertTriangle className="h-3.5 w-3.5" />
+                )}
+                {t('commonIssues')}
+              </Button>
+            </div>
+          )}
         </div>
       </div>
 
+      {/* AI Summary Panel */}
+      {activeAiPanel === 'summary' && aiEnabled && (
+        <Card className="py-0 gap-0 overflow-hidden">
+          <div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5">
+            <div className="flex items-center gap-2">
+              <Sparkles className="h-4 w-4 text-purple-500" />
+              <h3 className="text-sm font-semibold">{t('aiSummaryTitle')}</h3>
+              {storedSummary && (
+                <span className="text-[11px] text-muted-foreground">
+                  {t('aiSummaryUpdated', { date: formatDate(new Date(storedSummary.updatedAt)) })}
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-1.5">
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-7 gap-1.5 text-xs"
+                onClick={handleAiSummary}
+                disabled={aiSummaryLoading}
+              >
+                {aiSummaryLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Sparkles className="h-3.5 w-3.5" />
+                )}
+                {storedSummary ? t('aiSummaryRegenerate') : t('aiSummaryGenerate')}
+              </Button>
+              {storedSummary && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+                  onClick={() => handleClearAiMessage(AI_MESSAGE_TYPES.SUMMARY)}
+                >
+                  <X className="h-3.5 w-3.5" />
+                </Button>
+              )}
+            </div>
+          </div>
+          <div className="px-4 py-3">
+            {aiSummaryLoading && !storedSummary ? (
+              <div className="flex items-center justify-center py-6">
+                <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+              </div>
+            ) : storedSummary ? (
+              (() => {
+                let summary: {
+                  overview: string
+                  majorWork: { title: string; date: string | null; cost: number }[]
+                  recurringIssues: { title: string; description: string }[]
+                  upcomingMaintenance: { item: string; urgency: string; reason: string }[]
+                } | null = null
+                try {
+                  const raw = storedSummary.content.replace(/^```json?\n?|\n?```$/g, '').trim()
+                  summary = JSON.parse(raw)
+                } catch {
+                  return (
+                    <div className="text-sm text-muted-foreground whitespace-pre-wrap">
+                      {storedSummary.content}
+                    </div>
+                  )
+                }
+                if (!summary) return null
+                const urgencyColor: Record<string, string> = {
+                  high: 'bg-red-500/10 text-red-600 border-red-500/20',
+                  medium: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+                  low: 'bg-green-500/10 text-green-600 border-green-500/20',
+                }
+                return (
+                  <div className="space-y-4">
+                    <p className="text-sm">{summary.overview}</p>
+
+                    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                      {summary.majorWork.length > 0 && (
+                        <div>
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <Wrench className="h-3.5 w-3.5" />
+                            {t('summaryMajorWork')}
+                          </h4>
+                          <div className="space-y-1.5">
+                            {summary.majorWork.map((w, i) => (
+                              <div key={i} className="text-sm">
+                                <span className="font-medium">{w.title}</span>
+                                {(w.date || w.cost > 0) && (
+                                  <span className="text-xs text-muted-foreground ml-2">
+                                    {[w.date, w.cost > 0 && new Intl.NumberFormat('en-US', { minimumFractionDigits: 0 }).format(w.cost)].filter(Boolean).join(' · ')}
+                                  </span>
+                                )}
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+
+                      {summary.upcomingMaintenance.length > 0 && (
+                        <div>
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <Clock className="h-3.5 w-3.5" />
+                            {t('summaryUpcoming')}
+                          </h4>
+                          <div className="space-y-2">
+                            {summary.upcomingMaintenance.map((m, i) => (
+                              <div key={i}>
+                                <div className="flex items-center gap-2">
+                                  <span className="text-sm font-medium">{m.item}</span>
+                                  <Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${urgencyColor[m.urgency] || urgencyColor.medium}`}>
+                                    {m.urgency}
+                                  </Badge>
+                                </div>
+                                <p className="text-xs text-muted-foreground mt-0.5">{m.reason}</p>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+
+                      {summary.recurringIssues.length > 0 && (
+                        <div className="md:col-span-2">
+                          <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2 flex items-center gap-1.5">
+                            <AlertTriangle className="h-3.5 w-3.5" />
+                            {t('summaryRecurringIssues')}
+                          </h4>
+                          <div className="grid grid-cols-1 md:grid-cols-2 gap-1.5">
+                            {summary.recurringIssues.map((issue, i) => (
+                              <div key={i} className="text-sm">
+                                <span className="font-medium">{issue.title}</span>
+                                <p className="text-xs text-muted-foreground mt-0.5">{issue.description}</p>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                )
+              })()
+            ) : (
+              <p className="text-sm text-muted-foreground italic">{t('aiSummaryEmpty')}</p>
+            )}
+          </div>
+        </Card>
+      )}
+
+      {/* Common Issues Panel */}
+      {activeAiPanel === 'issues' && aiEnabled && (
+        <Card className="py-0 gap-0 overflow-hidden">
+          <div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2.5">
+            <div className="flex items-center gap-2">
+              <AlertTriangle className="h-4 w-4 text-amber-500" />
+              <h3 className="text-sm font-semibold">{t('commonIssuesTitle', { make: vehicle.make, model: vehicle.model })}</h3>
+              {storedCommonIssues && (
+                <span className="text-[11px] text-muted-foreground">
+                  {t('aiSummaryUpdated', { date: formatDate(new Date(storedCommonIssues.updatedAt)) })}
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-1.5">
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-7 gap-1.5 text-xs"
+                onClick={handleCommonIssues}
+                disabled={commonIssuesLoading}
+              >
+                {commonIssuesLoading ? (
+                  <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <AlertTriangle className="h-3.5 w-3.5" />
+                )}
+                {storedCommonIssues ? t('aiSummaryRegenerate') : t('aiSummaryGenerate')}
+              </Button>
+              {storedCommonIssues && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+                  onClick={() => handleClearAiMessage(AI_MESSAGE_TYPES.COMMON_ISSUES)}
+                >
+                  <X className="h-3.5 w-3.5" />
+                </Button>
+              )}
+            </div>
+          </div>
+          <div className="divide-y">
+            {commonIssuesLoading && !storedCommonIssues ? (
+              <div className="flex items-center justify-center py-6">
+                <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+              </div>
+            ) : storedCommonIssues ? (
+              (() => {
+                let issues: { title: string; description: string; cost: string; risk: string; severity?: number }[] = []
+                try {
+                  const raw = storedCommonIssues.content.replace(/^```json?\n?|\n?```$/g, '').trim()
+                  issues = JSON.parse(raw)
+                } catch {
+                  return (
+                    <div className="px-4 py-3 text-sm text-muted-foreground whitespace-pre-wrap">
+                      {storedCommonIssues.content}
+                    </div>
+                  )
+                }
+                const riskColor: Record<string, string> = {
+                  safety: 'bg-red-500/10 text-red-600 border-red-500/20',
+                  engine: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
+                  transmission: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+                  electrical: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
+                  other: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
+                }
+                const severityBarColor = (s: number) =>
+                  s >= 4 ? 'bg-red-500' : s >= 3 ? 'bg-amber-500' : 'bg-yellow-400'
+                const severityLabel = (s: number) => {
+                  if (s >= 5) return t('severityVeryCommon')
+                  if (s >= 4) return t('severityCommon')
+                  if (s >= 3) return t('severityModerate')
+                  if (s >= 2) return t('severityUncommon')
+                  return t('severityRare')
+                }
+                return issues.map((issue, i) => {
+                  const sev = Math.max(1, Math.min(5, issue.severity ?? 3))
+                  return (
+                    <div key={i} className="flex gap-3 px-4 py-3">
+                      <div className="flex flex-col items-center gap-0.5 pt-1 cursor-help" title={severityLabel(sev)}>
+                        {Array.from({ length: 5 }).map((_, j) => (
+                          <div
+                            key={j}
+                            className={`w-1.5 h-1.5 rounded-full ${4 - j < sev ? severityBarColor(sev) : 'bg-muted-foreground/20'}`}
+                          />
+                        ))}
+                      </div>
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2 flex-wrap">
+                          <span className="text-sm font-semibold">{issue.title}</span>
+                          <Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${riskColor[issue.risk] || riskColor.other}`}>
+                            {issue.risk}
+                          </Badge>
+                        </div>
+                        <p className="text-sm text-muted-foreground mt-0.5">{issue.description}</p>
+                        <span className="text-xs font-medium text-foreground/70 mt-1 inline-block">{issue.cost}</span>
+                      </div>
+                    </div>
+                  )
+                })
+              })()
+            ) : (
+              <div className="px-4 py-3">
+                <p className="text-sm text-muted-foreground italic">{t('commonIssuesEmpty')}</p>
+              </div>
+            )}
+          </div>
+        </Card>
+      )}
+
       {/* Tabs */}
       <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
         <TabsList className="grid w-full grid-cols-5">
@@ -1085,6 +1430,7 @@ export function VehicleDetailClient({
         </AlertDialogContent>
       </AlertDialog>
 
+
       {/* Image lightbox */}
       {showImage && vehicle.imageUrl && (
         <div

+ 6 - 6
src/app/(authenticated)/vehicles/vehicles-client.tsx

@@ -360,9 +360,9 @@ export function VehiclesClient({
         /* Grid skeleton */
         <div className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-3 ${view === 'grid6' ? 'xl:grid-cols-4 2xl:grid-cols-6' : 'xl:grid-cols-4'}`}>
           {Array.from({ length: view === 'grid6' ? 12 : 6 }).map((_, i) => (
-            <Card key={i} className="overflow-hidden border-0 shadow-sm">
+            <Card key={i} className="overflow-hidden border-0 py-0 gap-0 shadow-sm">
               <Skeleton className="aspect-[16/10] rounded-none" />
-              <CardContent className="flex items-center justify-between p-4">
+              <CardContent className="flex items-center justify-between px-4 py-2">
                 <Skeleton className="h-4 w-24" />
                 <Skeleton className="h-4 w-16" />
               </CardContent>
@@ -375,7 +375,7 @@ export function VehiclesClient({
           {data.vehicles.map((v) => (
             <Card
               key={v.id}
-              className="group overflow-hidden border-0 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5"
+              className="group overflow-hidden border-0 py-0 gap-0 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5"
             >
               <Link href={`/vehicles/${v.id}`}>
                 <div className="relative aspect-[16/10] overflow-hidden bg-muted">
@@ -451,12 +451,12 @@ export function VehiclesClient({
                     </DropdownMenu>
                   </div>
                 </div>
-                <CardContent className={`flex items-center justify-between ${view === 'grid6' ? 'p-2 text-xs' : 'p-4 text-sm'}`}>
-                  <div className="flex items-center gap-1.5 text-muted-foreground">
+                <CardContent className={`flex items-center justify-between ${view === 'grid6' ? 'px-2 py-1.5 text-xs' : 'px-4 py-2 text-sm'}`}>
+                  <div className="flex items-center gap-1.5 text-foreground">
                     <Gauge className={view === 'grid6' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
                     <span className="font-medium">{new Intl.NumberFormat('en-US').format(v.mileage)}</span>
                   </div>
-                  <div className={`flex items-center ${view === 'grid6' ? 'gap-2' : 'gap-3'} text-muted-foreground`}>
+                  <div className={`flex items-center ${view === 'grid6' ? 'gap-2' : 'gap-3'} text-foreground`}>
                     {v.customer && <span className={view === 'grid6' ? 'text-[10px] hidden xl:inline' : 'text-xs'}>{v.customer.name}</span>}
                     <div className="flex items-center gap-1">
                       <Wrench className={view === 'grid6' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />

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

@@ -802,7 +802,7 @@ export async function POST(request: NextRequest) {
               metadata: log.metadata != null ? log.metadata as Prisma.InputJsonValue : Prisma.DbNull,
               ip: (log.ip as string) || null,
               userAgent: (log.userAgent as string) || null,
-              userId: (log.userId as string) || null,
+              userId: ctx.userId,
               organizationId: ctx.organizationId,
             })
           ),

+ 69 - 0
src/app/globals.css

@@ -253,3 +253,72 @@
     margin-top: 0.5em;
   }
 }
+
+/* AI chat markdown */
+.ai-markdown {
+  > * + * {
+    margin-top: 0.5em;
+  }
+  h1, h2, h3 {
+    font-weight: 600;
+    margin-top: 0.75em;
+  }
+  h1 { font-size: 1.25rem; }
+  h2 { font-size: 1.1rem; }
+  h3 { font-size: 1rem; }
+  p { line-height: 1.5; }
+  strong { font-weight: 600; }
+  ul {
+    list-style: disc;
+    padding-left: 1.25rem;
+  }
+  ol {
+    list-style: decimal;
+    padding-left: 1.25rem;
+  }
+  li { margin-top: 0.15em; }
+  code {
+    font-size: 0.85em;
+    background: var(--muted);
+    padding: 0.15em 0.35em;
+    border-radius: 4px;
+  }
+  pre {
+    background: var(--muted);
+    padding: 0.75rem 1rem;
+    border-radius: 6px;
+    overflow-x: auto;
+  }
+  pre code {
+    background: none;
+    padding: 0;
+  }
+  blockquote {
+    border-left: 3px solid var(--border);
+    padding-left: 0.75rem;
+    color: var(--muted-foreground);
+    font-style: italic;
+  }
+  .table-wrapper {
+    overflow-x: auto;
+  }
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 0.5em 0;
+    font-size: 0.875rem;
+    white-space: nowrap;
+  }
+  th, td {
+    padding: 0.5rem 0.75rem;
+    text-align: left;
+    border-bottom: 1px solid var(--border);
+  }
+  th {
+    font-weight: 600;
+    background: var(--muted);
+  }
+  tr:last-child td {
+    border-bottom: none;
+  }
+}

+ 59 - 53
src/components/app-sidebar.tsx

@@ -81,9 +81,8 @@ export function AppSidebar({
   activeOrgId,
   isSuperAdmin,
   features,
-  canAccessSettings = true,
-  canAccessReports = true,
   isAdminOrOwner = false,
+  visibleSubjects,
   ...props
 }: React.ComponentProps<typeof Sidebar> & {
   companyLogo?: string
@@ -91,9 +90,8 @@ export function AppSidebar({
   activeOrgId?: string
   isSuperAdmin?: boolean
   features?: PlanFeatures
-  canAccessSettings?: boolean
-  canAccessReports?: boolean
   isAdminOrOwner?: boolean
+  visibleSubjects?: string[]
 }) {
   const pathname = usePathname()
   const router = useRouter()
@@ -106,29 +104,29 @@ export function AppSidebar({
   const [newOrgName, setNewOrgName] = React.useState('')
   const [creatingOrg, setCreatingOrg] = React.useState(false)
 
-  const showReports = features?.reports !== false && canAccessReports
+  const canAccess = (subject: string) => !visibleSubjects || visibleSubjects.includes(subject)
 
   const clientItems = [
-    { titleKey: 'sidebar.customers' as const, url: '/customers', icon: Users },
-    { titleKey: 'sidebar.messages' as const, url: '/messages', icon: MessageSquare },
-  ]
+    { titleKey: 'sidebar.customers' as const, url: '/customers', icon: Users, subject: 'customers' },
+    { titleKey: 'sidebar.messages' as const, url: '/messages', icon: MessageSquare, subject: 'customers' },
+  ].filter((item) => canAccess(item.subject))
 
   const workshopItems = [
-    { titleKey: 'sidebar.vehicles' as const, url: '/vehicles', icon: Car },
-    { titleKey: 'sidebar.reminders' as const, url: '/reminders', icon: Bell },
-    { titleKey: 'sidebar.workOrders' as const, url: '/work-orders', icon: ClipboardList },
-    { titleKey: 'sidebar.inspections' as const, url: '/inspections', icon: ClipboardCheck },
-    { titleKey: 'sidebar.calendar' as const, url: '/calendar', icon: CalendarDays },
-    { titleKey: 'sidebar.workBoard' as const, url: '/work-board', icon: Columns3 },
-  ]
+    { titleKey: 'sidebar.vehicles' as const, url: '/vehicles', icon: Car, subject: 'vehicles' },
+    { titleKey: 'sidebar.reminders' as const, url: '/reminders', icon: Bell, subject: 'vehicles' },
+    { titleKey: 'sidebar.workOrders' as const, url: '/work-orders', icon: ClipboardList, subject: 'work_orders' },
+    { titleKey: 'sidebar.inspections' as const, url: '/inspections', icon: ClipboardCheck, subject: 'inspections' },
+    { titleKey: 'sidebar.calendar' as const, url: '/calendar', icon: CalendarDays, subject: 'work_orders' },
+    { titleKey: 'sidebar.workBoard' as const, url: '/work-board', icon: Columns3, subject: 'work_board' },
+  ].filter((item) => canAccess(item.subject))
 
   const businessItems = [
-    { titleKey: 'sidebar.quotes' as const, url: '/quotes', icon: FileText },
-    { titleKey: 'sidebar.billing' as const, url: '/billing', icon: Receipt },
-    { titleKey: 'sidebar.inventory' as const, url: '/inventory', icon: Package },
-    ...(showReports ? [{ titleKey: 'sidebar.reports' as const, url: '/reports', icon: BarChart3 }] : []),
-    { titleKey: 'sidebar.auditLog' as const, url: '/audit-log', icon: History },
-  ]
+    { titleKey: 'sidebar.quotes' as const, url: '/quotes', icon: FileText, subject: 'quotes' },
+    { titleKey: 'sidebar.billing' as const, url: '/billing', icon: Receipt, subject: 'billing' },
+    { titleKey: 'sidebar.inventory' as const, url: '/inventory', icon: Package, subject: 'inventory' },
+    ...(features?.reports !== false && canAccess('reports') ? [{ titleKey: 'sidebar.reports' as const, url: '/reports', icon: BarChart3, subject: 'reports' }] : []),
+    { titleKey: 'sidebar.auditLog' as const, url: '/audit-log', icon: History, subject: 'settings' },
+  ].filter((item) => canAccess(item.subject))
 
   const closeMobileSidebar = () => {
     if (isMobile) setOpenMobile(false)
@@ -248,45 +246,53 @@ export function AppSidebar({
       </SidebarHeader>
       <SidebarContent>
         {/* Dashboard */}
-        <SidebarGroup>
-          <SidebarMenu className="gap-2">
-            <SidebarMenuItem>
-              <SidebarMenuButton asChild isActive={dashboardActive}>
-                <Link href="/" className="font-medium" onClick={closeMobileSidebar}>
-                  <LayoutDashboard className="size-4" />
-                  {t('sidebar.dashboard')}
-                </Link>
-              </SidebarMenuButton>
-            </SidebarMenuItem>
-          </SidebarMenu>
-        </SidebarGroup>
+        {canAccess('dashboard') && (
+          <SidebarGroup>
+            <SidebarMenu className="gap-2">
+              <SidebarMenuItem>
+                <SidebarMenuButton asChild isActive={dashboardActive}>
+                  <Link href="/" className="font-medium" onClick={closeMobileSidebar}>
+                    <LayoutDashboard className="size-4" />
+                    {t('sidebar.dashboard')}
+                  </Link>
+                </SidebarMenuButton>
+              </SidebarMenuItem>
+            </SidebarMenu>
+          </SidebarGroup>
+        )}
 
         {/* Clients */}
-        <SidebarGroup>
-          <SidebarGroupLabel>{t('sidebar.clients')}</SidebarGroupLabel>
-          <SidebarMenu className="gap-2">
-            {renderNavGroup(clientItems)}
-          </SidebarMenu>
-        </SidebarGroup>
+        {clientItems.length > 0 && (
+          <SidebarGroup>
+            <SidebarGroupLabel>{t('sidebar.clients')}</SidebarGroupLabel>
+            <SidebarMenu className="gap-2">
+              {renderNavGroup(clientItems)}
+            </SidebarMenu>
+          </SidebarGroup>
+        )}
 
         {/* Workshop */}
-        <SidebarGroup>
-          <SidebarGroupLabel>{t('sidebar.workshop')}</SidebarGroupLabel>
-          <SidebarMenu className="gap-2">
-            {renderNavGroup(workshopItems)}
-          </SidebarMenu>
-        </SidebarGroup>
+        {workshopItems.length > 0 && (
+          <SidebarGroup>
+            <SidebarGroupLabel>{t('sidebar.workshop')}</SidebarGroupLabel>
+            <SidebarMenu className="gap-2">
+              {renderNavGroup(workshopItems)}
+            </SidebarMenu>
+          </SidebarGroup>
+        )}
 
         {/* Business */}
-        <SidebarGroup>
-          <SidebarGroupLabel>{t('sidebar.business')}</SidebarGroupLabel>
-          <SidebarMenu className="gap-2">
-            {renderNavGroup(businessItems)}
-          </SidebarMenu>
-        </SidebarGroup>
+        {businessItems.length > 0 && (
+          <SidebarGroup>
+            <SidebarGroupLabel>{t('sidebar.business')}</SidebarGroupLabel>
+            <SidebarMenu className="gap-2">
+              {renderNavGroup(businessItems)}
+            </SidebarMenu>
+          </SidebarGroup>
+        )}
 
         {/* Settings */}
-        {canAccessSettings && (
+        {canAccess('settings') && (
           <SidebarGroup>
             <SidebarMenu className="gap-2">
               <SidebarMenuItem>
@@ -347,7 +353,7 @@ export function AppSidebar({
                 align="end"
                 sideOffset={4}
               >
-                {canAccessSettings && (
+                {canAccess('settings') && (
                   <DropdownMenuItem asChild>
                     <Link href="/settings">
                       <Settings className="mr-2 size-4" />

+ 1 - 0
src/components/page-header.tsx

@@ -75,6 +75,7 @@ const breadcrumbMap: Record<string, BreadcrumbSegment[]> = {
   '/settings/subscription': [{ key: 'settings', href: '/settings' }, { key: 'subscription' }],
   '/settings/maintenance': [{ key: 'settings', href: '/settings' }, { key: 'maintenance' }],
   '/settings/customer-portal': [{ key: 'settings', href: '/settings' }, { key: 'customerPortal' }],
+  '/ai': [{ key: 'aiAssistant' }],
   '/audit-log': [{ key: 'auditLog' }],
 }
 

+ 3 - 1
src/components/ui/command.tsx

@@ -35,12 +35,14 @@ function CommandDialog({
   children,
   className,
   showCloseButton = true,
+  shouldFilter,
   ...props
 }: React.ComponentProps<typeof Dialog> & {
   title?: string
   description?: string
   className?: string
   showCloseButton?: boolean
+  shouldFilter?: boolean
 }) {
   return (
     <Dialog {...props}>
@@ -52,7 +54,7 @@ function CommandDialog({
         className={cn("overflow-hidden p-0", className)}
         showCloseButton={showCloseButton}
       >
-        <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+        <Command shouldFilter={shouldFilter} className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
           {children}
         </Command>
       </DialogContent>

+ 166 - 0
src/features/ai/Actions/aiActions.ts

@@ -0,0 +1,166 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { getLocale } from "next-intl/server";
+import type { Locale } from "@/i18n/config";
+import {
+  generateServiceDescription,
+  summarizeServiceHistory,
+  getCommonIssues,
+  testAiConnection,
+} from "@/lib/ai";
+import { AI_MESSAGE_TYPES } from "@/features/ai/constants";
+
+export async function aiGenerateServiceNotes(serviceRecordId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const locale = (await getLocale()) as Locale;
+      const sr = await db.serviceRecord.findFirst({
+        where: { id: serviceRecordId, vehicle: { organizationId } },
+        include: {
+          vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
+          partItems: { select: { name: true, quantity: true } },
+          laborItems: { select: { description: true, hours: true } },
+        },
+      });
+
+      if (!sr) throw new Error("Service record not found");
+
+      return generateServiceDescription(organizationId, {
+        vehicleMake: sr.vehicle.make,
+        vehicleModel: sr.vehicle.model,
+        vehicleYear: sr.vehicle.year,
+        licensePlate: sr.vehicle.licensePlate,
+        serviceType: sr.type,
+        serviceTitle: sr.title,
+        parts: sr.partItems.map((p) => ({ name: p.name, quantity: p.quantity })),
+        labor: sr.laborItems.map((l) => ({
+          description: l.description,
+          hours: l.hours,
+        })),
+      }, locale);
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    },
+  );
+}
+
+async function upsertAiMessage(vehicleId: string, type: string, content: string) {
+  return db.aiGeneratedMessage.upsert({
+    where: { vehicleId_type: { vehicleId, type } },
+    create: { vehicleId, type, content },
+    update: { content },
+  });
+}
+
+export async function aiSummarizeVehicleHistory(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const locale = (await getLocale()) as Locale;
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+        select: {
+          id: true,
+          make: true,
+          model: true,
+          year: true,
+          licensePlate: true,
+          serviceRecords: {
+            select: {
+              title: true,
+              description: true,
+              serviceDate: true,
+              type: true,
+              cost: true,
+              mileage: true,
+            },
+            orderBy: { serviceDate: "asc" },
+          },
+        },
+      });
+
+      if (!vehicle) throw new Error("Vehicle not found");
+
+      if (vehicle.serviceRecords.length === 0) {
+        return "No service records found for this vehicle.";
+      }
+
+      const summary = await summarizeServiceHistory(organizationId, vehicle, vehicle.serviceRecords, locale);
+
+      await upsertAiMessage(vehicle.id, AI_MESSAGE_TYPES.SUMMARY, summary);
+
+      return summary;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES },
+      ],
+    },
+  );
+}
+
+export async function aiGetCommonIssues(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const locale = (await getLocale()) as Locale;
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+        select: { id: true, make: true, model: true, year: true },
+      });
+
+      if (!vehicle) throw new Error("Vehicle not found");
+
+      const issues = await getCommonIssues(organizationId, vehicle, locale);
+
+      await upsertAiMessage(vehicle.id, AI_MESSAGE_TYPES.COMMON_ISSUES, issues);
+
+      return issues;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.VEHICLES },
+      ],
+    },
+  );
+}
+
+export async function aiClearMessage(vehicleId: string, type: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+        select: { id: true },
+      });
+      if (!vehicle) throw new Error("Vehicle not found");
+
+      await db.aiGeneratedMessage.deleteMany({
+        where: { vehicleId: vehicle.id, type },
+      });
+
+      return true;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    },
+  );
+}
+
+export async function aiTestConnection() {
+  return withAuth(
+    async ({ organizationId }) => {
+      return testAiConnection(organizationId);
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}

+ 278 - 0
src/features/ai/Actions/aiChatActions.ts

@@ -0,0 +1,278 @@
+"use server";
+
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { getAiConfig, createClient } from "@/lib/ai";
+import { getLocale } from "next-intl/server";
+import { localeNames, type Locale } from "@/i18n/config";
+import { workshopTools, executeTool, DB_SCHEMA } from "../tools/workshop-tools";
+import { db } from "@/lib/db";
+import type OpenAI from "openai";
+
+export interface ChatMessage {
+  role: "user" | "assistant";
+  content: string;
+}
+
+export interface ChatSummary {
+  id: string;
+  title: string;
+  updatedAt: Date;
+}
+
+const MAX_TOOL_ROUNDS = 5;
+
+function buildSystemPrompt(locale: Locale): string {
+  const langNote =
+    locale !== "en"
+      ? `\n\nIMPORTANT: You MUST respond entirely in ${localeNames[locale] || locale}.`
+      : "";
+
+  return `You are a helpful AI assistant for an automotive workshop management system called TorqVoice. You can query the workshop database using the run_sql_query tool.
+
+${DB_SCHEMA}
+
+Guidelines:
+- Write PostgreSQL SELECT queries to answer user questions
+- Be concise and direct in your answers
+- Format data in easy-to-read lists or tables when returning multiple items
+- When showing financial data, include currency amounts
+- If a query returns no results, suggest alternative searches
+- You can run multiple queries if needed to answer a question
+- Never make up data — only report what the queries return
+- You can ONLY read data — if asked to modify data, explain that you cannot
+- Always double-quote camelCase column names in SQL
+- Do NOT add organization filters — they are applied automatically
+- Keep queries efficient: use LIMIT, avoid SELECT * on large tables${langNote}`;
+}
+
+/** Generate a short title from the first user message */
+function generateTitle(content: string): string {
+  const trimmed = content.trim();
+  if (trimmed.length <= 50) return trimmed;
+  return trimmed.slice(0, 47) + "...";
+}
+
+// ─── Chat CRUD ──────────────────────────────────────────────────────────────
+
+/** List all chats for the current user/org */
+export async function listAiChats() {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const chats = await db.aiChat.findMany({
+        where: { userId, organizationId },
+        select: { id: true, title: true, updatedAt: true },
+        orderBy: { updatedAt: "desc" },
+        take: 50,
+      });
+      return chats;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.AI_ASSISTANT },
+      ],
+    },
+  );
+}
+
+/** Load messages for a specific chat */
+export async function loadAiChat(chatId: string) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const chat = await db.aiChat.findFirst({
+        where: { id: chatId, userId, organizationId },
+        include: {
+          messages: {
+            select: { role: true, content: true },
+            orderBy: { createdAt: "asc" },
+          },
+        },
+      });
+      if (!chat) throw new Error("Chat not found");
+      return chat.messages as ChatMessage[];
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.AI_ASSISTANT },
+      ],
+    },
+  );
+}
+
+/** Delete a chat */
+export async function deleteAiChat(chatId: string) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      await db.aiChat.deleteMany({
+        where: { id: chatId, userId, organizationId },
+      });
+      return true;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.AI_ASSISTANT },
+      ],
+    },
+  );
+}
+
+// ─── Chat with AI ───────────────────────────────────────────────────────────
+
+export async function aiChat(chatId: string | null, messages: ChatMessage[]) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const locale = (await getLocale()) as Locale;
+      const config = await getAiConfig(organizationId);
+      const client = createClient(config);
+
+      const systemPrompt = buildSystemPrompt(locale);
+
+      const apiMessages: OpenAI.ChatCompletionMessageParam[] = [
+        { role: "system", content: systemPrompt },
+        ...messages.map((m) => ({
+          role: m.role as "user" | "assistant",
+          content: m.content,
+        })),
+      ];
+
+      // Tool-calling loop
+      let rounds = 0;
+      while (rounds < MAX_TOOL_ROUNDS) {
+        rounds++;
+
+        const response = await client.chat.completions.create({
+          model: config.model,
+          messages: apiMessages,
+          tools: workshopTools,
+          temperature: 0.3,
+          max_tokens: 3000,
+        });
+
+        const choice = response.choices[0];
+        if (!choice) throw new Error("No response from AI");
+
+        const message = choice.message;
+
+        // If no tool calls, we have the final response
+        if (!message.tool_calls || message.tool_calls.length === 0) {
+          const assistantContent = message.content || "";
+
+          // Persist to database
+          const lastUserMsg = messages[messages.length - 1];
+          if (lastUserMsg) {
+            let currentChatId = chatId;
+
+            if (!currentChatId) {
+              // Create new chat with title from first user message
+              const firstUserMsg = messages.find((m) => m.role === "user");
+              const chat = await db.aiChat.create({
+                data: {
+                  title: generateTitle(firstUserMsg?.content || "New chat"),
+                  userId,
+                  organizationId,
+                },
+              });
+              currentChatId = chat.id;
+
+              // Save all previous messages if this is a new chat
+              for (const msg of messages.slice(0, -1)) {
+                await db.aiChatMessage.create({
+                  data: { chatId: currentChatId, role: msg.role, content: msg.content },
+                });
+              }
+            }
+
+            // Save the latest user message and assistant response
+            await db.aiChatMessage.createMany({
+              data: [
+                { chatId: currentChatId, role: lastUserMsg.role, content: lastUserMsg.content },
+                { chatId: currentChatId, role: "assistant", content: assistantContent },
+              ],
+            });
+
+            // Update chat timestamp
+            await db.aiChat.update({
+              where: { id: currentChatId },
+              data: { updatedAt: new Date() },
+            });
+
+            return { content: assistantContent, chatId: currentChatId };
+          }
+
+          return { content: assistantContent, chatId };
+        }
+
+        // Add assistant message with tool calls to history
+        apiMessages.push(message);
+
+        // Execute each tool call and add results
+        for (const toolCall of message.tool_calls) {
+          if (toolCall.type !== "function") continue;
+          const fn = toolCall.function;
+          const args = JSON.parse(fn.arguments || "{}");
+          const result = await executeTool(
+            fn.name,
+            args,
+            organizationId,
+          );
+
+          apiMessages.push({
+            role: "tool",
+            tool_call_id: toolCall.id,
+            content: result,
+          });
+        }
+      }
+
+      // If we hit the limit, do one final call without tools
+      const finalResponse = await client.chat.completions.create({
+        model: config.model,
+        messages: apiMessages,
+        temperature: 0.3,
+        max_tokens: 3000,
+      });
+
+      const assistantContent = finalResponse.choices[0]?.message?.content || "";
+
+      // Persist final response
+      const lastUserMsg = messages[messages.length - 1];
+      if (lastUserMsg) {
+        let currentChatId = chatId;
+        if (!currentChatId) {
+          const firstUserMsg = messages.find((m) => m.role === "user");
+          const chat = await db.aiChat.create({
+            data: {
+              title: generateTitle(firstUserMsg?.content || "New chat"),
+              userId,
+              organizationId,
+            },
+          });
+          currentChatId = chat.id;
+          for (const msg of messages.slice(0, -1)) {
+            await db.aiChatMessage.create({
+              data: { chatId: currentChatId, role: msg.role, content: msg.content },
+            });
+          }
+        }
+        await db.aiChatMessage.createMany({
+          data: [
+            { chatId: currentChatId, role: lastUserMsg.role, content: lastUserMsg.content },
+            { chatId: currentChatId, role: "assistant", content: assistantContent },
+          ],
+        });
+        await db.aiChat.update({
+          where: { id: currentChatId },
+          data: { updatedAt: new Date() },
+        });
+        return { content: assistantContent, chatId: currentChatId };
+      }
+
+      return { content: assistantContent, chatId };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.AI_ASSISTANT },
+      ],
+    },
+  );
+}

+ 195 - 0
src/features/ai/Actions/aiSettingsActions.ts

@@ -0,0 +1,195 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { ALL_AI_KEYS, AI_KEYS, type AiProvider, type AiModel, getModelCost, formatModelLabel } from "../Schema/aiSettingsSchema";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+const API_KEY_MASK = "••••••••••••••••";
+
+export async function getAiSettings() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const settings = await db.appSetting.findMany({
+        where: { organizationId, key: { in: ALL_AI_KEYS } },
+      });
+      const map: Record<string, string> = {};
+      for (const s of settings) {
+        // Never send the actual API key to the client
+        if (s.key === AI_KEYS.AI_API_KEY) {
+          map[s.key] = s.value ? API_KEY_MASK : "";
+        } else {
+          map[s.key] = s.value;
+        }
+      }
+      return map;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function setAiSettings(entries: Record<string, string>) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      // Filter out masked API key — only update if user provided a new one
+      const filtered = Object.entries(entries).filter(
+        ([key, value]) => !(key === AI_KEYS.AI_API_KEY && value === API_KEY_MASK),
+      );
+      await db.$transaction(
+        filtered.map(([key, value]) =>
+          db.appSetting.upsert({
+            where: { organizationId_key: { organizationId, key } },
+            update: { value },
+            create: { userId, organizationId, key, value },
+          }),
+        ),
+      );
+      revalidatePath("/settings/ai");
+      return true;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.SETTINGS,
+        },
+      ],
+    },
+  );
+}
+
+// Chat-capable model prefixes for OpenAI (filter out embeddings, whisper, dall-e, etc.)
+const OPENAI_CHAT_PREFIXES = ["gpt-", "o1", "o3", "o4", "chatgpt-"];
+
+function isOpenAiChatModel(id: string): boolean {
+  return OPENAI_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix));
+}
+
+// Anthropic model patterns to include
+function isAnthropicChatModel(id: string): boolean {
+  return id.includes("claude");
+}
+
+/**
+ * Sort OpenAI models: newest version first, within same version: full > mini > nano.
+ * o-series models come first (o4 > o3 > o1), then GPT-4.x, then GPT-4o, then older.
+ */
+function openAiModelOrder(id: string): number {
+  const l = id.toLowerCase();
+  // o-series: o4, o3, o1 (newest first)
+  if (l.startsWith("o4")) return 100 + (l.includes("mini") ? 1 : 0);
+  if (l.startsWith("o3")) return 200 + (l.includes("mini") ? 1 : 0);
+  if (l.startsWith("o1")) return 300 + (l.includes("mini") ? 1 : 0);
+  // GPT-4.1
+  if (l.startsWith("gpt-4.1")) return 400 + (l.includes("nano") ? 2 : l.includes("mini") ? 1 : 0);
+  // GPT-4.5
+  if (l.startsWith("gpt-4.5")) return 350 + (l.includes("nano") ? 2 : l.includes("mini") ? 1 : 0);
+  // GPT-4o
+  if (l.startsWith("gpt-4o")) return 500 + (l.includes("mini") ? 1 : 0);
+  // GPT-4 (non-turbo / turbo)
+  if (l.startsWith("gpt-4")) return 600 + (l.includes("turbo") ? 0 : 1) + (l.includes("mini") ? 2 : 0);
+  // chatgpt-4o
+  if (l.startsWith("chatgpt")) return 700;
+  // Everything else
+  return 900;
+}
+
+/**
+ * Sort Anthropic models: newest version first, within same version: opus > sonnet > haiku.
+ */
+function anthropicModelOrder(id: string): number {
+  const l = id.toLowerCase();
+  // Extract version: claude-4, claude-3.5, claude-3, etc.
+  const versionMatch = l.match(/(\d+)[\.\-]?(\d*)/);
+  const major = versionMatch ? parseInt(versionMatch[1]) : 0;
+  const minor = versionMatch && versionMatch[2] ? parseInt(versionMatch[2]) : 0;
+  // Higher version = lower order number (sorts first)
+  const versionScore = (10 - major) * 100 + (10 - minor) * 10;
+  // Tier within version
+  const tierScore = l.includes("opus") ? 0 : l.includes("sonnet") ? 1 : l.includes("haiku") ? 2 : 3;
+  return versionScore + tierScore;
+}
+
+export async function fetchAiModels(provider: AiProvider) {
+  return withAuth(
+    async ({ organizationId }) => {
+      // Read API key from DB — never accept it from the client
+      const setting = await db.appSetting.findUnique({
+        where: { organizationId_key: { organizationId, key: AI_KEYS.AI_API_KEY } },
+        select: { value: true },
+      });
+      const apiKey = setting?.value;
+      if (!apiKey) throw new Error("API key is not configured");
+
+      const models: AiModel[] = [];
+
+      if (provider === "openai") {
+        const res = await fetch("https://api.openai.com/v1/models", {
+          headers: { Authorization: `Bearer ${apiKey}` },
+        });
+        if (!res.ok) throw new Error("Failed to fetch models. Check your API key.");
+        const data = (await res.json()) as { data: { id: string }[] };
+        for (const m of data.data) {
+          if (isOpenAiChatModel(m.id)) {
+            models.push({
+              id: m.id,
+              label: formatModelLabel(m.id),
+              cost: getModelCost("openai", m.id),
+            });
+          }
+        }
+      } else {
+        // Anthropic paginates models — fetch all pages
+        let afterId: string | undefined;
+        let hasMore = true;
+        while (hasMore) {
+          const url = new URL("https://api.anthropic.com/v1/models");
+          url.searchParams.set("limit", "100");
+          if (afterId) url.searchParams.set("after_id", afterId);
+          const res = await fetch(url.toString(), {
+            headers: {
+              "x-api-key": apiKey,
+              "anthropic-version": "2023-06-01",
+            },
+          });
+          if (!res.ok) throw new Error("Failed to fetch models. Check your API key.");
+          const data = (await res.json()) as {
+            data: { id: string; display_name?: string }[];
+            has_more?: boolean;
+            last_id?: string;
+          };
+          for (const m of data.data) {
+            if (isAnthropicChatModel(m.id)) {
+              models.push({
+                id: m.id,
+                label: m.display_name || formatModelLabel(m.id),
+                cost: getModelCost("anthropic", m.id),
+              });
+            }
+          }
+          hasMore = data.has_more === true && !!data.last_id;
+          afterId = data.last_id;
+        }
+      }
+
+      // Sort by model version (newest first) within each provider
+      if (provider === "openai") {
+        models.sort((a, b) => openAiModelOrder(a.id) - openAiModelOrder(b.id));
+      } else {
+        models.sort((a, b) => anthropicModelOrder(a.id) - anthropicModelOrder(b.id));
+      }
+
+      return models;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}

+ 353 - 0
src/features/ai/Components/AiChatPage.tsx

@@ -0,0 +1,353 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Card } from "@/components/ui/card";
+import {
+  Loader2,
+  Lock,
+  MessageSquarePlus,
+  Send,
+  Sparkles,
+  Trash2,
+  User,
+} from "lucide-react";
+import {
+  aiChat,
+  listAiChats,
+  loadAiChat,
+  deleteAiChat,
+  type ChatMessage,
+  type ChatSummary,
+} from "../Actions/aiChatActions";
+import { cn } from "@/lib/utils";
+
+const SUGGESTIONS_KEYS = [
+  "suggestion1",
+  "suggestion2",
+  "suggestion3",
+  "suggestion4",
+] as const;
+
+export function AiChatPage() {
+  const t = useTranslations("aiChat");
+  const router = useRouter();
+  const [messages, setMessages] = useState<ChatMessage[]>([]);
+  const [input, setInput] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [chatId, setChatId] = useState<string | null>(null);
+  const [chats, setChats] = useState<ChatSummary[]>([]);
+  const [loadingChats, setLoadingChats] = useState(true);
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+  const scrollToBottom = useCallback(() => {
+    if (scrollRef.current) {
+      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+    }
+  }, []);
+
+  useEffect(() => {
+    scrollToBottom();
+  }, [messages, loading, scrollToBottom]);
+
+  // Load chat list on mount
+  useEffect(() => {
+    loadChatList();
+  }, []);
+
+  const loadChatList = async () => {
+    setLoadingChats(true);
+    try {
+      const result = await listAiChats();
+      if (result.success && result.data) {
+        setChats(result.data);
+      }
+    } catch {
+      // ignore
+    } finally {
+      setLoadingChats(false);
+    }
+  };
+
+  const handleNewChat = () => {
+    setChatId(null);
+    setMessages([]);
+    setInput("");
+    textareaRef.current?.focus();
+  };
+
+  const handleSelectChat = async (id: string) => {
+    if (id === chatId) return;
+    try {
+      const result = await loadAiChat(id);
+      if (result.success && result.data) {
+        setChatId(id);
+        setMessages(result.data);
+      }
+    } catch {
+      // ignore
+    }
+  };
+
+  const handleDeleteChat = async (id: string, e: React.MouseEvent) => {
+    e.stopPropagation();
+    try {
+      await deleteAiChat(id);
+      setChats((prev) => prev.filter((c) => c.id !== id));
+      if (chatId === id) {
+        handleNewChat();
+      }
+    } catch {
+      // ignore
+    }
+  };
+
+  const sendMessage = async (text: string) => {
+    if (!text.trim() || loading) return;
+
+    const userMessage: ChatMessage = { role: "user", content: text.trim() };
+    const updated = [...messages, userMessage];
+    setMessages(updated);
+    setInput("");
+    setLoading(true);
+
+    try {
+      const result = await aiChat(chatId, updated);
+      if (result.success && result.data) {
+        const { content, chatId: returnedChatId } = result.data;
+        setMessages([
+          ...updated,
+          { role: "assistant", content },
+        ]);
+        if (returnedChatId && returnedChatId !== chatId) {
+          setChatId(returnedChatId);
+        }
+        // Refresh chat list to show new/updated chat
+        loadChatList();
+      } else {
+        setMessages([
+          ...updated,
+          { role: "assistant", content: result.error || t("error") },
+        ]);
+      }
+    } catch {
+      setMessages([
+        ...updated,
+        { role: "assistant", content: t("error") },
+      ]);
+    } finally {
+      setLoading(false);
+      textareaRef.current?.focus();
+    }
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault();
+      sendMessage(input);
+    }
+  };
+
+  return (
+    <div className="flex flex-1 overflow-hidden">
+      {/* Sidebar */}
+      <div className="flex w-64 shrink-0 flex-col border-r bg-muted/30">
+        <div className="flex flex-col gap-2 border-b p-3">
+          <Button
+            variant="outline"
+            size="sm"
+            className="w-full justify-start gap-2"
+            onClick={handleNewChat}
+          >
+            <MessageSquarePlus className="h-4 w-4" />
+            {t("newChat")}
+          </Button>
+          <p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
+            <Lock className="h-3 w-3 shrink-0" />
+            {t("chatsPrivate")}
+          </p>
+        </div>
+
+        <div className="flex-1 overflow-y-auto">
+          {loadingChats ? (
+            <div className="flex justify-center p-4">
+              <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+            </div>
+          ) : chats.length === 0 ? (
+            <p className="p-4 text-center text-xs text-muted-foreground">
+              {t("noChats")}
+            </p>
+          ) : (
+            <div className="flex flex-col gap-0.5 p-2">
+              {chats.map((chat) => (
+                <div
+                  key={chat.id}
+                  role="button"
+                  tabIndex={0}
+                  onClick={() => handleSelectChat(chat.id)}
+                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleSelectChat(chat.id); }}
+                  className={cn(
+                    "group flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
+                    chat.id === chatId && "bg-accent"
+                  )}
+                >
+                  <span className="flex-1 truncate">{chat.title}</span>
+                  <button
+                    type="button"
+                    onClick={(e) => handleDeleteChat(chat.id, e)}
+                    className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
+                    title={t("deleteChat")}
+                  >
+                    <Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
+                  </button>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Main chat area */}
+      <div className="flex flex-1 flex-col overflow-hidden">
+        {/* Messages area */}
+        <div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
+          {messages.length === 0 ? (
+            <div className="flex h-full flex-col items-center justify-center gap-6">
+              <div className="flex flex-col items-center gap-2 text-center">
+                <div className="rounded-full bg-primary/10 p-3">
+                  <Sparkles className="h-8 w-8 text-primary" />
+                </div>
+                <h2 className="text-lg font-semibold">{t("title")}</h2>
+                <p className="max-w-md text-sm text-muted-foreground">
+                  {t("description")}
+                </p>
+              </div>
+              <div className="grid w-full max-w-2xl grid-cols-1 gap-2 sm:grid-cols-2">
+                {SUGGESTIONS_KEYS.map((key) => (
+                  <button
+                    key={key}
+                    type="button"
+                    onClick={() => sendMessage(t(key))}
+                    className="rounded-lg border bg-card p-3 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
+                  >
+                    {t(key)}
+                  </button>
+                ))}
+              </div>
+            </div>
+          ) : (
+            <div className="mx-auto max-w-5xl space-y-4 pb-4">
+              {messages.map((msg, i) => (
+                <div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : ""}`}>
+                  {msg.role === "assistant" && (
+                    <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
+                      <Sparkles className="h-3.5 w-3.5 text-primary" />
+                    </div>
+                  )}
+                  <Card
+                    className={cn(
+                      "px-4 py-3",
+                      msg.role === "user"
+                        ? "max-w-[85%] bg-primary text-primary-foreground"
+                        : "max-w-full bg-card"
+                    )}
+                  >
+                    {msg.role === "user" ? (
+                      <div className="whitespace-pre-wrap text-sm">{msg.content}</div>
+                    ) : (
+                      <div className="ai-markdown text-sm">
+                        <ReactMarkdown
+                          remarkPlugins={[remarkGfm]}
+                          components={{
+                            table: ({ children }) => (
+                              <div className="overflow-x-auto">
+                                <table>{children}</table>
+                              </div>
+                            ),
+                            a: ({ href, children }) => {
+                              if (href?.startsWith("/")) {
+                                return (
+                                  <a
+                                    href={href}
+                                    onClick={(e) => {
+                                      e.preventDefault();
+                                      router.push(href);
+                                    }}
+                                    className="text-primary underline underline-offset-2 hover:text-primary/80"
+                                  >
+                                    {children}
+                                  </a>
+                                );
+                              }
+                              return (
+                                <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2">
+                                  {children}
+                                </a>
+                              );
+                            },
+                          }}
+                        >
+                          {msg.content}
+                        </ReactMarkdown>
+                      </div>
+                    )}
+                  </Card>
+                  {msg.role === "user" && (
+                    <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
+                      <User className="h-3.5 w-3.5" />
+                    </div>
+                  )}
+                </div>
+              ))}
+              {loading && (
+                <div className="flex gap-3">
+                  <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
+                    <Sparkles className="h-3.5 w-3.5 text-primary" />
+                  </div>
+                  <Card className="bg-card px-4 py-3">
+                    <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+                  </Card>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        {/* Input area */}
+        <div className="shrink-0 border-t bg-background p-4">
+          <div className="mx-auto flex max-w-5xl items-end gap-2">
+            <Textarea
+              ref={textareaRef}
+              value={input}
+              onChange={(e) => setInput(e.target.value)}
+              onKeyDown={handleKeyDown}
+              placeholder={t("placeholder")}
+              rows={1}
+              className="min-h-[44px] max-h-32 resize-none"
+              disabled={loading}
+            />
+            <Button
+              type="button"
+              size="icon"
+              className="shrink-0"
+              onClick={() => sendMessage(input)}
+              disabled={loading || !input.trim()}
+            >
+              {loading ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                <Send className="h-4 w-4" />
+              )}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 308 - 0
src/features/ai/Components/AiSettingsForm.tsx

@@ -0,0 +1,308 @@
+"use client";
+
+import { useState, useTransition, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "@/components/ui/card";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Info, Loader2, Sparkles, Zap } from "lucide-react";
+import {
+  AI_KEYS,
+  AI_PROVIDERS,
+  OPENAI_FALLBACK_MODELS,
+  ANTHROPIC_FALLBACK_MODELS,
+  type AiProvider,
+  type AiModel,
+} from "../Schema/aiSettingsSchema";
+import { setAiSettings, fetchAiModels } from "../Actions/aiSettingsActions";
+import { aiTestConnection } from "../Actions/aiActions";
+import {
+  ReadOnlyBanner,
+  SaveButton,
+  ReadOnlyWrapper,
+} from "@/app/(authenticated)/settings/read-only-guard";
+
+const COST_COLORS: Record<AiModel["cost"], string> = {
+  high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
+  medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
+  low: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
+};
+
+export function AiSettingsForm({
+  initial,
+}: {
+  initial: Record<string, string>;
+}) {
+  const t = useTranslations("settings");
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+  const [isTesting, setIsTesting] = useState(false);
+
+  const hasConfig = !!initial[AI_KEYS.AI_API_KEY];
+  const [enabled, setEnabled] = useState(
+    initial[AI_KEYS.AI_ENABLED] === "true" || false,
+  );
+  const [provider, setProvider] = useState<AiProvider>(
+    (initial[AI_KEYS.AI_PROVIDER] as AiProvider) || "openai",
+  );
+  const [apiKey, setApiKey] = useState(initial[AI_KEYS.AI_API_KEY] || "");
+  const [model, setModel] = useState(
+    initial[AI_KEYS.AI_MODEL] || "gpt-4o-mini",
+  );
+
+  const [models, setModels] = useState<AiModel[]>(
+    provider === "anthropic" ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS,
+  );
+  const [fetchingModels, setFetchingModels] = useState(false);
+
+  const loadModels = useCallback(async (p: AiProvider) => {
+    setFetchingModels(true);
+    try {
+      const result = await fetchAiModels(p);
+      if (result.success && result.data && result.data.length > 0) {
+        setModels(result.data);
+      } else {
+        setModels(p === "anthropic" ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS);
+      }
+    } catch {
+      setModels(p === "anthropic" ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS);
+    } finally {
+      setFetchingModels(false);
+    }
+  }, []);
+
+  // Fetch models on mount if API key is configured (hasConfig means key exists in DB)
+  useEffect(() => {
+    if (hasConfig) {
+      loadModels(provider);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const handleProviderChange = (value: string) => {
+    const p = value as AiProvider;
+    setProvider(p);
+    const fallback = p === "anthropic" ? ANTHROPIC_FALLBACK_MODELS : OPENAI_FALLBACK_MODELS;
+    setModels(fallback);
+    setModel(fallback[0].id);
+    if (hasConfig) {
+      loadModels(p);
+    }
+  };
+
+  const handleApiKeyBlur = () => {
+    // After saving a new key, models can be refreshed
+    // But we don't send the key to the server from here — it reads from DB
+  };
+
+  const handleSave = () => {
+    startTransition(async () => {
+      const data: Record<string, string> = {
+        [AI_KEYS.AI_ENABLED]: enabled ? "true" : "false",
+        [AI_KEYS.AI_PROVIDER]: provider,
+        [AI_KEYS.AI_API_KEY]: apiKey,
+        [AI_KEYS.AI_MODEL]: model,
+      };
+
+      const result = await setAiSettings(data);
+      if (result.success) {
+        toast.success(t("ai.saved"));
+        router.refresh();
+      } else {
+        toast.error(result.error ?? t("ai.failedSave"));
+      }
+    });
+  };
+
+  const handleTestConnection = async () => {
+    setIsTesting(true);
+    try {
+      const result = await aiTestConnection();
+      if (result.success && result.data) {
+        toast.success(t("ai.testSuccess"));
+      } else {
+        toast.error(result.error ?? t("ai.testFailed"));
+      }
+    } catch (err) {
+      toast.error(
+        err instanceof Error ? err.message : t("ai.testFailed"),
+      );
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  const canTest = enabled && apiKey && model;
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <ReadOnlyWrapper>
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <Sparkles className="h-5 w-5" />
+              {t("ai.title")}
+            </CardTitle>
+            <CardDescription>{t("ai.description")}</CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-6">
+            <div className="flex items-center justify-between">
+              <div className="space-y-0.5">
+                <Label htmlFor="enable-ai">{t("ai.enableLabel")}</Label>
+                <p className="text-xs text-muted-foreground">
+                  {t("ai.enableHint")}
+                </p>
+              </div>
+              <Switch
+                id="enable-ai"
+                checked={enabled}
+                onCheckedChange={setEnabled}
+              />
+            </div>
+
+            {!enabled && (
+              <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
+                <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                <p className="text-sm text-muted-foreground">
+                  {t("ai.disabledInfo")}
+                </p>
+              </div>
+            )}
+
+            {enabled && (
+              <>
+                {/* Provider selection */}
+                <div className="space-y-2">
+                  <Label>{t("ai.provider")}</Label>
+                  <div className="flex flex-wrap gap-2">
+                    {AI_PROVIDERS.map((p) => (
+                      <Button
+                        key={p}
+                        type="button"
+                        variant={provider === p ? "default" : "outline"}
+                        onClick={() => handleProviderChange(p)}
+                        className="flex-1"
+                      >
+                        {p === "openai" ? "OpenAI" : "Anthropic"}
+                      </Button>
+                    ))}
+                  </div>
+                </div>
+
+                {/* API Key */}
+                <div className="space-y-2">
+                  <Label htmlFor="ai-api-key">{t("ai.apiKey")}</Label>
+                  <Input
+                    id="ai-api-key"
+                    type="password"
+                    placeholder={
+                      provider === "openai"
+                        ? "sk-••••••••••••••••••••••••••••••••"
+                        : "sk-ant-••••••••••••••••••••••••••"
+                    }
+                    value={apiKey}
+                    onChange={(e) => setApiKey(e.target.value)}
+                    onBlur={handleApiKeyBlur}
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    {t("ai.apiKeyHint", {
+                      provider: provider === "openai" ? "OpenAI" : "Anthropic",
+                    })}
+                  </p>
+                </div>
+
+                {/* Model selection */}
+                <div className="space-y-2">
+                  <div className="flex items-center gap-2">
+                    <Label htmlFor="ai-model">{t("ai.model")}</Label>
+                    {fetchingModels && (
+                      <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
+                    )}
+                  </div>
+                  <Select value={model} onValueChange={setModel}>
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {models.map((m) => (
+                        <SelectItem key={m.id} value={m.id}>
+                          <span className="flex items-center gap-2">
+                            {m.label}
+                            <Badge
+                              variant="outline"
+                              className={`ml-1 text-[10px] px-1.5 py-0 font-medium ${COST_COLORS[m.cost]}`}
+                            >
+                              {t(`ai.cost.${m.cost}`)}
+                            </Badge>
+                          </span>
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                {/* Test connection */}
+                {hasConfig && (
+                  <div className="pt-2">
+                    <Button
+                      type="button"
+                      variant="outline"
+                      onClick={handleTestConnection}
+                      disabled={!canTest || isTesting}
+                    >
+                      {isTesting ? (
+                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                      ) : (
+                        <Zap className="mr-2 h-4 w-4" />
+                      )}
+                      {t("ai.testConnection")}
+                    </Button>
+                  </div>
+                )}
+
+                {/* Features info */}
+                <div className="rounded-lg border bg-muted/30 p-4 space-y-2">
+                  <p className="text-sm font-medium">{t("ai.featuresTitle")}</p>
+                  <ul className="text-sm text-muted-foreground space-y-1">
+                    <li>• {t("ai.featureServiceNotes")}</li>
+                    <li>• {t("ai.featureHistorySummary")}</li>
+                  </ul>
+                </div>
+              </>
+            )}
+          </CardContent>
+        </Card>
+
+        <SaveButton>
+          <div className="flex justify-end">
+            <Button onClick={handleSave} disabled={isPending}>
+              {isPending && (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              )}
+              {t("ai.saveSettings")}
+            </Button>
+          </div>
+        </SaveButton>
+      </ReadOnlyWrapper>
+    </div>
+  );
+}

+ 64 - 0
src/features/ai/Schema/aiSettingsSchema.ts

@@ -0,0 +1,64 @@
+export const AI_KEYS = {
+  AI_PROVIDER: "ai.provider",
+  AI_API_KEY: "ai.apiKey",
+  AI_MODEL: "ai.model",
+  AI_ENABLED: "ai.enabled",
+} as const;
+
+export type AiKey = (typeof AI_KEYS)[keyof typeof AI_KEYS];
+
+export const ALL_AI_KEYS = Object.values(AI_KEYS);
+
+export const AI_PROVIDERS = ["openai", "anthropic"] as const;
+export type AiProvider = (typeof AI_PROVIDERS)[number];
+
+export interface AiModel {
+  id: string;
+  label: string;
+  cost: "high" | "medium" | "low";
+}
+
+// Fallback models when API fetch fails (ordered by version, newest first)
+export const OPENAI_FALLBACK_MODELS: AiModel[] = [
+  { id: "gpt-4.1", label: "GPT-4.1", cost: "medium" },
+  { id: "gpt-4.1-mini", label: "GPT-4.1 Mini", cost: "low" },
+  { id: "gpt-4.1-nano", label: "GPT-4.1 Nano", cost: "low" },
+  { id: "gpt-4o", label: "GPT-4o", cost: "medium" },
+  { id: "gpt-4o-mini", label: "GPT-4o Mini", cost: "low" },
+];
+
+export const ANTHROPIC_FALLBACK_MODELS: AiModel[] = [
+  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", cost: "medium" },
+  { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", cost: "low" },
+];
+
+/** Classify a model ID into a cost tier */
+export function getModelCost(provider: AiProvider, modelId: string): AiModel["cost"] {
+  const id = modelId.toLowerCase();
+
+  if (provider === "anthropic") {
+    if (id.includes("opus")) return "high";
+    if (id.includes("sonnet")) return "medium";
+    if (id.includes("haiku")) return "low";
+    return "medium";
+  }
+
+  // OpenAI
+  if (id.includes("o1") || id.includes("o3") || id.includes("o4-mini")) return "high";
+  if (id.includes("nano")) return "low";
+  if (id.includes("mini")) return "low";
+  if (id.startsWith("gpt-4") || id.startsWith("gpt-4.")) return "medium";
+  return "medium";
+}
+
+/** Build a human-readable label from a model ID */
+export function formatModelLabel(modelId: string): string {
+  return modelId
+    .replace(/-(\d{8})$/, "") // strip date suffix like -20251001
+    .replace(/^gpt-/i, "GPT-")
+    .replace(/^claude-/i, "Claude ")
+    .replace(/^o(\d)/i, "o$1")
+    .replace(/-/g, " ")
+    .replace(/\b\w/g, (c) => c.toUpperCase())
+    .replace(/Gpt/g, "GPT");
+}

+ 4 - 0
src/features/ai/constants.ts

@@ -0,0 +1,4 @@
+export const AI_MESSAGE_TYPES = {
+  SUMMARY: "summary",
+  COMMON_ISSUES: "common_issues",
+} as const;

+ 401 - 0
src/features/ai/tools/workshop-tools.ts

@@ -0,0 +1,401 @@
+import "server-only";
+import { db } from "@/lib/db";
+import type OpenAI from "openai";
+import { Prisma } from "@prisma/client";
+
+const MAX_ROWS = 100;
+
+/**
+ * Single tool: AI generates a SQL query, we validate and execute it.
+ */
+export const workshopTools: OpenAI.ChatCompletionTool[] = [
+  {
+    type: "function",
+    function: {
+      name: "run_sql_query",
+      description:
+        "Execute a read-only SQL SELECT query against the workshop database. The query MUST be a SELECT statement. You do NOT need to filter by organization — that is enforced automatically. Always use the actual PostgreSQL table names (snake_case with @@map names). Limit results to 100 rows max.",
+      parameters: {
+        type: "object",
+        properties: {
+          sql: {
+            type: "string",
+            description: "A PostgreSQL SELECT query. Must start with SELECT.",
+          },
+        },
+        required: ["sql"],
+      },
+    },
+  },
+];
+
+// ─── Blocked keywords (mutation / DDL / admin) ─────────────────────────────
+
+const BLOCKED_KEYWORDS = [
+  "INSERT",
+  "UPDATE",
+  "DELETE",
+  "DROP",
+  "ALTER",
+  "TRUNCATE",
+  "CREATE",
+  "REPLACE",
+  "UPSERT",
+  "MERGE",
+  "GRANT",
+  "REVOKE",
+  "EXEC",
+  "EXECUTE",
+  "CALL",
+  "COPY",
+  "VACUUM",
+  "REINDEX",
+  "CLUSTER",
+  "COMMENT",
+  "LOCK",
+  "NOTIFY",
+  "LISTEN",
+  "PREPARE",
+  "DEALLOCATE",
+  "INTO",
+  "SET",
+  "RESET",
+  "DISCARD",
+  "LOAD",
+  "SECURITY",
+  "REASSIGN",
+  "REFRESH",
+] as const;
+
+// Tables the AI is allowed to query (whitelist approach — everything else is blocked)
+const ALLOWED_TABLES = [
+  "vehicles",
+  "service_records",
+  "service_parts",
+  "service_labor",
+  "service_attachments",
+  "payments",
+  "customers",
+  "reminders",
+  "quotes",
+  "quote_parts",
+  "quote_labor",
+  "quote_attachments",
+  "inventory_parts",
+  "notes",
+  "fuel_logs",
+  "technicians",
+  "inspections",
+  "inspection_items",
+  "recurring_invoices",
+  "recurring_parts",
+  "recurring_labor",
+  "notifications",
+  "sms_messages",
+  "inspection_quote_requests",
+] as const;
+
+// ─── Validation ─────────────────────────────────────────────────────────────
+
+export function validateSql(sql: string): { valid: boolean; error?: string } {
+  const trimmed = sql.trim();
+
+  // Must start with SELECT (case-insensitive)
+  if (!/^SELECT\b/i.test(trimmed)) {
+    return { valid: false, error: "Only SELECT queries are allowed." };
+  }
+
+  // Must not contain semicolons (prevent multi-statement injection)
+  if (trimmed.includes(";")) {
+    return { valid: false, error: "Multiple statements are not allowed." };
+  }
+
+  // Block SQL comments
+  if (/--/.test(trimmed)) {
+    return { valid: false, error: "SQL comments are not allowed." };
+  }
+  if (/\/\*/.test(trimmed)) {
+    return { valid: false, error: "SQL block comments are not allowed." };
+  }
+
+  // Block ANY schema-qualified table references (schema.table or "schema"."table")
+  // This catches public.vehicles, pg_temp.vehicles, myschema.anything, etc.
+  if (/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\.\s*"?[a-zA-Z_]/i.test(trimmed)) {
+    // Allow column references like t.id or vehicles.id (single letter or allowed table aliases)
+    // but block schema references by checking if left side looks like a schema name
+    const schemaPattern = /\b(public|pg_temp|pg_catalog|information_schema)\s*\./i;
+    if (schemaPattern.test(trimmed)) {
+      return { valid: false, error: "Schema-qualified table names are not allowed." };
+    }
+    // Also block "quoted"."qualified" patterns
+    if (/"[^"]+"\s*\.\s*"[^"]+"/.test(trimmed)) {
+      return { valid: false, error: "Schema-qualified table names are not allowed." };
+    }
+  }
+
+  // Block information_schema / pg_catalog / pg_temp access
+  if (/\b(information_schema|pg_catalog|pg_temp|pg_tables|pg_views|pg_roles|pg_user)\b/i.test(trimmed)) {
+    return { valid: false, error: "System catalog access is not allowed." };
+  }
+
+  // Block Postgres function calls that could leak data or cause side effects
+  if (/\b(pg_read_file|pg_ls_dir|lo_import|lo_export|dblink|query_to_xml|current_setting|set_config)\b/i.test(trimmed)) {
+    return { valid: false, error: "Unsafe function call is not allowed." };
+  }
+
+  // Normalize for keyword checking: collapse whitespace, uppercase
+  const normalized = trimmed.replace(/\s+/g, " ").toUpperCase();
+
+  // Check for blocked mutation/DDL keywords
+  for (const keyword of BLOCKED_KEYWORDS) {
+    const regex = new RegExp(`\\b${keyword}\\b`);
+    if (regex.test(normalized)) {
+      return { valid: false, error: `Forbidden keyword: ${keyword}` };
+    }
+  }
+
+  // Whitelist: extract all table-like identifiers from FROM / JOIN clauses
+  // and verify they're in the allowed list
+  const tableRefPattern = /\b(?:FROM|JOIN)\s+"?([a-z_][a-z0-9_]*)"?/gi;
+  let match;
+  while ((match = tableRefPattern.exec(trimmed)) !== null) {
+    const tableName = match[1].toLowerCase();
+    if (!ALLOWED_TABLES.includes(tableName as (typeof ALLOWED_TABLES)[number])) {
+      return { valid: false, error: `Access to table "${tableName}" is not allowed.` };
+    }
+  }
+
+  return { valid: true };
+}
+
+// ─── Org-scoped execution ───────────────────────────────────────────────────
+
+/**
+ * Executes the AI query inside a READ ONLY transaction with temporary views
+ * that enforce organization isolation at the database level.
+ *
+ * Security layers:
+ * 1. Transaction is SET TRANSACTION READ ONLY — cannot mutate anything
+ * 2. Temporary views shadow every allowed table name, pre-filtered by orgId
+ * 3. Child tables (service_records, payments, etc.) are scoped via JOIN
+ *    to their org-scoped parent — no direct organizationId needed
+ * 4. The orgId is passed as a parameterized value, not string-interpolated
+ * 5. search_path is set to pg_temp first, so unqualified table names
+ *    always resolve to our temporary views, never the real tables
+ */
+async function executeOrgScopedQuery(sql: string, orgId: string): Promise<unknown[]> {
+  // Validate orgId is a safe CUID (alphanumeric only) — defense-in-depth
+  if (!/^[a-zA-Z0-9_-]+$/.test(orgId)) {
+    throw new Error("Invalid organization ID");
+  }
+
+  return db.$transaction(async (tx) => {
+    // 1. Set search_path so pg_temp (temp views) takes priority over public
+    await tx.$executeRawUnsafe("SET LOCAL search_path TO pg_temp, public");
+
+    // 2. Create temp views BEFORE setting read-only (CREATE VIEW requires write)
+    //    These views are the security boundary — they enforce org isolation.
+    const orgDirectTables = [
+      "vehicles",
+      "customers",
+      "quotes",
+      "inventory_parts",
+      "notifications",
+      "sms_messages",
+      "technicians",
+      "inspections",
+      "inspection_quote_requests",
+    ];
+
+    for (const table of orgDirectTables) {
+      await tx.$executeRaw`${Prisma.raw(
+        `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT * FROM "public"."${table}" WHERE "organizationId" = '${orgId}'`
+      )}`;
+    }
+
+    // 4. Create temp views for child tables scoped through their parent
+    //    These tables don't have organizationId directly, so we JOIN to
+    //    the already-scoped parent view.
+
+    // Children of vehicles (via vehicleId → vehicles.id)
+    const vehicleChildren = [
+      "service_records",
+      "notes",
+      "fuel_logs",
+      "reminders",
+      "recurring_invoices",
+    ];
+    for (const table of vehicleChildren) {
+      await tx.$executeRaw`${Prisma.raw(
+        `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "vehicles" v ON t."vehicleId" = v.id`
+      )}`;
+    }
+
+    // Children of service_records (via serviceRecordId → service_records.id)
+    const serviceChildren = [
+      "service_parts",
+      "service_labor",
+      "service_attachments",
+      "payments",
+    ];
+    for (const table of serviceChildren) {
+      await tx.$executeRaw`${Prisma.raw(
+        `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "service_records" sr ON t."serviceRecordId" = sr.id`
+      )}`;
+    }
+
+    // Children of quotes (via quoteId → quotes.id)
+    const quoteChildren = ["quote_parts", "quote_labor", "quote_attachments"];
+    for (const table of quoteChildren) {
+      await tx.$executeRaw`${Prisma.raw(
+        `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "quotes" q ON t."quoteId" = q.id`
+      )}`;
+    }
+
+    // Children of recurring_invoices
+    const recurringChildren = ["recurring_parts", "recurring_labor"];
+    for (const table of recurringChildren) {
+      await tx.$executeRaw`${Prisma.raw(
+        `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "recurring_invoices" ri ON t."recurringInvoiceId" = ri.id`
+      )}`;
+    }
+
+    // Children of inspections (via inspectionId → inspections.id)
+    await tx.$executeRaw`${Prisma.raw(
+      `CREATE OR REPLACE TEMPORARY VIEW "inspection_items" AS SELECT t.* FROM "public"."inspection_items" t INNER JOIN "inspections" i ON t."inspectionId" = i.id`
+    )}`;
+
+    // 5. NOW set transaction read-only — the AI query cannot mutate anything
+    await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY");
+
+    // 6. Execute the AI's query against the temp views
+    const rows = await tx.$queryRawUnsafe(sql);
+    return Array.isArray(rows) ? rows.slice(0, MAX_ROWS) : [];
+  });
+}
+
+// ─── Tool execution ─────────────────────────────────────────────────────────
+
+export async function executeTool(
+  name: string,
+  args: Record<string, unknown>,
+  organizationId: string,
+): Promise<string> {
+  if (name !== "run_sql_query") {
+    return JSON.stringify({ error: `Unknown tool: ${name}` });
+  }
+
+  const sql = (args.sql as string) || "";
+
+  // Validate
+  const validation = validateSql(sql);
+  if (!validation.valid) {
+    return JSON.stringify({ error: validation.error });
+  }
+
+  try {
+    // Add LIMIT if missing
+    const hasLimit = /\bLIMIT\b/i.test(sql);
+    const limitedSql = hasLimit ? sql : `${sql} LIMIT ${MAX_ROWS}`;
+
+    const rows = await executeOrgScopedQuery(limitedSql, organizationId);
+
+    return JSON.stringify(rows, (_key, value) =>
+      typeof value === "bigint" ? Number(value) : value,
+    );
+  } catch (err) {
+    return JSON.stringify({
+      error: err instanceof Error ? err.message : "Query execution failed",
+    });
+  }
+}
+
+// ─── Schema description for the AI system prompt ────────────────────────────
+
+export const DB_SCHEMA = `
+PostgreSQL database schema (use these exact table/column names in queries):
+
+vehicles (id, make, model, year, vin, "licensePlate", color, mileage, "fuelType", transmission, "engineSize", "purchaseDate", "purchasePrice", "isArchived", "createdAt", "updatedAt", "customerId")
+
+service_records (id, title, description, type, status, cost, mileage, "serviceDate", "startDateTime", "endDateTime", "shopName", "techName", parts, "laborHours", "diagnosticNotes", "invoiceNotes", subtotal, "taxRate", "taxAmount", "totalAmount", "invoiceNumber", "discountType", "discountValue", "discountAmount", "manuallyPaid", "createdAt", "updatedAt", "vehicleId", "technicianId", "sortOrder")
+
+service_parts (id, "partNumber", name, quantity, "unitPrice", total, "serviceRecordId")
+
+service_labor (id, description, hours, rate, total, "serviceRecordId")
+
+payments (id, amount, date, method, note, provider, "externalId", "createdAt", "updatedAt", "serviceRecordId")
+
+customers (id, name, email, phone, address, company, notes, "createdAt", "updatedAt")
+
+reminders (id, title, description, "dueDate", "dueMileage", "isCompleted", "createdAt", "updatedAt", "vehicleId")
+
+quotes (id, "quoteNumber", title, description, status, "validUntil", subtotal, "taxRate", "taxAmount", "discountType", "discountValue", "discountAmount", "totalAmount", notes, "customerMessage", "convertedToId", "createdAt", "updatedAt", "customerId", "vehicleId", "inspectionId")
+
+quote_parts (id, "partNumber", name, quantity, "unitPrice", total, excluded, "quoteId")
+
+quote_labor (id, description, hours, rate, total, excluded, "quoteId")
+
+inventory_parts (id, "partNumber", name, description, category, quantity, "minQuantity", "unitCost", "sellPrice", supplier, "supplierPhone", "supplierEmail", "supplierUrl", location, "isArchived", "createdAt", "updatedAt")
+
+notes (id, title, content, "isPinned", "createdAt", "updatedAt", "vehicleId")
+
+fuel_logs (id, date, mileage, gallons, "pricePerGallon", "totalCost", "isFillUp", station, notes, "createdAt", "updatedAt", "vehicleId")
+
+technicians (id, name, color, "isActive", "sortOrder", "dailyCapacity", "createdAt", "updatedAt")
+
+inspections (id, status, mileage, notes, "startDateTime", "endDateTime", "completedAt", "createdAt", "updatedAt", "vehicleId", "templateId", "technicianId", "sortOrder")
+
+inspection_items (id, name, section, "sortOrder", condition, notes, "imageUrls", "inspectionId")
+
+recurring_invoices (id, title, description, frequency, "nextRunDate", "endDate", "isActive", "lastRunAt", "runCount", type, cost, "taxRate", "invoiceNotes", "vehicleId", "createdAt", "updatedAt")
+
+notifications (id, type, title, message, "entityType", "entityId", read, "createdAt")
+
+sms_messages (id, direction, "fromNumber", "toNumber", body, status, "createdAt", "updatedAt", "customerId")
+
+service_attachments (id, "fileName", "fileUrl", "fileType", "fileSize", category, description, "includeInInvoice", "createdAt", "serviceRecordId")
+
+Key relationships:
+- vehicles."customerId" → customers.id
+- service_records."vehicleId" → vehicles.id
+- service_parts."serviceRecordId" → service_records.id
+- service_labor."serviceRecordId" → service_records.id
+- payments."serviceRecordId" → service_records.id
+- reminders."vehicleId" → vehicles.id
+- quotes."customerId" → customers.id
+- quotes."vehicleId" → vehicles.id
+- quote_parts."quoteId" → quotes.id
+- quote_labor."quoteId" → quotes.id
+- inspections."vehicleId" → vehicles.id
+- inspections."technicianId" → technicians.id
+
+IMPORTANT business rules:
+- A service record is considered PAID if "manuallyPaid" = true OR the sum of payments.amount for that service >= the effective total (use "totalAmount" if > 0, otherwise use cost).
+- To find UNPAID invoices, use this pattern:
+  SELECT sr.*, COALESCE(p.paid, 0) AS paid_amount
+  FROM service_records sr
+  LEFT JOIN (SELECT "serviceRecordId", SUM(amount) AS paid FROM payments GROUP BY "serviceRecordId") p ON p."serviceRecordId" = sr.id
+  WHERE sr."manuallyPaid" = false
+    AND (CASE WHEN sr."totalAmount" > 0 THEN sr."totalAmount" ELSE sr.cost END) > 0
+    AND COALESCE(p.paid, 0) < (CASE WHEN sr."totalAmount" > 0 THEN sr."totalAmount" ELSE sr.cost END)
+- Quote statuses: draft, sent, accepted, rejected, expired, converted
+- Service record statuses: pending, in_progress, waiting_parts, completed
+- Service record types: maintenance, repair, upgrade, inspection
+
+Note: camelCase columns MUST be double-quoted in SQL (e.g. "licensePlate", "totalAmount").
+Organization filtering is applied automatically — do NOT add WHERE "organizationId" = ... yourself.
+
+IMPORTANT: Linking to pages
+Always include id columns in your queries — for the primary entity AND any related entities (vehicle, customer, etc.). When displaying results, add markdown links for ALL entities present in the row. Use these URL patterns:
+- Vehicle: [vehicle name](/vehicles/{vehicle.id})
+- Service record: [title](/vehicles/{vehicle.id}/service/{service_record.id})
+- Customer: [name](/customers/{customer.id})
+- Quote: [title](/quotes/{quote.id})
+- Inventory part: [name](/inventory)
+- Reminder: [title](/reminders)
+- Inspection: [template name](/inspections/{inspection.id})
+
+When a query involves JOINs, make every related entity clickable. For example, when showing invoices with vehicle and customer info:
+| [Oil Change](/vehicles/abc123/service/def456) | [Toyota Corolla](/vehicles/abc123) | [John Smith](/customers/cust789) | $150 |
+Always JOIN to vehicles and customers when relevant so you can include their ids and link to them.
+`.trim();

+ 94 - 0
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -0,0 +1,94 @@
+"use client";
+
+import { memo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Plus, Trash2 } from "lucide-react";
+import { formatCurrency } from "@/lib/format";
+import type { QuoteLaborInput } from "./quote-page-types";
+
+const QuoteLaborRow = memo(function QuoteLaborRow({
+  labor,
+  index,
+  currencyCode,
+  onUpdate,
+  onDelete,
+  tDescriptionPlaceholder,
+}: {
+  labor: QuoteLaborInput;
+  index: number;
+  currencyCode: string;
+  onUpdate: (index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => void;
+  onDelete: (index: number) => void;
+  tDescriptionPlaceholder: string;
+}) {
+  return (
+    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
+      <Input placeholder={tDescriptionPlaceholder} value={labor.description} onChange={(e) => onUpdate(index, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
+      <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => onUpdate(index, "hours", e.target.value)} />
+      <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => onUpdate(index, "rate", e.target.value)} />
+      <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
+      <div className="flex items-center gap-1">
+        <input type="checkbox" checked={labor.excluded ?? false} onChange={(e) => onUpdate(index, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+        <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => onDelete(index)}><Trash2 className="h-4 w-4" /></Button>
+      </div>
+    </div>
+  );
+});
+
+interface QuoteLaborEditorProps {
+  laborItems: QuoteLaborInput[];
+  currencyCode: string;
+  cs: string;
+  laborSubtotal: number;
+  onUpdate: (index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => void;
+  onDelete: (index: number) => void;
+  onAdd: () => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  t: (key: string, values?: any) => string;
+}
+
+export const QuoteLaborEditor = memo(function QuoteLaborEditor({
+  laborItems,
+  currencyCode,
+  cs,
+  laborSubtotal,
+  onUpdate,
+  onDelete,
+  onAdd,
+  t,
+}: QuoteLaborEditorProps) {
+  return (
+    <div className="rounded-lg border p-3 space-y-2">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
+        <Button type="button" variant="outline" size="sm" onClick={onAdd}><Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}</Button>
+      </div>
+      {laborItems.length > 0 && (
+        <>
+          <div className="hidden grid-cols-[2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
+          </div>
+          {laborItems.map((labor, i) => (
+            <QuoteLaborRow
+              key={i}
+              labor={labor}
+              index={i}
+              currencyCode={currencyCode}
+              onUpdate={onUpdate}
+              onDelete={onDelete}
+              tDescriptionPlaceholder={t("labor.descriptionPlaceholder")}
+            />
+          ))}
+          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={onAdd}><Plus className="h-4 w-4" /></button>
+          <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("labor.subtotal", { amount: formatCurrency(laborSubtotal, currencyCode) })}</span></div>
+        </>
+      )}
+      {laborItems.length === 0 && (
+        <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={onAdd}>
+          <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("labor.addLabor")}</span>
+        </button>
+      )}
+    </div>
+  );
+});

+ 59 - 0
src/features/quotes/Components/QuoteNotesEditor.tsx

@@ -0,0 +1,59 @@
+"use client";
+
+import { memo } from "react";
+import { FileText } from "lucide-react";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { RichTextEditor } from "@/features/vehicles/Components/service-edit/RichTextEditor";
+
+interface QuoteNotesEditorProps {
+  noteType: "public" | "internal";
+  onNoteTypeChange: (v: "public" | "internal") => void;
+  description: string;
+  onDescriptionChange: (v: string) => void;
+  notes: string;
+  onNotesChange: (v: string) => void;
+  t: (key: string) => string;
+}
+
+export const QuoteNotesEditor = memo(function QuoteNotesEditor({
+  noteType,
+  onNoteTypeChange,
+  description,
+  onDescriptionChange,
+  notes,
+  onNotesChange,
+  t,
+}: QuoteNotesEditorProps) {
+  return (
+    <div className="rounded-lg border p-3 space-y-3">
+      <div className="flex items-center justify-between">
+        <h3 className="flex items-center gap-2 text-sm font-semibold"><FileText className="h-3.5 w-3.5" />{t("notes.title")}</h3>
+        <Select value={noteType} onValueChange={(v) => onNoteTypeChange(v as "public" | "internal")}>
+          <SelectTrigger className="h-7 w-[120px] text-xs"><SelectValue /></SelectTrigger>
+          <SelectContent>
+            <SelectItem value="public">{t("notes.public")}</SelectItem>
+            <SelectItem value="internal">{t("notes.internal")}</SelectItem>
+          </SelectContent>
+        </Select>
+      </div>
+      {noteType === "public" && (
+        <div className="space-y-1">
+          <RichTextEditor content={description} onChange={onDescriptionChange} placeholder={t("notes.publicPlaceholder")} />
+          <p className="text-xs text-muted-foreground">{t("notes.publicHelper")}</p>
+        </div>
+      )}
+      {noteType === "internal" && (
+        <div className="space-y-1">
+          <RichTextEditor content={notes} onChange={onNotesChange} placeholder={t("notes.internalPlaceholder")} />
+          <p className="text-xs text-muted-foreground">{t("notes.internalHelper")}</p>
+        </div>
+      )}
+    </div>
+  );
+});

Разница между файлами не показана из-за своего большого размера
+ 201 - 724
src/features/quotes/Components/QuotePageClient.tsx


+ 100 - 0
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -0,0 +1,100 @@
+"use client";
+
+import { memo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Plus, Trash2 } from "lucide-react";
+import { formatCurrency } from "@/lib/format";
+import type { QuotePartInput } from "./quote-page-types";
+
+const QuotePartRow = memo(function QuotePartRow({
+  part,
+  index,
+  currencyCode,
+  onUpdate,
+  onDelete,
+  tPartNumber,
+  tNamePlaceholder,
+}: {
+  part: QuotePartInput;
+  index: number;
+  currencyCode: string;
+  onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void;
+  onDelete: (index: number) => void;
+  tPartNumber: string;
+  tNamePlaceholder: string;
+}) {
+  return (
+    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
+      <Input placeholder={tPartNumber} value={part.partNumber ?? ""} onChange={(e) => onUpdate(index, "partNumber", e.target.value)} />
+      <Input placeholder={tNamePlaceholder} value={part.name} onChange={(e) => onUpdate(index, "name", e.target.value)} />
+      <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => onUpdate(index, "quantity", e.target.value)} />
+      <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => onUpdate(index, "unitPrice", e.target.value)} />
+      <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
+      <div className="flex items-center gap-1">
+        <input type="checkbox" checked={part.excluded ?? false} onChange={(e) => onUpdate(index, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
+        <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => onDelete(index)}><Trash2 className="h-4 w-4" /></Button>
+      </div>
+    </div>
+  );
+});
+
+interface QuotePartsEditorProps {
+  partItems: QuotePartInput[];
+  currencyCode: string;
+  partsSubtotal: number;
+  onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void;
+  onDelete: (index: number) => void;
+  onAdd: () => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  t: (key: string, values?: any) => string;
+}
+
+export const QuotePartsEditor = memo(function QuotePartsEditor({
+  partItems,
+  currencyCode,
+  partsSubtotal,
+  onUpdate,
+  onDelete,
+  onAdd,
+  t,
+}: QuotePartsEditorProps) {
+  return (
+    <div className="rounded-lg border p-3 space-y-2">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">{t("parts.title")}</h3>
+        <div className="flex items-center gap-2">
+          <Button type="button" variant="outline" size="sm" onClick={onAdd}>
+            <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
+          </Button>
+        </div>
+      </div>
+      {partItems.length > 0 && (
+        <>
+          <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
+          </div>
+          {partItems.map((part, i) => (
+            <QuotePartRow
+              key={i}
+              part={part}
+              index={i}
+              currencyCode={currencyCode}
+              onUpdate={onUpdate}
+              onDelete={onDelete}
+              tPartNumber={t("parts.partNumber")}
+              tNamePlaceholder={t("parts.namePlaceholder")}
+            />
+          ))}
+          <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={onAdd}><Plus className="h-4 w-4" /></button>
+          <div className="flex justify-end pt-1 text-sm"><span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span></div>
+        </>
+      )}
+      {partItems.length === 0 && (
+        <button type="button" className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground" onClick={onAdd}>
+          <Plus className="mr-1 h-4 w-4" /><span className="text-sm">{t("parts.addPart")}</span>
+        </button>
+      )}
+    </div>
+  );
+});

+ 400 - 0
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -0,0 +1,400 @@
+'use client'
+
+import { memo } from 'react'
+import Link from 'next/link'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import {
+  ArrowRight,
+  Car,
+  Check,
+  ClipboardCheck,
+  Loader2,
+  MessageSquare,
+  Users,
+  X,
+} from 'lucide-react'
+import { SharedLinkCard } from '@/components/shared-link-card'
+import { formatCurrency } from '@/lib/format'
+import type { QuoteFormState } from './useQuoteFormState'
+import type { QuoteRecord } from './quote-page-types'
+import { VehicleCombobox } from './VehicleCombobox'
+import { CustomerCombobox } from './CustomerCombobox'
+
+interface QuoteRightColumnProps {
+  state: QuoteFormState
+  quote: QuoteRecord
+  organizationId: string
+  currencyCode: string
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  t: (key: string, values?: any) => string
+  onRevoke: () => Promise<void>
+}
+
+export const QuoteRightColumn = memo(function QuoteRightColumn({
+  state,
+  quote,
+  organizationId,
+  currencyCode,
+  t,
+  onRevoke,
+}: QuoteRightColumnProps) {
+  return (
+    <div className="space-y-3">
+      {/* Convert to Work Order */}
+      {quote.status !== 'converted' && (
+        <div className="rounded-lg border p-3">
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            className="w-full"
+            onClick={() => state.setShowConvertDialog(true)}
+          >
+            <ArrowRight className="mr-1 h-3.5 w-3.5" /> {t('page.convertToWorkOrder')}
+          </Button>
+        </div>
+      )}
+
+      {/* Shared Link */}
+      {quote.publicToken && (
+        <SharedLinkCard
+          publicToken={quote.publicToken}
+          organizationId={organizationId}
+          type="quote"
+          sharedAt={quote.sharedAt}
+          viewCount={quote.viewCount}
+          lastViewedAt={quote.lastViewedAt}
+          onRevoke={onRevoke}
+        />
+      )}
+
+      {/* Vehicle & Customer */}
+      <div className="rounded-lg border p-3 space-y-3">
+        <div className="space-y-1">
+          <Label className="text-xs">{t('details.vehicle')}</Label>
+          <VehicleCombobox
+            value={state.vehicleId}
+            initialVehicle={state.selectedVehicle}
+            placeholder={t('details.selectVehicle')}
+            noneLabel={t('details.none')}
+            onChange={(id, vehicle) => {
+              state.setVehicleId(id)
+              state.setSelectedVehicle(vehicle)
+              if (vehicle?.customerId) {
+                state.setCustomerId(vehicle.customerId)
+                if (vehicle.customer) {
+                  state.setSelectedCustomer({
+                    id: vehicle.customer.id,
+                    name: vehicle.customer.name,
+                    company: null,
+                  })
+                }
+              }
+              state.markDirty()
+            }}
+          />
+        </div>
+        {state.selectedVehicle && (
+          <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
+            <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <Link
+              href={`/vehicles/${state.selectedVehicle.id}`}
+              target="_blank"
+              className="min-w-0 flex-1 text-sm hover:underline"
+            >
+              <span className="font-medium">
+                {state.selectedVehicle.year} {state.selectedVehicle.make}{' '}
+                {state.selectedVehicle.model}
+              </span>
+              {state.selectedVehicle.licensePlate && (
+                <span className="ml-1.5 text-muted-foreground">
+                  {state.selectedVehicle.licensePlate}
+                </span>
+              )}
+            </Link>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
+              onClick={() => {
+                state.setVehicleId('')
+                state.setSelectedVehicle(null)
+                state.markDirty()
+              }}
+            >
+              <X className="h-3 w-3" />
+            </Button>
+          </div>
+        )}
+        <div className="space-y-1">
+          <Label className="text-xs">{t('details.customer')}</Label>
+          <CustomerCombobox
+            value={state.customerId}
+            initialCustomer={state.selectedCustomer}
+            placeholder={t('details.selectCustomer')}
+            noneLabel={t('details.none')}
+            onChange={(id, customer) => {
+              state.setCustomerId(id)
+              state.setSelectedCustomer(customer)
+              state.markDirty()
+            }}
+          />
+        </div>
+        {state.selectedCustomer && (
+          <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
+            <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <Link
+              href={`/customers/${state.selectedCustomer.id}`}
+              target="_blank"
+              className="min-w-0 flex-1 text-sm hover:underline"
+            >
+              <span className="font-medium">{state.selectedCustomer.name}</span>
+              {state.selectedCustomer.company && (
+                <span className="ml-1.5 text-muted-foreground">
+                  {state.selectedCustomer.company}
+                </span>
+              )}
+            </Link>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
+              onClick={() => {
+                state.setCustomerId('')
+                state.setSelectedCustomer(null)
+                state.markDirty()
+              }}
+            >
+              <X className="h-3 w-3" />
+            </Button>
+          </div>
+        )}
+      </div>
+
+      {/* Quote Details */}
+      <div className="rounded-lg border p-3 space-y-3">
+        <h3 className="text-sm font-semibold">{t('details.title')}</h3>
+        <div className="space-y-1">
+          <Label htmlFor="title" className="text-xs">
+            {t('details.titleLabel')}
+          </Label>
+          <Input
+            id="title"
+            name="title"
+            placeholder={t('details.titlePlaceholder')}
+            defaultValue={quote.title}
+            required
+            onChange={state.markDirty}
+          />
+        </div>
+        <div className="grid grid-cols-2 gap-2">
+          <div className="space-y-1">
+            <Label className="text-xs">{t('details.status')}</Label>
+            <Select
+              value={state.status}
+              onValueChange={(v) => {
+                state.setStatus(v)
+                state.markDirty()
+              }}
+            >
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="draft">{t('details.statusDraft')}</SelectItem>
+                <SelectItem value="sent">{t('details.statusSent')}</SelectItem>
+                <SelectItem value="accepted">{t('details.statusAccepted')}</SelectItem>
+                <SelectItem value="rejected">{t('details.statusRejected')}</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+          <div className="space-y-1">
+            <Label htmlFor="validUntil" className="text-xs">
+              {t('details.validUntil')}
+            </Label>
+            <Input
+              id="validUntil"
+              name="validUntil"
+              type="date"
+              defaultValue={state.defaultValidDate}
+              onChange={state.markDirty}
+            />
+          </div>
+        </div>
+        {quote.inspectionId && (
+          <Link
+            href={`/inspections/${quote.inspectionId}`}
+            className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
+          >
+            <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
+            <span>{t('details.viewInspection')}</span>
+          </Link>
+        )}
+      </div>
+
+      {/* Customer Response */}
+      {quote.customerMessage &&
+        (state.status === 'changes_requested' || state.status === 'accepted') && (
+          <div
+            className={`rounded-lg border p-3 space-y-2 ${
+              state.status === 'changes_requested'
+                ? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20'
+                : 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20'
+            }`}
+          >
+            <div className="flex items-center justify-between">
+              <h3
+                className={`flex items-center gap-1.5 text-sm font-semibold ${
+                  state.status === 'changes_requested'
+                    ? 'text-orange-700 dark:text-orange-400'
+                    : 'text-emerald-700 dark:text-emerald-400'
+                }`}
+              >
+                <MessageSquare className="h-3.5 w-3.5" />
+                {state.status === 'changes_requested'
+                  ? t('page.changesRequested')
+                  : t('page.quoteAccepted')}
+              </h3>
+              <span
+                className={`text-[10px] ${
+                  state.status === 'changes_requested'
+                    ? 'text-orange-500 dark:text-orange-500'
+                    : 'text-emerald-500 dark:text-emerald-500'
+                }`}
+              >
+                {new Date(quote.updatedAt).toLocaleDateString(undefined, {
+                  month: 'short',
+                  day: 'numeric',
+                  year: 'numeric',
+                })}{' '}
+                {new Date(quote.updatedAt).toLocaleTimeString(undefined, {
+                  hour: '2-digit',
+                  minute: '2-digit',
+                })}
+              </span>
+            </div>
+            <p
+              className={`text-sm ${
+                state.status === 'changes_requested'
+                  ? 'text-orange-600 dark:text-orange-400'
+                  : 'text-emerald-600 dark:text-emerald-400'
+              }`}
+            >
+              &ldquo;{quote.customerMessage}&rdquo;
+            </p>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              className="w-full"
+              disabled={state.resolving}
+              onClick={state.handleResolveResponse}
+            >
+              {state.resolving ? (
+                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+              ) : (
+                <Check className="mr-1 h-3.5 w-3.5" />
+              )}
+              {t('page.markResolved')}
+            </Button>
+          </div>
+        )}
+
+      {/* Totals */}
+      <div className="rounded-lg border p-3 space-y-2">
+        <h3 className="text-sm font-semibold">{t('totals.title')}</h3>
+        <div className="space-y-2">
+          <div className="flex items-center justify-between text-sm">
+            <span className="text-muted-foreground">{t('totals.parts')}</span>
+            <span>{formatCurrency(state.partsSubtotal, currencyCode)}</span>
+          </div>
+          <div className="flex items-center justify-between text-sm">
+            <span className="text-muted-foreground">{t('totals.labor')}</span>
+            <span>{formatCurrency(state.laborSubtotal, currencyCode)}</span>
+          </div>
+          <div className="flex items-center justify-between text-sm">
+            <span className="text-muted-foreground">{t('totals.subtotal')}</span>
+            <span className="font-medium">{formatCurrency(state.subtotal, currencyCode)}</span>
+          </div>
+          <div className="flex items-center justify-between text-sm">
+            <div className="flex items-center gap-2">
+              <span className="text-muted-foreground">{t('totals.discount')}</span>
+              <Select
+                value={state.discountType}
+                onValueChange={(v) => {
+                  state.setDiscountType(v)
+                  state.markDirty()
+                }}
+              >
+                <SelectTrigger className="h-7 w-28 text-xs">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="none">{t('totals.discountNone')}</SelectItem>
+                  <SelectItem value="percentage">{t('totals.discountPercentage')}</SelectItem>
+                  <SelectItem value="fixed">{t('totals.discountFixed')}</SelectItem>
+                </SelectContent>
+              </Select>
+              {state.discountType !== 'none' && (
+                <Input
+                  type="number"
+                  min="0"
+                  step="0.01"
+                  value={state.discountValue}
+                  onChange={(e) => {
+                    state.setDiscountValue(e.target.value === '' ? 0 : Number(e.target.value))
+                    state.markDirty()
+                  }}
+                  className="h-7 w-20 text-right text-xs"
+                />
+              )}
+              {state.discountType === 'percentage' && (
+                <span className="text-muted-foreground">%</span>
+              )}
+            </div>
+            {state.discountAmount > 0 && (
+              <span className="text-destructive">
+                {formatCurrency(-state.discountAmount, currencyCode)}
+              </span>
+            )}
+          </div>
+          {state.taxEnabled && (
+            <div className="flex items-center justify-between text-sm">
+              <div className="flex items-center gap-2">
+                <span className="text-muted-foreground">{t('totals.tax')}</span>
+                <Input
+                  type="number"
+                  min="0"
+                  step="0.1"
+                  value={state.taxRate}
+                  onChange={(e) => {
+                    state.setTaxRate(e.target.value === '' ? 0 : Number(e.target.value))
+                    state.markDirty()
+                  }}
+                  className="h-7 w-20 text-right text-xs"
+                />
+                <span className="text-muted-foreground">%</span>
+              </div>
+              <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
+            </div>
+          )}
+          <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
+            <span>{t('totals.total')}</span>
+            <span>{formatCurrency(state.totalAmount, currencyCode)}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+})

+ 73 - 0
src/features/quotes/Components/quote-page-types.ts

@@ -0,0 +1,73 @@
+import type { QuotePartInput, QuoteLaborInput } from "@/features/quotes/Schema/quoteSchema";
+
+export type { QuotePartInput, QuoteLaborInput };
+
+export interface QuoteAttachment {
+  id: string;
+  fileName: string;
+  fileUrl: string;
+  fileType: string;
+  fileSize: number;
+  category: string;
+  description: string | null;
+  includeInInvoice: boolean;
+}
+
+export type TabType = "details" | "images" | "documents";
+
+export interface QuoteRecord {
+  id: string;
+  quoteNumber: string | null;
+  title: string;
+  description: string | null;
+  status: string;
+  validUntil: Date | null;
+  subtotal: number;
+  taxRate: number;
+  taxAmount: number;
+  discountType: string | null;
+  discountValue: number;
+  discountAmount: number;
+  totalAmount: number;
+  notes: string | null;
+  customerMessage: string | null;
+  publicToken: string | null;
+  sharedAt: Date | null;
+  viewCount: number;
+  lastViewedAt: Date | null;
+  convertedToId: string | null;
+  inspectionId: string | null;
+  createdAt: Date;
+  updatedAt: Date;
+  partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number; excluded?: boolean }[];
+  laborItems: { id: string; description: string; hours: number; rate: number; total: number; excluded?: boolean }[];
+  customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
+  vehicle: { id: string; make: string; model: string; year: number; vin: string | null; licensePlate: string | null; mileage: number } | null;
+}
+
+export const statusColors: Record<string, string> = {
+  draft: "bg-gray-500/10 text-gray-500 border-gray-500/20",
+  sent: "bg-blue-500/10 text-blue-500 border-blue-500/20",
+  accepted: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
+  rejected: "bg-red-500/10 text-red-500 border-red-500/20",
+  expired: "bg-amber-500/10 text-amber-600 border-amber-500/20",
+  converted: "bg-purple-500/10 text-purple-500 border-purple-500/20",
+  changes_requested: "bg-orange-500/10 text-orange-600 border-orange-500/20",
+};
+
+export const emptyPart = (): QuotePartInput => ({
+  partNumber: "",
+  name: "",
+  quantity: 1,
+  unitPrice: 0,
+  total: 0,
+  excluded: false,
+});
+
+export const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
+  description: "",
+  hours: 0,
+  rate: defaultRate,
+  total: 0,
+  excluded: false,
+});

+ 329 - 0
src/features/quotes/Components/useQuoteFormState.ts

@@ -0,0 +1,329 @@
+"use client";
+
+import { useState, useCallback, useEffect, useRef, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { useGlassModal } from "@/components/glass-modal";
+import { useConfirm } from "@/components/confirm-dialog";
+import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
+import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
+import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from "./quote-page-types";
+import { emptyPart, makeEmptyLabor } from "./quote-page-types";
+
+interface SelectedVehicle {
+  id: string;
+  make: string;
+  model: string;
+  year: number;
+  licensePlate: string | null;
+  customerId: string | null;
+  customer: { id: string; name: string } | null;
+}
+
+interface SelectedCustomer {
+  id: string;
+  name: string;
+  company: string | null;
+}
+
+export function useQuoteFormState({
+  quote,
+  currencyCode,
+  defaultTaxRate,
+  taxEnabled,
+  defaultLaborRate,
+  t,
+}: {
+  quote: QuoteRecord;
+  currencyCode: string;
+  defaultTaxRate: number;
+  taxEnabled: boolean;
+  defaultLaborRate: number;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  t: (key: string, values?: any) => string;
+}) {
+  const router = useRouter();
+  const modal = useGlassModal();
+  const confirm = useConfirm();
+
+  // Tab state
+  const [activeTab, setActiveTab] = useState<"details" | "images" | "documents">("details");
+
+  // Form state
+  const [saving, setSaving] = useState(false);
+  const [downloading, setDownloading] = useState(false);
+  const [status, setStatus] = useState(quote.status);
+  const [customerId, setCustomerId] = useState(quote.customer?.id || "");
+  const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
+  const [partItems, setPartItems] = useState<QuotePartInput[]>(
+    quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false }))
+  );
+  const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
+    quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, excluded: l.excluded ?? false }))
+  );
+  const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate);
+  const [discountType, setDiscountType] = useState<string>(quote.discountType || "none");
+  const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0);
+  const [noteType, setNoteType] = useState<"public" | "internal">("public");
+  const [description, setDescription] = useState(quote.description || "");
+  const [notes, setNotes] = useState(quote.notes || "");
+
+  // Dialog state
+  const [showEmailDialog, setShowEmailDialog] = useState(false);
+  const [showShareDialog, setShowShareDialog] = useState(false);
+  const [showConvertDialog, setShowConvertDialog] = useState(false);
+  const [convertVehicleId, setConvertVehicleId] = useState(quote.vehicle?.id || "");
+  const [converting, setConverting] = useState(false);
+  const [resolving, setResolving] = useState(false);
+
+  const [defaultValidDate] = useState(() =>
+    quote.validUntil ? new Date(quote.validUntil).toISOString().split("T")[0] : ""
+  );
+
+  // Track selected vehicle/customer for display (initial from quote data)
+  const [selectedVehicle, setSelectedVehicle] = useState<SelectedVehicle | null>(
+    quote.vehicle
+      ? {
+          id: quote.vehicle.id,
+          make: quote.vehicle.make,
+          model: quote.vehicle.model,
+          year: quote.vehicle.year,
+          licensePlate: quote.vehicle.licensePlate,
+          customerId: quote.customer?.id || null,
+          customer: quote.customer ? { id: quote.customer.id, name: quote.customer.name } : null,
+        }
+      : null
+  );
+  const [selectedCustomer, setSelectedCustomer] = useState<SelectedCustomer | null>(
+    quote.customer
+      ? { id: quote.customer.id, name: quote.customer.name, company: quote.customer.company }
+      : null
+  );
+
+  // Unsaved changes tracking & autosave
+  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+  const [showSaved, setShowSaved] = useState(false);
+  const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const formRef = useRef<HTMLFormElement>(null);
+  const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const isSavingRef = useRef(false);
+
+  const markDirty = useCallback(() => {
+    setHasUnsavedChanges(true);
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    autosaveTimer.current = setTimeout(() => {
+      if (!isSavingRef.current && formRef.current) {
+        formRef.current.requestSubmit();
+      }
+    }, 5000);
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!hasUnsavedChanges) return;
+    const handler = (e: BeforeUnloadEvent) => {
+      e.preventDefault();
+    };
+    window.addEventListener("beforeunload", handler);
+    return () => window.removeEventListener("beforeunload", handler);
+  }, [hasUnsavedChanges]);
+
+  // Calculations
+  const partsSubtotal = useMemo(() => partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0), [partItems]);
+  const laborSubtotal = useMemo(() => laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0), [laborItems]);
+  const subtotal = partsSubtotal + laborSubtotal;
+  const discountAmount = discountType === "percentage"
+    ? subtotal * (discountValue / 100)
+    : discountType === "fixed"
+    ? Math.min(discountValue, subtotal)
+    : 0;
+  const taxAmount = (subtotal - discountAmount) * (taxRate / 100);
+  const totalAmount = subtotal - discountAmount + taxAmount;
+
+  const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
+    setPartItems((prev) => {
+      const updated = [...prev];
+      const part = { ...updated[index], [field]: value };
+      if (field === "quantity" || field === "unitPrice") part.total = Number(part.quantity) * Number(part.unitPrice);
+      updated[index] = part;
+      return updated;
+    });
+    markDirty();
+  }, [markDirty]);
+
+  const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
+    setLaborItems((prev) => {
+      const updated = [...prev];
+      const labor = { ...updated[index], [field]: value };
+      if (field === "hours" || field === "rate") labor.total = Number(labor.hours) * Number(labor.rate);
+      updated[index] = labor;
+      return updated;
+    });
+    markDirty();
+  }, [markDirty]);
+
+  const addPart = useCallback(() => {
+    setPartItems((prev) => [...prev, emptyPart()]);
+    markDirty();
+  }, [markDirty]);
+
+  const addLabor = useCallback(() => {
+    setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)]);
+    markDirty();
+  }, [defaultLaborRate, markDirty]);
+
+  const deletePart = useCallback((index: number) => {
+    setPartItems((prev) => prev.filter((_, j) => j !== index));
+    markDirty();
+  }, [markDirty]);
+
+  const deleteLabor = useCallback((index: number) => {
+    setLaborItems((prev) => prev.filter((_, j) => j !== index));
+    markDirty();
+  }, [markDirty]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    if (isSavingRef.current) return;
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    isSavingRef.current = true;
+    setSaving(true);
+    const formData = new FormData(e.currentTarget);
+    const result = await updateQuote({
+      id: quote.id,
+      title: formData.get("title") as string,
+      description: description || undefined,
+      status,
+      validUntil: (formData.get("validUntil") as string) || undefined,
+      customerId: customerId || undefined,
+      vehicleId: vehicleId || undefined,
+      notes: notes || undefined,
+      partItems: partItems.filter((p) => p.name),
+      laborItems: laborItems.filter((l) => l.description),
+      subtotal,
+      taxRate,
+      taxAmount,
+      discountType: discountType === "none" ? undefined : discountType,
+      discountValue,
+      discountAmount,
+      totalAmount,
+    });
+    if (result.success) {
+      setHasUnsavedChanges(false);
+      if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
+      setShowSaved(true);
+      savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000);
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || t("page.failedSave"));
+    }
+    isSavingRef.current = false;
+    setSaving(false);
+  };
+
+  const handleDelete = async () => {
+    const ok = await confirm({ title: t("page.deleteTitle"), description: t("page.deleteDescription"), confirmLabel: t("page.delete"), destructive: true });
+    if (!ok) return;
+    const result = await deleteQuote(quote.id);
+    if (result.success) {
+      router.push("/quotes");
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || t("page.failedDelete"));
+    }
+  };
+
+  const saveNow = async () => {
+    if (!formRef.current || isSavingRef.current) return;
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
+    formRef.current.requestSubmit();
+    await new Promise<void>((resolve) => {
+      const check = () => {
+        if (!isSavingRef.current) return resolve();
+        setTimeout(check, 50);
+      };
+      setTimeout(check, 50);
+    });
+  };
+
+  const handleDownloadPDF = async () => {
+    if (hasUnsavedChanges) await saveNow();
+    setDownloading(true);
+    try {
+      const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`);
+      if (!res.ok) throw new Error(t("page.failedPdf"));
+      const blob = await res.blob();
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = `quote-${quote.quoteNumber || quote.id}.pdf`;
+      a.click();
+      URL.revokeObjectURL(url);
+    } catch {
+      modal.open("error", "Error", t("page.failedPdf"));
+    }
+    setDownloading(false);
+  };
+
+  const handleConvert = async () => {
+    if (hasUnsavedChanges) await saveNow();
+    if (!convertVehicleId) {
+      modal.open("error", "Error", t("page.selectVehicle"));
+      return;
+    }
+    setConverting(true);
+    const result = await convertQuoteToServiceRecord(quote.id, convertVehicleId);
+    if (result.success && result.data) {
+      router.push(`/vehicles/${convertVehicleId}/service/${result.data.id}`);
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || t("page.failedConvert"));
+    }
+    setConverting(false);
+  };
+
+  const handleResolveResponse = async () => {
+    setResolving(true);
+    const result = await acknowledgeQuoteResponse(quote.id);
+    if (result.success) {
+      setStatus("draft");
+      toast.success(t("page.responseResolved"));
+      router.refresh();
+    }
+    setResolving(false);
+  };
+
+  return {
+    // Tab
+    activeTab, setActiveTab,
+    // Form state
+    saving, downloading, status, setStatus,
+    customerId, setCustomerId, vehicleId, setVehicleId,
+    partItems, laborItems,
+    taxRate, setTaxRate, taxEnabled,
+    discountType, setDiscountType, discountValue, setDiscountValue,
+    noteType, setNoteType, description, setDescription, notes, setNotes,
+    // Dialogs
+    showEmailDialog, setShowEmailDialog,
+    showShareDialog, setShowShareDialog,
+    showConvertDialog, setShowConvertDialog,
+    convertVehicleId, setConvertVehicleId, converting,
+    resolving, defaultValidDate,
+    // Unsaved
+    hasUnsavedChanges, showSaved, formRef,
+    // Computed
+    partsSubtotal, laborSubtotal, subtotal, discountAmount, taxAmount, totalAmount,
+    selectedVehicle, setSelectedVehicle, selectedCustomer, setSelectedCustomer,
+    // Callbacks
+    markDirty, updatePart, updateLabor, addPart, addLabor, deletePart, deleteLabor,
+    handleSubmit, handleDelete, handleDownloadPDF,
+    handleConvert, handleResolveResponse, saveNow,
+  };
+}
+
+export type QuoteFormState = ReturnType<typeof useQuoteFormState>;

+ 65 - 31
src/features/search/Actions/searchActions.ts

@@ -32,6 +32,9 @@ export async function globalSearch(query: string) {
     const q = query.trim();
     const mode = "insensitive" as Prisma.QueryMode;
     const words = q.split(/\s+/).filter(Boolean);
+    // Digits-only version for phone matching (strips +, spaces, dashes, parens)
+    const digitsOnly = q.replace(/[\s\-\+\(\)]/g, "");
+    const isPhoneLike = /^\d{3,}$/.test(digitsOnly);
 
     // For multi-word queries, each word must match at least one field (AND logic).
     // For single-word queries, behavior is the same as before.
@@ -44,6 +47,7 @@ export async function globalSearch(query: string) {
           { model: { contains: word, mode } },
           { licensePlate: { contains: word, mode } },
           { vin: { contains: word, mode } },
+          { customer: { name: { contains: word, mode } } },
         ];
         if (!isNaN(Number(word))) {
           or.push({ year: Number(word) });
@@ -64,39 +68,69 @@ export async function globalSearch(query: string) {
         },
         take: 10,
       }),
-      db.customer.findMany({
-        where: {
-          organizationId,
-          AND: words.map((word) => ({
-            OR: [
-              { name: { contains: word, mode } },
-              { email: { contains: word, mode } },
-              { phone: { contains: word, mode } },
-              { company: { contains: word, mode } },
-            ],
-          })),
-        },
-        select: {
-          id: true,
-          name: true,
-          email: true,
-          phone: true,
-          company: true,
-          vehicles: {
-            where: { isArchived: false },
-            select: {
-              id: true,
-              make: true,
-              model: true,
-              year: true,
-              licensePlate: true,
+      (async () => {
+        // For phone-like queries, use raw SQL to strip formatting before matching
+        if (isPhoneLike) {
+          const phoneCustomers = await db.$queryRaw<{ id: string; name: string; email: string | null; phone: string | null; company: string | null }[]>`
+            SELECT id, name, email, phone, company
+            FROM customers
+            WHERE "organizationId" = ${organizationId}
+              AND REGEXP_REPLACE(phone, '[^0-9]', '', 'g') LIKE ${'%' + digitsOnly + '%'}
+            LIMIT 10
+          `;
+          // Fetch vehicles for matched customers
+          const customerIds = phoneCustomers.map(c => c.id);
+          const customerVehicles = customerIds.length > 0
+            ? await db.vehicle.findMany({
+                where: { customerId: { in: customerIds }, isArchived: false },
+                select: { id: true, make: true, model: true, year: true, licensePlate: true, customerId: true },
+                take: 25,
+                orderBy: { updatedAt: "desc" },
+              })
+            : [];
+          return phoneCustomers.map(c => ({
+            ...c,
+            vehicles: customerVehicles
+              .filter(v => v.customerId === c.id)
+              .slice(0, 5)
+              .map(({ customerId: _, ...v }) => v),
+          }));
+        }
+        // Standard text search
+        return db.customer.findMany({
+          where: {
+            organizationId,
+            AND: words.map((word) => ({
+              OR: [
+                { name: { contains: word, mode } },
+                { email: { contains: word, mode } },
+                { phone: { contains: word, mode } },
+                { company: { contains: word, mode } },
+              ],
+            })),
+          },
+          select: {
+            id: true,
+            name: true,
+            email: true,
+            phone: true,
+            company: true,
+            vehicles: {
+              where: { isArchived: false },
+              select: {
+                id: true,
+                make: true,
+                model: true,
+                year: true,
+                licensePlate: true,
+              },
+              take: 5,
+              orderBy: { updatedAt: "desc" },
             },
-            take: 5,
-            orderBy: { updatedAt: "desc" },
           },
-        },
-        take: 10,
-      }),
+          take: 10,
+        });
+      })(),
       db.serviceRecord.findMany({
         where: {
           vehicle: { organizationId },

+ 3 - 2
src/features/search/Components/SearchCommand.tsx

@@ -211,6 +211,7 @@ export function SearchCommand() {
       title="Search"
       description="Search for vehicles, customers, services, and settings"
       className="sm:max-w-2xl"
+      shouldFilter={!hasQuery}
     >
       <CommandInput
         placeholder="Search by name, plate, phone, VIN, invoice..."
@@ -228,7 +229,7 @@ export function SearchCommand() {
             {recentCustomers.map((c) => (
               <CommandItem
                 key={c.id}
-                value={`${c.name} ${c.email || ""} ${c.company || ""}`}
+                value={`${c.name} ${c.email || ""} ${c.phone || ""} ${c.company || ""}`}
                 onSelect={() => handleSelect(`/customers/${c.id}`)}
               >
                 <Users className="mr-2 h-4 w-4 text-muted-foreground" />
@@ -251,7 +252,7 @@ export function SearchCommand() {
                 {results.customers.map((c) => (
                   <div key={c.id}>
                     <CommandItem
-                      value={`${c.name} ${c.email || ""} ${c.company || ""}`}
+                      value={`${c.name} ${c.email || ""} ${c.phone || ""} ${c.company || ""}`}
                       onSelect={() => handleSelect(`/customers/${c.id}`)}
                     >
                       <Users className="mr-2 h-4 w-4 text-muted-foreground" />

+ 4 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -76,6 +76,10 @@ export const SETTING_KEYS = {
   WORKBOARD_WORK_DAY_END: "workboard.workDayEnd",
   INVOICE_LAYOUT_CONFIG: "invoice.layoutConfig",
   QUOTE_LAYOUT_CONFIG: "quote.layoutConfig",
+  AI_PROVIDER: "ai.provider",
+  AI_API_KEY: "ai.apiKey",
+  AI_MODEL: "ai.model",
+  AI_ENABLED: "ai.enabled",
 } as const;
 
 export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];

+ 3 - 0
src/features/vehicles/Actions/vehicleActions.ts

@@ -49,6 +49,9 @@ export async function getVehicle(vehicleId: string) {
             createdAt: true,
           },
         },
+        aiMessages: {
+          select: { type: true, content: true, updatedAt: true },
+        },
         _count: {
           select: {
             serviceRecords: true,

+ 145 - 11
src/features/vehicles/Components/service-edit/NotesSection.tsx

@@ -2,6 +2,7 @@
 
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
 import {
   Select,
   SelectContent,
@@ -9,20 +10,42 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
-import { FileText } from 'lucide-react'
+import {
+  AlertDialog,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import { FileText, Loader2, Sparkles } from 'lucide-react'
 import { RichTextEditor } from './RichTextEditor'
 import type { InitialData } from './form-types'
+import { aiGenerateServiceNotes } from '@/features/ai/Actions/aiActions'
+
+const SKIP_AI_DIALOG_KEY = 'torqvoice:skipAiWriteDialog'
 
 interface NotesSectionProps {
   initialData: InitialData
   onNotesChange: (field: 'invoiceNotes' | 'diagnosticNotes' | 'description', value: string) => void
+  serviceRecordId?: string
+  aiEnabled?: boolean
+}
+
+function hasContent(html: string): boolean {
+  const text = html.replace(/<[^>]*>/g, '').trim()
+  return text.length > 0
 }
 
-export function NotesSection({ initialData, onNotesChange }: NotesSectionProps) {
+export function NotesSection({ initialData, onNotesChange, serviceRecordId, aiEnabled }: NotesSectionProps) {
   const t = useTranslations('service.notes')
   const [noteType, setNoteType] = useState<'public' | 'internal'>('public')
   const [publicNotes, setPublicNotes] = useState(initialData.invoiceNotes || '')
   const [internalNotes, setInternalNotes] = useState(initialData.diagnosticNotes || '')
+  const [generating, setGenerating] = useState(false)
+  const [showDialog, setShowDialog] = useState(false)
+  const [dontShowAgain, setDontShowAgain] = useState(false)
 
   const handlePublicChange = (html: string) => {
     setPublicNotes(html)
@@ -34,6 +57,56 @@ export function NotesSection({ initialData, onNotesChange }: NotesSectionProps)
     onNotesChange('diagnosticNotes', html)
   }
 
+  const runAiGenerate = async (mode: 'replace' | 'append') => {
+    if (!serviceRecordId) return
+    setGenerating(true)
+    try {
+      const result = await aiGenerateServiceNotes(serviceRecordId)
+      if (result.success && result.data) {
+        const html = result.data.replace(/\n/g, '<br>')
+        const currentNotes = noteType === 'public' ? publicNotes : internalNotes
+        const finalHtml = mode === 'append' ? `${currentNotes}<br><br>${html}` : html
+
+        if (noteType === 'public') {
+          setPublicNotes(finalHtml)
+          onNotesChange('invoiceNotes', finalHtml)
+        } else {
+          setInternalNotes(finalHtml)
+          onNotesChange('diagnosticNotes', finalHtml)
+        }
+        toast.success(t('aiGenerated'))
+      } else {
+        toast.error(result.error ?? t('aiGenerateFailed'))
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t('aiGenerateFailed'))
+    } finally {
+      setGenerating(false)
+    }
+  }
+
+  const handleAiClick = () => {
+    const skipDialog = localStorage.getItem(SKIP_AI_DIALOG_KEY) === 'true'
+    const currentNotes = noteType === 'public' ? publicNotes : internalNotes
+
+    if (skipDialog && !hasContent(currentNotes)) {
+      runAiGenerate('replace')
+    } else {
+      setDontShowAgain(false)
+      setShowDialog(true)
+    }
+  }
+
+  const handleGenerate = (mode: 'replace' | 'append') => {
+    if (dontShowAgain) {
+      localStorage.setItem(SKIP_AI_DIALOG_KEY, 'true')
+    }
+    setShowDialog(false)
+    runAiGenerate(mode)
+  }
+
+  const notesHaveContent = hasContent(noteType === 'public' ? publicNotes : internalNotes)
+
   return (
     <div className="rounded-lg border p-3 space-y-3">
       <div className="flex items-center justify-between">
@@ -41,15 +114,34 @@ export function NotesSection({ initialData, onNotesChange }: NotesSectionProps)
           <FileText className="h-3.5 w-3.5" />
           {t('title')}
         </h3>
-        <Select value={noteType} onValueChange={(v) => setNoteType(v as 'public' | 'internal')}>
-          <SelectTrigger className="h-7 w-[120px] text-xs">
-            <SelectValue />
-          </SelectTrigger>
-          <SelectContent>
-            <SelectItem value="public">{t('public')}</SelectItem>
-            <SelectItem value="internal">{t('internal')}</SelectItem>
-          </SelectContent>
-        </Select>
+        <div className="flex items-center gap-2">
+          {aiEnabled && serviceRecordId && (
+            <Button
+              type="button"
+              variant="ghost"
+              size="sm"
+              className="h-7 gap-1.5 text-xs"
+              onClick={handleAiClick}
+              disabled={generating}
+            >
+              {generating ? (
+                <Loader2 className="h-3.5 w-3.5 animate-spin" />
+              ) : (
+                <Sparkles className="h-3.5 w-3.5" />
+              )}
+              {t('aiWrite')}
+            </Button>
+          )}
+          <Select value={noteType} onValueChange={(v) => setNoteType(v as 'public' | 'internal')}>
+            <SelectTrigger className="h-7 w-[120px] text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="public">{t('public')}</SelectItem>
+              <SelectItem value="internal">{t('internal')}</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
       </div>
 
       {noteType === 'public' && (
@@ -73,6 +165,48 @@ export function NotesSection({ initialData, onNotesChange }: NotesSectionProps)
           <p className="text-xs text-muted-foreground">{t('internalHelper')}</p>
         </div>
       )}
+
+      <AlertDialog open={showDialog} onOpenChange={setShowDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle className="flex items-center gap-2">
+              <Sparkles className="h-4 w-4" />
+              {t('aiDialogTitle')}
+            </AlertDialogTitle>
+            <AlertDialogDescription>{t('aiDialogDescription')}</AlertDialogDescription>
+          </AlertDialogHeader>
+          {notesHaveContent && (
+            <p className="text-sm text-amber-600 dark:text-amber-400">
+              {t('aiOverwriteDescription')}
+            </p>
+          )}
+          <div className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              id="dont-show-again"
+              checked={dontShowAgain}
+              onChange={(e) => setDontShowAgain(e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300"
+            />
+            <label htmlFor="dont-show-again" className="text-sm text-muted-foreground">
+              {t('aiDontShowAgain')}
+            </label>
+          </div>
+          <AlertDialogFooter>
+            <Button variant="outline" onClick={() => setShowDialog(false)}>
+              {t('aiOverwriteCancel')}
+            </Button>
+            {notesHaveContent && (
+              <Button variant="outline" onClick={() => handleGenerate('append')}>
+                {t('aiOverwriteAppend')}
+              </Button>
+            )}
+            <Button onClick={() => handleGenerate('replace')}>
+              {notesHaveContent ? t('aiOverwriteReplace') : t('aiGenerate')}
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
     </div>
   )
 }

+ 14 - 1
src/features/vehicles/Components/service-edit/RichTextEditor.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { useEffect, useRef } from 'react'
 import { useEditor, EditorContent } from '@tiptap/react'
 import StarterKit from '@tiptap/starter-kit'
 import Underline from '@tiptap/extension-underline'
@@ -26,6 +27,8 @@ interface RichTextEditorProps {
 }
 
 export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
+  const lastContentRef = useRef(content)
+
   const editor = useEditor({
     immediatelyRender: false,
     extensions: [
@@ -39,7 +42,9 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
     ],
     content,
     onUpdate: ({ editor }) => {
-      onChange(editor.getHTML())
+      const html = editor.getHTML()
+      lastContentRef.current = html
+      onChange(html)
     },
     editorProps: {
       attributes: {
@@ -48,6 +53,14 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
     },
   })
 
+  // Sync editor when content is updated externally (e.g. AI generation)
+  useEffect(() => {
+    if (editor && content !== lastContentRef.current) {
+      lastContentRef.current = content
+      editor.commands.setContent(content)
+    }
+  }, [content, editor])
+
   if (!editor) return null
 
   return (

+ 8 - 1
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -15,6 +15,7 @@ interface DetailsLeftColumnProps {
   currencyCode: string
   defaultLaborRate: number
   inventoryParts: InventoryPartOption[]
+  aiEnabled?: boolean
 }
 
 export function DetailsLeftColumn({
@@ -24,6 +25,7 @@ export function DetailsLeftColumn({
   currencyCode,
   defaultLaborRate,
   inventoryParts,
+  aiEnabled,
 }: DetailsLeftColumnProps) {
   return (
     <div className="space-y-3">
@@ -44,7 +46,12 @@ export function DetailsLeftColumn({
         currencyCode={currencyCode}
         defaultLaborRate={defaultLaborRate}
       />
-      <NotesSection initialData={formState.initialData} onNotesChange={formState.handleNotesChange} />
+      <NotesSection
+        initialData={formState.initialData}
+        onNotesChange={formState.handleNotesChange}
+        serviceRecordId={record.id}
+        aiEnabled={aiEnabled}
+      />
       <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
         <PaymentsSection
           payments={record.payments || []}

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

@@ -45,6 +45,7 @@ export function ServicePageClient({
   maxDocumentsPerService,
   smsEnabled = false,
   emailEnabled = false,
+  aiEnabled = false,
 }: ServicePageClientProps) {
   const t = useTranslations('service')
   const [activeTab, setActiveTab] = useState<ServiceTab>('details')
@@ -100,6 +101,7 @@ export function ServicePageClient({
                 currencyCode={currencyCode}
                 defaultLaborRate={defaultLaborRate}
                 inventoryParts={inventoryParts}
+                aiEnabled={aiEnabled}
               />
             }
             rightColumn={

+ 1 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -41,6 +41,7 @@ export interface ServicePageClientProps {
   maxDocumentsPerService: number
   smsEnabled?: boolean
   emailEnabled?: boolean
+  aiEnabled?: boolean
 }
 
 export type { ServicePartInput, ServiceLaborInput, ServiceDetail, InitialData, InventoryPartOption }

+ 2 - 0
src/i18n/request.ts

@@ -65,6 +65,7 @@ export default getRequestConfig(async () => {
   const billing = (await import(`../../messages/${locale}/billing.json`)).default
   const reminders = (await import(`../../messages/${locale}/reminders.json`)).default
   const audit = (await import(`../../messages/${locale}/audit.json`)).default
+  const aiChat = (await import(`../../messages/${locale}/aiChat.json`)).default
 
   return {
     locale,
@@ -92,6 +93,7 @@ export default getRequestConfig(async () => {
       billing,
       reminders,
       audit,
+      aiChat,
     },
   }
 })

+ 205 - 0
src/lib/ai.ts

@@ -0,0 +1,205 @@
+import "server-only";
+import { db } from "@/lib/db";
+import { AI_KEYS } from "@/features/ai/Schema/aiSettingsSchema";
+import { localeNames, type Locale } from "@/i18n/config";
+import OpenAI from "openai";
+
+interface AiConfig {
+  provider: string;
+  apiKey: string;
+  model: string;
+}
+
+export async function getAiConfig(organizationId: string): Promise<AiConfig> {
+  const settings = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: {
+        in: [
+          AI_KEYS.AI_ENABLED,
+          AI_KEYS.AI_PROVIDER,
+          AI_KEYS.AI_API_KEY,
+          AI_KEYS.AI_MODEL,
+        ],
+      },
+    },
+  });
+
+  const map = new Map(settings.map((s) => [s.key, s.value]));
+
+  if (map.get(AI_KEYS.AI_ENABLED) !== "true") {
+    throw new Error("AI is not enabled. Configure it in Settings → AI.");
+  }
+
+  const provider = map.get(AI_KEYS.AI_PROVIDER);
+  const apiKey = map.get(AI_KEYS.AI_API_KEY);
+  const model = map.get(AI_KEYS.AI_MODEL);
+
+  if (!provider || !apiKey || !model) {
+    throw new Error(
+      "AI is not fully configured. Set provider, API key, and model in Settings → AI.",
+    );
+  }
+
+  return { provider, apiKey, model };
+}
+
+export function createClient(config: AiConfig): OpenAI {
+  if (config.provider === "anthropic") {
+    return new OpenAI({
+      apiKey: config.apiKey,
+      baseURL: "https://api.anthropic.com/v1/",
+      defaultHeaders: {
+        "anthropic-version": "2023-06-01",
+      },
+    });
+  }
+  return new OpenAI({ apiKey: config.apiKey });
+}
+
+function languageInstruction(locale: Locale): string {
+  if (locale === "en") return "";
+  const name = localeNames[locale] || locale;
+  return `\n\nIMPORTANT: You MUST respond entirely in ${name}.`;
+}
+
+async function chatCompletion(
+  organizationId: string,
+  systemPrompt: string,
+  userPrompt: string,
+): Promise<string> {
+  const config = await getAiConfig(organizationId);
+  const client = createClient(config);
+
+  const response = await client.chat.completions.create({
+    model: config.model,
+    messages: [
+      { role: "system", content: systemPrompt },
+      { role: "user", content: userPrompt },
+    ],
+    temperature: 0.7,
+    max_tokens: 2000,
+  });
+
+  return response.choices[0]?.message?.content ?? "";
+}
+
+// ─── AI Feature Functions ────────────────────────────────────────────────────
+
+export interface ServiceContext {
+  vehicleMake: string;
+  vehicleModel: string;
+  vehicleYear: number;
+  licensePlate?: string | null;
+  serviceType: string;
+  serviceTitle: string;
+  parts: { name: string; quantity: number }[];
+  labor: { description: string; hours: number }[];
+}
+
+export async function generateServiceDescription(
+  organizationId: string,
+  context: ServiceContext,
+  locale: Locale = "en",
+): Promise<string> {
+  const systemPrompt = `You are a professional automotive service writer. Generate a clear, professional service description for a customer-facing invoice. Be concise but thorough. Do not include pricing. Write in plain text, no markdown.${languageInstruction(locale)}`;
+
+  const partsStr =
+    context.parts.length > 0
+      ? context.parts.map((p) => `- ${p.name} (qty: ${p.quantity})`).join("\n")
+      : "No parts listed";
+
+  const laborStr =
+    context.labor.length > 0
+      ? context.labor
+          .map((l) => `- ${l.description} (${l.hours}h)`)
+          .join("\n")
+      : "No labor listed";
+
+  const userPrompt = `Vehicle: ${context.vehicleYear} ${context.vehicleMake} ${context.vehicleModel}${context.licensePlate ? ` (${context.licensePlate})` : ""}
+Service type: ${context.serviceType}
+Title: ${context.serviceTitle}
+
+Parts used:
+${partsStr}
+
+Labor performed:
+${laborStr}
+
+Write a professional service description and diagnostic notes for the invoice.`;
+
+  return chatCompletion(organizationId, systemPrompt, userPrompt);
+}
+
+export interface ServiceHistoryRecord {
+  title: string;
+  description: string | null;
+  serviceDate: Date | null;
+  type: string;
+  cost: number;
+  mileage: number | null;
+}
+
+export async function summarizeServiceHistory(
+  organizationId: string,
+  vehicle: { make: string; model: string; year: number; licensePlate?: string | null },
+  records: ServiceHistoryRecord[],
+  locale: Locale = "en",
+): Promise<string> {
+  const systemPrompt = `You are an automotive service advisor. Summarize a vehicle's complete service history as structured JSON.
+
+Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
+{"overview":"1-2 sentence general condition summary","majorWork":[{"title":"Work title","date":"YYYY-MM-DD or null","cost":0}],"recurringIssues":[{"title":"Issue name","description":"Brief explanation of the pattern"}],"upcomingMaintenance":[{"item":"Maintenance item","urgency":"high|medium|low","reason":"Why it's needed"}]}
+
+majorWork: list the 3-5 most significant services performed (most recent first).
+recurringIssues: list any patterns or repeated problems with title and description (empty array if none).
+upcomingMaintenance: predict 2-4 likely upcoming needs based on mileage, age, and service history.${languageInstruction(locale)}`;
+
+  const recordsStr = records
+    .map(
+      (r) =>
+        `- ${r.serviceDate ? new Date(r.serviceDate).toISOString().slice(0, 10) : "Unknown date"}: ${r.title} (${r.type}) — Cost: ${r.cost}${r.mileage ? `, Mileage: ${r.mileage}` : ""}${r.description ? `\n  Notes: ${r.description}` : ""}`,
+    )
+    .join("\n");
+
+  const userPrompt = `Vehicle: ${vehicle.year} ${vehicle.make} ${vehicle.model}${vehicle.licensePlate ? ` (${vehicle.licensePlate})` : ""}
+
+Service history (${records.length} records):
+${recordsStr || "No service records found."}
+
+Return JSON only.`;
+
+  return chatCompletion(organizationId, systemPrompt, userPrompt);
+}
+
+export async function getCommonIssues(
+  organizationId: string,
+  vehicle: { make: string; model: string; year: number },
+  locale: Locale = "en",
+): Promise<string> {
+  const systemPrompt = `You are an expert automotive technician. Return a JSON array of exactly 5 critical known issues for the requested vehicle, sorted by severity and cost (highest first). Only serious, well-documented problems.
+
+Return ONLY valid JSON, no markdown, no explanation. Use this exact schema:
+[{"title":"Issue name","description":"1-2 sentence explanation","cost":"X,XXX–X,XXX","risk":"safety|engine|transmission|electrical|other","severity":5}]
+
+severity is 1-5 based on how widely reported the issue is (5 = extremely common/well-documented, affects majority of vehicles; 1 = rare but critical).${languageInstruction(locale)}`;
+
+  const userPrompt = `What are the 5 most critical and common issues with the ${vehicle.year} ${vehicle.make} ${vehicle.model}? Return JSON only.`;
+
+  return chatCompletion(organizationId, systemPrompt, userPrompt);
+}
+
+export async function testAiConnection(
+  organizationId: string,
+): Promise<boolean> {
+  const config = await getAiConfig(organizationId);
+  const client = createClient(config);
+
+  const response = await client.chat.completions.create({
+    model: config.model,
+    messages: [{ role: "user", content: "Say OK" }],
+    max_tokens: 5,
+  });
+
+  return !!response.choices[0]?.message?.content;
+}

+ 5 - 0
src/lib/features.ts

@@ -21,6 +21,7 @@ export type PlanFeatures = {
   maxDiagnosticsPerService: number
   maxDocumentsPerService: number
   customerPortal: boolean
+  ai: boolean
 }
 
 export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
@@ -42,6 +43,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     maxDiagnosticsPerService: 5,
     maxDocumentsPerService: 5,
     customerPortal: false,
+    ai: true,
   },
   pro: {
     maxOrganizations: 3,
@@ -61,6 +63,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     maxDiagnosticsPerService: 30,
     maxDocumentsPerService: 30,
     customerPortal: true,
+    ai: true,
   },
   enterprise: {
     maxOrganizations: 10,
@@ -80,6 +83,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     maxDiagnosticsPerService: 100,
     maxDocumentsPerService: 100,
     customerPortal: true,
+    ai: true,
   },
   'white-label': {
     maxOrganizations: 999999,
@@ -99,6 +103,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     maxDiagnosticsPerService: 999999,
     maxDocumentsPerService: 999999,
     customerPortal: true,
+    ai: true,
   },
 }
 

+ 8 - 0
src/lib/permissions.ts

@@ -19,6 +19,7 @@ export enum PermissionSubject {
   REPORTS = "reports",
   SETTINGS = "settings",
   WORK_BOARD = "work_board",
+  AI_ASSISTANT = "ai_assistant",
 }
 
 export type PermissionInput = {
@@ -140,6 +141,13 @@ export const permissionGroups: PermissionGroup[] = [
       { action: PermissionAction.DELETE, label: "Delete" },
     ],
   },
+  {
+    name: "AI Assistant",
+    subject: PermissionSubject.AI_ASSISTANT,
+    permissions: [
+      { action: PermissionAction.READ, label: "View" },
+    ],
+  },
   {
     name: "Settings",
     subject: PermissionSubject.SETTINGS,

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