Quellcode durchsuchen

Multi-image inventory with AI auto-fill (#91)

* improved layout mobile

* mobile nav

* mobile nav

* vehicles mobile layout

* add parts mobile layout

* ai analyzing image

* multiple parts image

* part ai

* test
Bernt Christian Egeland vor 6 Monaten
Ursprung
Commit
1c4c215064
39 geänderte Dateien mit 942 neuen und 165 gelöschten Zeilen
  1. 7 1
      messages/de/inventory.json
  2. 1 0
      messages/de/navigation.json
  3. 7 1
      messages/en/inventory.json
  4. 1 0
      messages/en/navigation.json
  5. 7 1
      messages/es/inventory.json
  6. 1 0
      messages/es/navigation.json
  7. 7 1
      messages/fr/inventory.json
  8. 1 0
      messages/fr/navigation.json
  9. 7 1
      messages/it/inventory.json
  10. 1 0
      messages/it/navigation.json
  11. 7 1
      messages/nb/inventory.json
  12. 1 0
      messages/nb/navigation.json
  13. 7 1
      messages/nl/inventory.json
  14. 1 0
      messages/nl/navigation.json
  15. 7 1
      messages/pl/inventory.json
  16. 1 0
      messages/pl/navigation.json
  17. 7 1
      messages/pt-BR/inventory.json
  18. 1 0
      messages/pt-BR/navigation.json
  19. 7 1
      messages/ru/inventory.json
  20. 1 0
      messages/ru/navigation.json
  21. 7 1
      messages/tr/inventory.json
  22. 1 0
      messages/tr/navigation.json
  23. 14 0
      package-lock.json
  24. 1 0
      package.json
  25. 21 0
      prisma/migrations/20260324212722_multi_part_images/migration.sql
  26. 17 0
      prisma/schema.prisma
  27. 15 1
      src/__tests__/multitenancy/customer-inventory-isolation.test.ts
  28. 82 16
      src/app/(authenticated)/inventory/inventory-client.tsx
  29. 5 1
      src/app/(authenticated)/layout.tsx
  30. 11 11
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  31. 168 0
      src/components/mobile-bottom-nav.tsx
  32. 135 0
      src/components/ui/drawer.tsx
  33. 60 0
      src/features/inventory/Actions/aiAnalyzePartImage.ts
  34. 65 29
      src/features/inventory/Actions/inventoryActions.ts
  35. 206 76
      src/features/inventory/Components/InventoryPartForm.tsx
  36. 7 1
      src/features/inventory/Schema/inventorySchema.ts
  37. 7 7
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  38. 12 12
      src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx
  39. 28 0
      src/lib/ai.ts

+ 7 - 1
messages/de/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Teil bearbeiten",
     "currentStock": "Aktueller Bestand: {quantity}",
     "addedStock": "{amount} zum Bestand hinzugefügt",
-    "customAmount": "Menge..."
+    "customAmount": "Menge...",
+    "scanParts": "Teile scannen"
   },
   "form": {
     "editPart": "Teil bearbeiten",
@@ -67,6 +68,11 @@
     "dropOrClick": "Bild ablegen oder klicken",
     "replace": "Ersetzen",
     "upload": "Hochladen",
+    "camera": "Kamera",
+    "aiAnalyze": "KI-Autofill",
+    "aiAnalyzing": "Bild wird analysiert...",
+    "aiAnalyzeSuccess": "Felder aus Bild ausgefüllt",
+    "aiAnalyzeFailed": "Bild konnte nicht analysiert werden",
     "imageUploading": "Bild wird hochgeladen...",
     "imageUploaded": "Bild hochgeladen",
     "imageUploadFailed": "Bild konnte nicht hochgeladen werden",

+ 1 - 0
messages/de/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Heller Modus",
     "darkMode": "Dunkler Modus",
     "signOut": "Abmelden",
+    "more": "Mehr",
     "auditLog": "Aktivitätsprotokoll",
     "aiAssistant": "KI-Assistent"
   }

+ 7 - 1
messages/en/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Edit Part",
     "currentStock": "Current stock: {quantity}",
     "addedStock": "Added {amount} to stock",
-    "customAmount": "Qty..."
+    "customAmount": "Qty...",
+    "scanParts": "Scan Parts"
   },
   "form": {
     "editPart": "Edit Part",
@@ -67,6 +68,11 @@
     "dropOrClick": "Drop image or click",
     "replace": "Replace",
     "upload": "Upload",
+    "camera": "Camera",
+    "aiAnalyze": "AI Auto-fill",
+    "aiAnalyzing": "Analyzing image...",
+    "aiAnalyzeSuccess": "Fields auto-filled from image",
+    "aiAnalyzeFailed": "Could not analyze image",
     "imageUploading": "Uploading image...",
     "imageUploaded": "Image uploaded",
     "imageUploadFailed": "Could not upload image",

+ 1 - 0
messages/en/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Light Mode",
     "darkMode": "Dark Mode",
     "signOut": "Sign Out",
+    "more": "More",
     "auditLog": "Audit Log",
     "aiAssistant": "AI Assistant"
   }

+ 7 - 1
messages/es/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Editar pieza",
     "currentStock": "Stock actual: {quantity}",
     "addedStock": "{amount} agregados al stock",
-    "customAmount": "Cant..."
+    "customAmount": "Cant...",
+    "scanParts": "Escanear piezas"
   },
   "form": {
     "editPart": "Editar pieza",
@@ -67,6 +68,11 @@
     "dropOrClick": "Arrastre una imagen o haga clic",
     "replace": "Reemplazar",
     "upload": "Subir",
+    "camera": "Cámara",
+    "aiAnalyze": "IA Auto-rellenar",
+    "aiAnalyzing": "Analizando imagen...",
+    "aiAnalyzeSuccess": "Campos completados desde la imagen",
+    "aiAnalyzeFailed": "No se pudo analizar la imagen",
     "imageUploading": "Subiendo imagen...",
     "imageUploaded": "Imagen subida",
     "imageUploadFailed": "No se pudo subir la imagen",

+ 1 - 0
messages/es/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Modo claro",
     "darkMode": "Modo oscuro",
     "signOut": "Cerrar sesión",
+    "more": "Más",
     "auditLog": "Registro de auditoría",
     "aiAssistant": "Asistente IA"
   }

+ 7 - 1
messages/fr/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Modifier la pièce",
     "currentStock": "Stock actuel : {quantity}",
     "addedStock": "{amount} ajoutés au stock",
-    "customAmount": "Qté..."
+    "customAmount": "Qté...",
+    "scanParts": "Scanner des pièces"
   },
   "form": {
     "editPart": "Modifier la pièce",
@@ -67,6 +68,11 @@
     "dropOrClick": "Déposez une image ou cliquez",
     "replace": "Remplacer",
     "upload": "Télécharger",
+    "camera": "Caméra",
+    "aiAnalyze": "IA Auto-remplir",
+    "aiAnalyzing": "Analyse de l'image...",
+    "aiAnalyzeSuccess": "Champs remplis depuis l'image",
+    "aiAnalyzeFailed": "Impossible d'analyser l'image",
     "imageUploading": "Téléchargement de l'image...",
     "imageUploaded": "Image téléchargée",
     "imageUploadFailed": "Impossible de télécharger l'image",

+ 1 - 0
messages/fr/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Mode clair",
     "darkMode": "Mode sombre",
     "signOut": "Se déconnecter",
+    "more": "Plus",
     "auditLog": "Journal d'audit",
     "aiAssistant": "Assistant IA"
   }

+ 7 - 1
messages/it/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Modifica pezzo",
     "currentStock": "Stock attuale: {quantity}",
     "addedStock": "{amount} aggiunti allo stock",
-    "customAmount": "Qtà..."
+    "customAmount": "Qtà...",
+    "scanParts": "Scansiona ricambi"
   },
   "form": {
     "editPart": "Modifica ricambio",
@@ -67,6 +68,11 @@
     "dropOrClick": "Trascina un'immagine o clicca",
     "replace": "Sostituisci",
     "upload": "Carica",
+    "camera": "Fotocamera",
+    "aiAnalyze": "IA Compilazione automatica",
+    "aiAnalyzing": "Analisi dell'immagine...",
+    "aiAnalyzeSuccess": "Campi compilati dall'immagine",
+    "aiAnalyzeFailed": "Impossibile analizzare l'immagine",
     "imageUploading": "Caricamento immagine...",
     "imageUploaded": "Immagine caricata",
     "imageUploadFailed": "Impossibile caricare l'immagine",

+ 1 - 0
messages/it/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Modalità chiara",
     "darkMode": "Modalità scura",
     "signOut": "Esci",
+    "more": "Altro",
     "auditLog": "Registro attività",
     "aiAssistant": "Assistente IA"
   }

+ 7 - 1
messages/nb/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Rediger del",
     "currentStock": "Nåværende beholdning: {quantity}",
     "addedStock": "{amount} lagt til på lager",
-    "customAmount": "Antall..."
+    "customAmount": "Antall...",
+    "scanParts": "Skann deler"
   },
   "form": {
     "editPart": "Rediger del",
@@ -67,6 +68,11 @@
     "dropOrClick": "Slipp bilde eller klikk",
     "replace": "Erstatt",
     "upload": "Last opp",
+    "camera": "Kamera",
+    "aiAnalyze": "KI Autofyll",
+    "aiAnalyzing": "Analyserer bilde...",
+    "aiAnalyzeSuccess": "Felter fylt ut fra bilde",
+    "aiAnalyzeFailed": "Kunne ikke analysere bildet",
     "imageUploading": "Laster opp bilde...",
     "imageUploaded": "Bilde lastet opp",
     "imageUploadFailed": "Kunne ikke laste opp bildet",

+ 1 - 0
messages/nb/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Lyst modus",
     "darkMode": "Mørkt modus",
     "signOut": "Logg ut",
+    "more": "Mer",
     "auditLog": "Aktivitetslogg",
     "aiAssistant": "AI-assistent"
   }

+ 7 - 1
messages/nl/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Onderdeel bewerken",
     "currentStock": "Huidige voorraad: {quantity}",
     "addedStock": "{amount} toegevoegd aan voorraad",
-    "customAmount": "Aantal..."
+    "customAmount": "Aantal...",
+    "scanParts": "Onderdelen scannen"
   },
   "form": {
     "editPart": "Onderdeel bewerken",
@@ -67,6 +68,11 @@
     "dropOrClick": "Sleep afbeelding of klik",
     "replace": "Vervangen",
     "upload": "Uploaden",
+    "camera": "Camera",
+    "aiAnalyze": "AI Automatisch invullen",
+    "aiAnalyzing": "Afbeelding analyseren...",
+    "aiAnalyzeSuccess": "Velden ingevuld vanuit afbeelding",
+    "aiAnalyzeFailed": "Kan afbeelding niet analyseren",
     "imageUploading": "Afbeelding uploaden...",
     "imageUploaded": "Afbeelding geüpload",
     "imageUploadFailed": "Kan afbeelding niet uploaden",

+ 1 - 0
messages/nl/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Lichte modus",
     "darkMode": "Donkere modus",
     "signOut": "Uitloggen",
+    "more": "Meer",
     "auditLog": "Activiteitenlog",
     "aiAssistant": "AI-assistent"
   }

+ 7 - 1
messages/pl/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Edytuj część",
     "currentStock": "Aktualny stan: {quantity}",
     "addedStock": "Dodano {amount} do stanu",
-    "customAmount": "Ilość..."
+    "customAmount": "Ilość...",
+    "scanParts": "Skanuj części"
   },
   "form": {
     "editPart": "Edytuj część",
@@ -67,6 +68,11 @@
     "dropOrClick": "Upuść zdjęcie lub kliknij",
     "replace": "Zamień",
     "upload": "Prześlij",
+    "camera": "Aparat",
+    "aiAnalyze": "AI Autouzupełnianie",
+    "aiAnalyzing": "Analizowanie zdjęcia...",
+    "aiAnalyzeSuccess": "Pola uzupełnione ze zdjęcia",
+    "aiAnalyzeFailed": "Nie udało się przeanalizować zdjęcia",
     "imageUploading": "Przesyłanie zdjęcia...",
     "imageUploaded": "Zdjęcie przesłane",
     "imageUploadFailed": "Nie udało się przesłać zdjęcia",

+ 1 - 0
messages/pl/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Tryb jasny",
     "darkMode": "Tryb ciemny",
     "signOut": "Wyloguj się",
+    "more": "Więcej",
     "auditLog": "Dziennik audytu",
     "aiAssistant": "Asystent AI"
   }

+ 7 - 1
messages/pt-BR/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Editar peça",
     "currentStock": "Estoque atual: {quantity}",
     "addedStock": "{amount} adicionados ao estoque",
-    "customAmount": "Qtd..."
+    "customAmount": "Qtd...",
+    "scanParts": "Escanear peças"
   },
   "form": {
     "editPart": "Editar peça",
@@ -67,6 +68,11 @@
     "dropOrClick": "Arraste uma imagem ou clique",
     "replace": "Substituir",
     "upload": "Enviar",
+    "camera": "Câmera",
+    "aiAnalyze": "IA Preenchimento",
+    "aiAnalyzing": "Analisando imagem...",
+    "aiAnalyzeSuccess": "Campos preenchidos a partir da imagem",
+    "aiAnalyzeFailed": "Não foi possível analisar a imagem",
     "imageUploading": "Enviando imagem...",
     "imageUploaded": "Imagem enviada",
     "imageUploadFailed": "Não foi possível enviar a imagem",

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

@@ -91,6 +91,7 @@
     "lightMode": "Modo Claro",
     "darkMode": "Modo Escuro",
     "signOut": "Sair",
+    "more": "Mais",
     "auditLog": "Registro de auditoria",
     "aiAssistant": "Assistente IA"
   }

+ 7 - 1
messages/ru/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Редактировать деталь",
     "currentStock": "Текущий запас: {quantity}",
     "addedStock": "{amount} добавлено на склад",
-    "customAmount": "Кол-во..."
+    "customAmount": "Кол-во...",
+    "scanParts": "Сканировать детали"
   },
   "form": {
     "editPart": "Редактировать запчасть",
@@ -67,6 +68,11 @@
     "dropOrClick": "Перетащите или нажмите",
     "replace": "Заменить",
     "upload": "Загрузить",
+    "camera": "Камера",
+    "aiAnalyze": "ИИ Автозаполнение",
+    "aiAnalyzing": "Анализ изображения...",
+    "aiAnalyzeSuccess": "Поля заполнены из изображения",
+    "aiAnalyzeFailed": "Не удалось проанализировать изображение",
     "imageUploading": "Загрузка изображения...",
     "imageUploaded": "Изображение загружено",
     "imageUploadFailed": "Не удалось загрузить изображение",

+ 1 - 0
messages/ru/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Светлая тема",
     "darkMode": "Тёмная тема",
     "signOut": "Выйти",
+    "more": "Ещё",
     "auditLog": "Журнал аудита",
     "aiAssistant": "ИИ-ассистент"
   }

+ 7 - 1
messages/tr/inventory.json

@@ -53,7 +53,8 @@
     "editPart": "Parçayı düzenle",
     "currentStock": "Mevcut stok: {quantity}",
     "addedStock": "Stoka {amount} eklendi",
-    "customAmount": "Adet..."
+    "customAmount": "Adet...",
+    "scanParts": "Parça tara"
   },
   "form": {
     "editPart": "Parçayı düzenle",
@@ -67,6 +68,11 @@
     "dropOrClick": "Görsel bırakın veya tıklayın",
     "replace": "Değiştir",
     "upload": "Yükle",
+    "camera": "Kamera",
+    "aiAnalyze": "AI Otomatik Doldur",
+    "aiAnalyzing": "Görsel analiz ediliyor...",
+    "aiAnalyzeSuccess": "Alanlar görselden dolduruldu",
+    "aiAnalyzeFailed": "Görsel analiz edilemedi",
     "imageUploading": "Görsel yükleniyor...",
     "imageUploaded": "Görsel yüklendi",
     "imageUploadFailed": "Görsel yüklenemedi",

+ 1 - 0
messages/tr/navigation.json

@@ -91,6 +91,7 @@
     "lightMode": "Açık Mod",
     "darkMode": "Koyu Mod",
     "signOut": "Çıkış Yap",
+    "more": "Diğer",
     "auditLog": "Denetim günlüğü",
     "aiAssistant": "AI Asistanı"
   }

+ 14 - 0
package-lock.json

@@ -63,6 +63,7 @@
         "sonner": "^2.0.7",
         "stripe": "^20.3.1",
         "tailwind-merge": "^3.4.0",
+        "vaul": "^1.1.2",
         "ws": "^8.19.0",
         "zod": "^4.3.6",
         "zustand": "^5.0.11"
@@ -18385,6 +18386,19 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/vaul": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
+      "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-dialog": "^1.1.1"
+      },
+      "peerDependencies": {
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+      }
+    },
     "node_modules/vfile": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

+ 1 - 0
package.json

@@ -71,6 +71,7 @@
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
+    "vaul": "^1.1.2",
     "ws": "^8.19.0",
     "zod": "^4.3.6",
     "zustand": "^5.0.11"

+ 21 - 0
prisma/migrations/20260324212722_multi_part_images/migration.sql

@@ -0,0 +1,21 @@
+-- AlterTable
+ALTER TABLE "inventory_parts" ADD COLUMN     "images" TEXT[] DEFAULT ARRAY[]::TEXT[];
+
+-- CreateTable
+CREATE TABLE "stored_images" (
+    "id" TEXT NOT NULL,
+    "url" TEXT NOT NULL,
+    "fileName" TEXT,
+    "description" TEXT,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "inventoryPartId" TEXT,
+
+    CONSTRAINT "stored_images_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "stored_images_inventoryPartId_idx" ON "stored_images"("inventoryPartId");
+
+-- AddForeignKey
+ALTER TABLE "stored_images" ADD CONSTRAINT "stored_images_inventoryPartId_fkey" FOREIGN KEY ("inventoryPartId") REFERENCES "inventory_parts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 17 - 0
prisma/schema.prisma

@@ -426,10 +426,27 @@ model InventoryPart {
   organizationId String?
   organization   Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
+  gallery StoredImage[] @relation("InventoryPartImages")
+
   @@index([organizationId])
   @@map("inventory_parts")
 }
 
+model StoredImage {
+  id          String   @id @default(cuid())
+  url         String
+  fileName    String?
+  description String?
+  sortOrder   Int      @default(0)
+  createdAt   DateTime @default(now())
+
+  inventoryPartId String?
+  inventoryPart   InventoryPart? @relation("InventoryPartImages", fields: [inventoryPartId], references: [id], onDelete: Cascade)
+
+  @@index([inventoryPartId])
+  @@map("stored_images")
+}
+
 model Quote {
   id              String    @id @default(cuid())
   quoteNumber     String?

+ 15 - 1
src/__tests__/multitenancy/customer-inventory-isolation.test.ts

@@ -19,6 +19,11 @@ vi.mock("@/lib/resolve-upload-path", () => ({
   resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
 }));
 
+vi.mock("fs/promises", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("fs/promises")>();
+  return { ...actual, default: actual, unlink: vi.fn().mockResolvedValue(undefined) };
+});
+
 vi.mock("@/lib/features", () => ({
   getFeatures: vi.fn().mockResolvedValue({ maxCustomers: 1000 }),
   FeatureGatedError: class FeatureGatedError extends Error {
@@ -42,6 +47,11 @@ vi.mock("@/lib/db", () => ({
       deleteMany: vi.fn(),
       update: vi.fn(),
     },
+    storedImage: {
+      findMany: vi.fn().mockResolvedValue([]),
+      deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+      createMany: vi.fn().mockResolvedValue({ count: 0 }),
+    },
   },
 }));
 
@@ -103,6 +113,10 @@ const ORG_A_PART = {
 
 beforeEach(() => {
   vi.resetAllMocks();
+  // Re-set storedImage defaults after reset (used by inventory gallery operations)
+  vi.mocked(db.storedImage.findMany).mockResolvedValue([]);
+  vi.mocked(db.storedImage.deleteMany).mockResolvedValue({ count: 0 } as any);
+  vi.mocked(db.storedImage.createMany).mockResolvedValue({ count: 0 } as any);
 });
 
 // ---------------------------------------------------------------------------
@@ -365,7 +379,7 @@ describe("deleteInventoryPart — cross-org isolation", () => {
 
   it("successfully deletes the caller's own inventory part", async () => {
     setupOrgAOwner();
-    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null } as any);
+    vi.mocked(db.inventoryPart.findFirst).mockResolvedValue({ imageUrl: null, gallery: [] } as any);
     vi.mocked(db.inventoryPart.deleteMany).mockResolvedValue({ count: 1 } as any);
 
     const result = await deleteInventoryPart("part-a");

+ 82 - 16
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -51,10 +51,13 @@ import {
   MoreVertical,
   Pencil,
   Percent,
+  ChevronLeft,
+  ChevronRight,
   Plus,
   ScanBarcode,
   Search,
   Trash2,
+  X,
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 
@@ -74,6 +77,7 @@ interface InventoryPart {
   supplierEmail: string | null;
   supplierUrl: string | null;
   imageUrl: string | null;
+  gallery: { id: string; url: string; fileName: string | null; description: string | null; sortOrder: number }[];
   location: string | null;
 }
 
@@ -112,6 +116,8 @@ export function InventoryClient({
   const [markupValue, setMarkupValue] = useState(String(initialMarkup));
   const [applyingMarkup, setApplyingMarkup] = useState(false);
   const [showScanner, setShowScanner] = useState(false);
+  const [galleryImages, setGalleryImages] = useState<string[]>([]);
+  const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
   const [scannedPart, setScannedPart] = useState<{
     id: string;
     name: string;
@@ -210,7 +216,7 @@ export function InventoryClient({
   return (
     <div className="space-y-4">
       {/* Toolbar */}
-      <div className="flex items-center justify-between gap-3">
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
         <div className="flex flex-1 items-center gap-2">
           <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" />
@@ -225,7 +231,7 @@ export function InventoryClient({
             value={category || "all"}
             onValueChange={(v) => navigate({ category: v === "all" ? undefined : v })}
           >
-            <SelectTrigger className="w-[150px]">
+            <SelectTrigger className="w-[120px] sm:w-[150px]">
               <SelectValue placeholder={t('categoryPlaceholder')} />
             </SelectTrigger>
             <SelectContent>
@@ -239,19 +245,21 @@ export function InventoryClient({
           </Select>
           {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
         </div>
-        <div className="flex items-center gap-2">
+        <div className="flex items-center justify-between gap-2">
           <Button size="sm" variant="outline" onClick={() => setShowScanner(true)}>
-            <ScanBarcode className="mr-1 h-3.5 w-3.5" />
-            {t('scanBarcode')}
-          </Button>
-          <Button size="sm" variant="outline" onClick={() => setShowMarkup(true)}>
-            <Percent className="mr-1 h-3.5 w-3.5" />
-            {t('applyMarkup')}
-          </Button>
-          <Button size="sm" onClick={() => setShowForm(true)}>
-            <Plus className="mr-1 h-3.5 w-3.5" />
-            {t('addPart')}
+            <ScanBarcode className="h-3.5 w-3.5 sm:mr-1" />
+            <span className="hidden sm:inline">{t('scanBarcode')}</span>
           </Button>
+          <div className="flex items-center gap-2">
+            <Button size="sm" variant="outline" onClick={() => setShowMarkup(true)}>
+              <Percent className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('applyMarkup')}</span>
+            </Button>
+            <Button size="sm" onClick={() => setShowForm(true)}>
+              <Plus className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('addPart')}</span>
+            </Button>
+          </div>
         </div>
       </div>
 
@@ -307,11 +315,20 @@ export function InventoryClient({
                     </TableCell>
                     <TableCell className="font-medium">
                       <div className="flex items-center gap-2">
-                        {part.imageUrl && (
+                        {(part.gallery[0]?.url || part.imageUrl) && (
                           <img
-                            src={part.imageUrl}
+                            src={part.gallery[0]?.url || part.imageUrl!}
                             alt={part.name}
-                            className="h-8 w-8 rounded object-cover"
+                            className="h-8 w-8 cursor-pointer rounded object-cover hover:opacity-80"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              const imgs = part.gallery.length > 0
+                                ? part.gallery.map(g => g.url)
+                                : part.imageUrl ? [part.imageUrl] : [];
+                              if (imgs.length === 0) return;
+                              setGalleryImages(imgs);
+                              setGalleryIndex(0);
+                            }}
                           />
                         )}
                         {part.name}
@@ -426,6 +443,10 @@ export function InventoryClient({
         part={editPart ?? undefined}
         markupMultiplier={initialMarkup}
         initialBarcode={!editPart ? scannedBarcode : undefined}
+        onViewImages={(urls, startIndex) => {
+          setGalleryImages(urls);
+          setGalleryIndex(startIndex);
+        }}
       />
 
       {/* Bulk Markup Dialog */}
@@ -488,6 +509,51 @@ export function InventoryClient({
           setShowForm(true);
         }}
       />
+
+      {galleryIndex !== null && galleryImages.length > 0 && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
+          onClick={() => setGalleryIndex(null)}
+        >
+          <button
+            type="button"
+            onClick={() => setGalleryIndex(null)}
+            className="absolute top-3 right-3 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70"
+          >
+            <X className="h-5 w-5" />
+          </button>
+          {galleryImages.length > 1 && (
+            <div className="absolute top-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm font-medium text-white">
+              {galleryIndex + 1} / {galleryImages.length}
+            </div>
+          )}
+          {galleryIndex > 0 && (
+            <button
+              type="button"
+              onClick={(e) => { e.stopPropagation(); setGalleryIndex(galleryIndex - 1); }}
+              className="absolute left-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 sm:left-4 sm:h-12 sm:w-12"
+            >
+              <ChevronLeft className="h-6 w-6" />
+            </button>
+          )}
+          {galleryIndex < galleryImages.length - 1 && (
+            <button
+              type="button"
+              onClick={(e) => { e.stopPropagation(); setGalleryIndex(galleryIndex + 1); }}
+              className="absolute right-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 sm:right-4 sm:h-12 sm:w-12"
+            >
+              <ChevronRight className="h-6 w-6" />
+            </button>
+          )}
+          <img
+            src={galleryImages[galleryIndex]}
+            alt={`Image ${galleryIndex + 1}`}
+            className="max-h-[85vh] max-w-[90vw] object-contain"
+            draggable={false}
+            onClick={(e) => e.stopPropagation()}
+          />
+        </div>
+      )}
     </div>
   );
 }

+ 5 - 1
src/app/(authenticated)/layout.tsx

@@ -12,6 +12,7 @@ import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { OnlineTracker } from "@/components/online-tracker";
 import { InstallBanner } from "@/components/pwa-install-prompt";
+import { MobileBottomNav } from "@/components/mobile-bottom-nav";
 import { ServiceTypeProvider } from "@/components/service-type-context";
 import { db } from "@/lib/db";
 
@@ -103,7 +104,10 @@ export default async function DashboardLayout({
           visibleSubjects={visibleSubjects}
           isAdminOrOwner={isOwnerOrAdmin}
         />
-        <SidebarInset>{children}</SidebarInset>
+        <SidebarInset>
+          <div className="pb-14 md:pb-0">{children}</div>
+        </SidebarInset>
+        <MobileBottomNav />
         <SearchCommand />
         {isOwnerOrAdmin && <NotificationInitializer />}
         <OnlineTracker />

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

@@ -563,7 +563,7 @@ export function VehicleDetailClient({
         </div>
 
         {/* Quick stats */}
-        <div className="flex items-center gap-4 text-sm">
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
           <div className="flex items-center gap-1.5 text-muted-foreground">
             <Gauge className="h-3.5 w-3.5" />
             <span className="font-semibold text-foreground">
@@ -975,8 +975,8 @@ export function VehicleDetailClient({
       <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
         <TabsList className="grid w-full grid-cols-5">
           <TabsTrigger value="services" className="gap-1.5">
-            <Wrench className="h-3.5 w-3.5" />
-            {t('tabs.services')}
+            <Wrench className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.services')}</span>
             {vehicle._count.serviceRecords > 0 && (
               <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
                 {vehicle._count.serviceRecords}
@@ -984,8 +984,8 @@ export function VehicleDetailClient({
             )}
           </TabsTrigger>
           <TabsTrigger value="quotes" className="gap-1.5">
-            <FileText className="h-3.5 w-3.5" />
-            {t('tabs.quotes')}
+            <FileText className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.quotes')}</span>
             {quotes.length > 0 && (
               <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
                 {quotes.length}
@@ -993,8 +993,8 @@ export function VehicleDetailClient({
             )}
           </TabsTrigger>
           <TabsTrigger value="inspections" className="gap-1.5">
-            <ClipboardCheck className="h-3.5 w-3.5" />
-            {t('tabs.inspections')}
+            <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.inspections')}</span>
             {inspections && inspections.length > 0 && (
               <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
                 {inspections.length}
@@ -1002,8 +1002,8 @@ export function VehicleDetailClient({
             )}
           </TabsTrigger>
           <TabsTrigger value="notes" className="gap-1.5">
-            <StickyNote className="h-3.5 w-3.5" />
-            {t('tabs.notes')}
+            <StickyNote className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.notes')}</span>
             {vehicle._count.notes > 0 && (
               <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
                 {vehicle._count.notes}
@@ -1011,8 +1011,8 @@ export function VehicleDetailClient({
             )}
           </TabsTrigger>
           <TabsTrigger value="reminders" className="gap-1.5">
-            <Bell className="h-3.5 w-3.5" />
-            {t('tabs.reminders')}
+            <Bell className="h-3.5 w-3.5 shrink-0" />
+            <span className="hidden sm:inline">{t('tabs.reminders')}</span>
             {overdueCount > 0 ? (
               <Badge variant="destructive" className="ml-1 h-5 min-w-5 px-1 text-[10px]">
                 {overdueCount}

+ 168 - 0
src/components/mobile-bottom-nav.tsx

@@ -0,0 +1,168 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import {
+  Bell,
+  CalendarDays,
+  Car,
+  ClipboardCheck,
+  ClipboardList,
+  Columns3,
+  FileText,
+  History,
+  Layers,
+  Menu,
+  Package,
+  Receipt,
+  ScanBarcode,
+  Settings,
+  Users,
+} from 'lucide-react'
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerTitle,
+} from '@/components/ui/drawer'
+import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
+import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
+import { adjustInventoryStock } from '@/features/inventory/Actions/inventoryActions'
+import { Separator } from '@/components/ui/separator'
+import { cn } from '@/lib/utils'
+import { toast } from 'sonner'
+
+const primaryItems = [
+  { href: '/vehicles', icon: Car, labelKey: 'vehicles' },
+  { href: '/work-orders', icon: ClipboardList, labelKey: 'workOrders' },
+  { href: '/customers', icon: Users, labelKey: 'customers' },
+  { href: '/inventory', icon: Package, labelKey: 'inventory' },
+] as const
+
+const drawerItems = [
+  { href: '/calendar', icon: CalendarDays, labelKey: 'calendar' },
+  { href: '/work-board', icon: Columns3, labelKey: 'workBoard' },
+  { href: '/quotes', icon: FileText, labelKey: 'quotes' },
+  { href: '/inspections', icon: ClipboardCheck, labelKey: 'inspections' },
+  { href: '/reminders', icon: Bell, labelKey: 'reminders' },
+  { href: '/billing', icon: Receipt, labelKey: 'billing' },
+  { href: '/labor-presets', icon: Layers, labelKey: 'laborPresets' },
+  { href: '/audit-log', icon: History, labelKey: 'auditLog' },
+  { href: '/settings', icon: Settings, labelKey: 'settings' },
+] as const
+
+export function MobileBottomNav() {
+  const pathname = usePathname()
+  const router = useRouter()
+  const t = useTranslations('navigation.sidebar')
+  const tScan = useTranslations('inventory.barcodeScan')
+  const [drawerOpen, setDrawerOpen] = useState(false)
+  const [scannerOpen, setScannerOpen] = useState(false)
+
+  const isMoreActive = drawerItems.some(
+    (item) => pathname === item.href || pathname.startsWith(`${item.href}/`)
+  )
+
+  const handleBarcodeScan = async (barcode: string) => {
+    const result = await lookupPartByBarcode(barcode)
+    if (result.success && result.data) {
+      const part = result.data
+      await adjustInventoryStock({ id: part.id, adjustment: 1 })
+      toast.success(tScan('addedStock', { amount: 1 }))
+      router.refresh()
+    } else {
+      toast.error(tScan('notFound', { barcode }))
+    }
+  }
+
+  return (
+    <>
+      <nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden">
+        <div className="grid grid-cols-5">
+          {primaryItems.map(({ href, icon: Icon, labelKey }) => {
+            const isActive = pathname === href || pathname.startsWith(`${href}/`)
+            return (
+              <Link
+                key={href}
+                href={href}
+                className={cn(
+                  'flex flex-col items-center gap-0.5 py-2 text-[10px]',
+                  isActive ? 'text-primary' : 'text-muted-foreground'
+                )}
+              >
+                <Icon className="h-5 w-5" />
+                <span>{t(labelKey)}</span>
+              </Link>
+            )
+          })}
+          <button
+            type="button"
+            onClick={() => setDrawerOpen(true)}
+            className={cn(
+              'flex flex-col items-center gap-0.5 py-2 text-[10px]',
+              isMoreActive ? 'text-primary' : 'text-muted-foreground'
+            )}
+          >
+            <Menu className="h-5 w-5" />
+            <span>{t('more')}</span>
+          </button>
+        </div>
+      </nav>
+
+      <Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
+        <DrawerContent>
+          <DrawerHeader className="sr-only">
+            <DrawerTitle>{t('more')}</DrawerTitle>
+          </DrawerHeader>
+          <div className="p-4">
+            {/* Quick action */}
+            <button
+              type="button"
+              onClick={() => {
+                setDrawerOpen(false)
+                setTimeout(() => setScannerOpen(true), 200)
+              }}
+              className="flex w-full items-center gap-3 rounded-lg bg-primary/5 px-4 py-3 text-sm font-medium text-primary transition-colors hover:bg-primary/10"
+            >
+              <ScanBarcode className="h-5 w-5" />
+              {tScan('scanParts')}
+            </button>
+
+            <Separator className="my-4" />
+
+            {/* Navigation grid */}
+            <nav className="grid grid-cols-4 gap-2">
+              {drawerItems.map(({ href, icon: Icon, labelKey }) => {
+                const isActive = pathname === href || pathname.startsWith(`${href}/`)
+                return (
+                  <Link
+                    key={href}
+                    href={href}
+                    onClick={() => setDrawerOpen(false)}
+                    className={cn(
+                      'flex flex-col items-center gap-1.5 rounded-lg py-3 text-xs transition-colors',
+                      isActive
+                        ? 'bg-primary/10 text-primary'
+                        : 'text-muted-foreground hover:bg-accent'
+                    )}
+                  >
+                    <Icon className="h-5 w-5" />
+                    <span>{t(labelKey)}</span>
+                  </Link>
+                )
+              })}
+            </nav>
+          </div>
+        </DrawerContent>
+      </Drawer>
+
+      <BarcodeScannerDialog
+        open={scannerOpen}
+        onOpenChange={setScannerOpen}
+        onScan={handleBarcodeScan}
+      />
+    </>
+  )
+}

+ 135 - 0
src/components/ui/drawer.tsx

@@ -0,0 +1,135 @@
+"use client"
+
+import * as React from "react"
+import { Drawer as DrawerPrimitive } from "vaul"
+
+import { cn } from "@/lib/utils"
+
+function Drawer({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
+  return <DrawerPrimitive.Root data-slot="drawer" {...props} />
+}
+
+function DrawerTrigger({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
+  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
+}
+
+function DrawerPortal({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
+  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
+}
+
+function DrawerClose({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
+  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
+}
+
+function DrawerOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
+  return (
+    <DrawerPrimitive.Overlay
+      data-slot="drawer-overlay"
+      className={cn(
+        "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DrawerContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
+  return (
+    <DrawerPortal data-slot="drawer-portal">
+      <DrawerOverlay />
+      <DrawerPrimitive.Content
+        data-slot="drawer-content"
+        className={cn(
+          "group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
+          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+          "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
+          "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
+          className
+        )}
+        {...props}
+      >
+        <div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
+        {children}
+      </DrawerPrimitive.Content>
+    </DrawerPortal>
+  )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-header"
+      className={cn(
+        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  )
+}
+
+function DrawerTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
+  return (
+    <DrawerPrimitive.Title
+      data-slot="drawer-title"
+      className={cn("font-semibold text-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function DrawerDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
+  return (
+    <DrawerPrimitive.Description
+      data-slot="drawer-description"
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Drawer,
+  DrawerPortal,
+  DrawerOverlay,
+  DrawerTrigger,
+  DrawerClose,
+  DrawerContent,
+  DrawerHeader,
+  DrawerFooter,
+  DrawerTitle,
+  DrawerDescription,
+}

+ 60 - 0
src/features/inventory/Actions/aiAnalyzePartImage.ts

@@ -0,0 +1,60 @@
+"use server";
+
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { visionCompletion } from "@/lib/ai";
+import { getLocale } from "next-intl/server";
+import { localeNames, type Locale } from "@/i18n/config";
+
+export interface PartAnalysisResult {
+  name?: string;
+  partNumber?: string;
+  barcode?: string;
+  category?: string;
+  description?: string;
+  supplier?: string;
+}
+
+export async function aiAnalyzePartImage(imageDataUris: string | string[]) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const locale = (await getLocale()) as Locale;
+      const langName = localeNames[locale] || "English";
+
+      const uris = Array.isArray(imageDataUris) ? imageDataUris : [imageDataUris];
+      const imageCount = uris.length;
+
+      const systemPrompt = `You are an expert automotive parts identifier. Analyze the ${imageCount > 1 ? `${imageCount} images` : "image"} of a part or its packaging and extract as much information as possible. ${imageCount > 1 ? "The images show the same part from different angles. Combine information from all images." : ""} Return ONLY valid JSON with these fields (omit fields you cannot determine):
+{
+  "name": "part name",
+  "partNumber": "manufacturer part number",
+  "barcode": "barcode/UPC/EAN number if visible",
+  "category": "part category (e.g. Brakes, Filters, Engine, Electrical)",
+  "description": "brief description of the part",
+  "supplier": "manufacturer or brand name"
+}
+
+${locale !== "en" ? `Respond with values in ${langName} where appropriate (name, category, description). Keep partNumber, barcode, and supplier in their original form.` : ""}`;
+
+      const userText = imageCount > 1
+        ? `Analyze these ${imageCount} images of the same automotive/repair part from different angles and extract all identifiable information.`
+        : "Analyze this automotive/repair part image and extract all identifiable information.";
+
+      const raw = await visionCompletion(
+        organizationId,
+        systemPrompt,
+        userText,
+        uris,
+      );
+
+      const cleaned = raw.replace(/^```json?\n?|\n?```$/g, "").trim();
+      const result: PartAnalysisResult = JSON.parse(cleaned);
+      return result;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}

+ 65 - 29
src/features/inventory/Actions/inventoryActions.ts

@@ -47,6 +47,7 @@ export async function getInventoryPartsPaginated(params: {
         orderBy: { updatedAt: "desc" },
         skip,
         take: pageSize,
+        include: { gallery: { orderBy: { sortOrder: "asc" } } },
       }),
       db.inventoryPart.count({ where }),
     ]);
@@ -65,6 +66,7 @@ export async function getInventoryPart(partId: string) {
   return withAuth(async ({ userId, organizationId }) => {
     const part = await db.inventoryPart.findFirst({
       where: { id: partId, organizationId },
+      include: { gallery: { orderBy: { sortOrder: "asc" } } },
     });
     if (!part) throw new Error("Part not found");
     return part;
@@ -74,23 +76,37 @@ export async function getInventoryPart(partId: string) {
 export async function createInventoryPart(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const data = createInventoryPartSchema.parse(input);
+    const { gallery, ...rest } = data;
     const part = await db.inventoryPart.create({
       data: {
-        ...data,
-        partNumber: data.partNumber || undefined,
-        barcode: data.barcode || undefined,
-        description: data.description || undefined,
-        category: data.category || undefined,
-        supplier: data.supplier || undefined,
-        supplierPhone: data.supplierPhone || undefined,
-        supplierEmail: data.supplierEmail || undefined,
-        supplierUrl: data.supplierUrl || undefined,
-        imageUrl: data.imageUrl || undefined,
-        location: data.location || undefined,
+        ...rest,
+        partNumber: rest.partNumber || undefined,
+        barcode: rest.barcode || undefined,
+        description: rest.description || undefined,
+        category: rest.category || undefined,
+        supplier: rest.supplier || undefined,
+        supplierPhone: rest.supplierPhone || undefined,
+        supplierEmail: rest.supplierEmail || undefined,
+        supplierUrl: rest.supplierUrl || undefined,
+        imageUrl: gallery?.[0]?.url || undefined,
+        location: rest.location || undefined,
         userId,
         organizationId,
       },
     });
+
+    if (gallery && gallery.length > 0) {
+      await db.storedImage.createMany({
+        data: gallery.map((img, i) => ({
+          url: img.url,
+          fileName: img.fileName || null,
+          description: img.description || null,
+          sortOrder: i,
+          inventoryPartId: part.id,
+        })),
+      });
+    }
+
     revalidatePath("/inventory");
     return part;
   }, {
@@ -108,21 +124,36 @@ export async function createInventoryPart(input: unknown) {
 export async function updateInventoryPart(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const data = updateInventoryPartSchema.parse(input);
-    const { id, ...updateData } = data;
+    const { id, gallery: galleryData, ...updateData } = data;
 
-    // If image is being changed, delete the old file
-    if (updateData.imageUrl !== undefined) {
-      const existing = await db.inventoryPart.findFirst({
-        where: { id, organizationId },
-        select: { imageUrl: true },
-      });
-      if (existing?.imageUrl && existing.imageUrl !== updateData.imageUrl) {
-        try {
-          await unlink(resolveUploadPath(existing.imageUrl));
-        } catch {
-          // Old file may already be gone
+    // Handle gallery updates
+    if (galleryData !== undefined) {
+      // Clean up old files that are no longer in the gallery
+      const existingImages = await db.storedImage.findMany({ where: { inventoryPartId: id } });
+      const newUrls = new Set(galleryData.map(g => g.url));
+      for (const old of existingImages) {
+        if (!newUrls.has(old.url)) {
+          try { await unlink(resolveUploadPath(old.url)); } catch { /* already gone */ }
         }
       }
+      // Replace all gallery records
+      await db.storedImage.deleteMany({ where: { inventoryPartId: id } });
+      if (galleryData.length > 0) {
+        await db.storedImage.createMany({
+          data: galleryData.map((img, i) => ({
+            url: img.url,
+            fileName: img.fileName || null,
+            description: img.description || null,
+            sortOrder: i,
+            inventoryPartId: id,
+          })),
+        });
+      }
+      // Update imageUrl for backward compat
+      await db.inventoryPart.updateMany({
+        where: { id, organizationId },
+        data: { imageUrl: galleryData[0]?.url || null },
+      });
     }
 
     const result = await db.inventoryPart.updateMany({
@@ -159,7 +190,7 @@ export async function deleteInventoryPart(partId: string) {
   return withAuth(async ({ userId, organizationId }) => {
     const part = await db.inventoryPart.findFirst({
       where: { id: partId, organizationId },
-      select: { imageUrl: true },
+      select: { imageUrl: true, gallery: { select: { url: true } } },
     });
 
     const result = await db.inventoryPart.deleteMany({
@@ -167,11 +198,15 @@ export async function deleteInventoryPart(partId: string) {
     });
     if (result.count === 0) throw new Error("Part not found");
 
-    if (part?.imageUrl) {
-      try {
-        await unlink(resolveUploadPath(part.imageUrl));
-      } catch {
-        // File may already be gone
+    if (part) {
+      const allUrls = [
+        part.imageUrl,
+        ...part.gallery.map(g => g.url),
+      ].filter(Boolean) as string[];
+      // Deduplicate URLs before deleting files
+      const uniqueUrls = [...new Set(allUrls)];
+      for (const url of uniqueUrls) {
+        try { await unlink(resolveUploadPath(url)); } catch { /* already gone */ }
       }
     }
 
@@ -235,6 +270,7 @@ export async function getInventoryPartsList() {
         sellPrice: true,
         quantity: true,
         category: true,
+        gallery: { select: { id: true, url: true, sortOrder: true }, orderBy: { sortOrder: "asc" } },
       },
       orderBy: { name: "asc" },
     });

+ 206 - 76
src/features/inventory/Components/InventoryPartForm.tsx

@@ -10,13 +10,15 @@ import { Textarea } from "@/components/ui/textarea";
 import {
   Dialog,
   DialogContent,
+  DialogDescription,
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
 import { toast } from "sonner";
 import { useGlassModal } from "@/components/glass-modal";
 import { createInventoryPart, updateInventoryPart } from "../Actions/inventoryActions";
-import { ExternalLink, ImageIcon, Loader2, Upload, X } from "lucide-react";
+import { aiAnalyzePartImage } from "../Actions/aiAnalyzePartImage";
+import { Camera, ExternalLink, ImageIcon, Loader2, Plus, Sparkles, Upload, X } from "lucide-react";
 import { compressImage } from "@/lib/compress-image";
 
 interface InventoryPartFormProps {
@@ -24,6 +26,7 @@ interface InventoryPartFormProps {
   onOpenChange: (open: boolean) => void;
   markupMultiplier?: number;
   initialBarcode?: string;
+  onViewImages?: (urls: string[], startIndex: number) => void;
   part?: {
     id: string;
     partNumber: string | null;
@@ -40,22 +43,29 @@ interface InventoryPartFormProps {
     supplierEmail: string | null;
     supplierUrl: string | null;
     imageUrl: string | null;
+    gallery: { id?: string; url: string; fileName?: string | null; description?: string | null; sortOrder: number }[];
     location: string | null;
   };
 }
 
-export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier, initialBarcode }: InventoryPartFormProps) {
+export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier, initialBarcode, onViewImages }: InventoryPartFormProps) {
   const router = useRouter();
   const modal = useGlassModal();
   const t = useTranslations('inventory');
   const [loading, setLoading] = useState(false);
   const [fetching, setFetching] = useState(false);
   const [uploading, setUploading] = useState(false);
+  const [analyzing, setAnalyzing] = useState(false);
   const [supplierUrl, setSupplierUrl] = useState(part?.supplierUrl ?? "");
-  const [imageUrl, setImageUrl] = useState(part?.imageUrl ?? "");
+  const [gallery, setGallery] = useState<{ id?: string; url: string; fileName?: string | null; description?: string | null; sortOrder: number }[]>(() => {
+    if (part?.gallery && part.gallery.length > 0) return part.gallery;
+    if (part?.imageUrl) return [{ url: part.imageUrl, sortOrder: 0 }];
+    return [];
+  });
   const [dragOver, setDragOver] = useState(false);
   const formRef = useRef<HTMLFormElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const cameraInputRef = useRef<HTMLInputElement>(null);
 
   const uploadFile = useCallback(async (file: File) => {
     const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/avif"];
@@ -84,7 +94,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
         return;
       }
       const { url } = await res.json();
-      setImageUrl(url);
+      setGallery((prev) => [...prev, { url, sortOrder: prev.length }]);
       toast.success(t('form.imageUploaded'), { id: toastId });
     } catch {
       toast.error(t('form.imageUploadFailed'), { id: toastId });
@@ -95,8 +105,12 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
 
   const handleFileChange = useCallback(
     (e: React.ChangeEvent<HTMLInputElement>) => {
-      const file = e.target.files?.[0];
-      if (file) uploadFile(file);
+      const files = e.target.files;
+      if (files) {
+        for (const file of Array.from(files)) {
+          uploadFile(file);
+        }
+      }
       e.target.value = "";
     },
     [uploadFile]
@@ -160,15 +174,76 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
           unitCostInput.value = String(metadata.unitCost);
         }
       }
-      if (metadata.imageUrl && !imageUrl) {
-        setImageUrl(metadata.imageUrl);
+      if (metadata.imageUrl && gallery.length === 0) {
+        setGallery([{ url: metadata.imageUrl, sortOrder: 0 }]);
       }
     } catch {
       modal.open("error", t('form.fetchFailed'), t('form.fetchError'));
     } finally {
       setFetching(false);
     }
-  }, [supplierUrl, imageUrl, modal]);
+  }, [supplierUrl, gallery, modal]);
+
+  const handleAiAnalyze = useCallback(async () => {
+    if (gallery.length === 0) return;
+    setAnalyzing(true);
+    const toastId = toast.loading(t('form.aiAnalyzing'));
+    try {
+      // Convert images to JPEG base64 on client (AI APIs require supported formats)
+      const dataUris = await Promise.all(gallery.map(g =>
+        new Promise<string>((resolve) => {
+          const img = new window.Image();
+          img.crossOrigin = "anonymous";
+          img.onload = () => {
+            const maxEdge = 1024;
+            let { naturalWidth: w, naturalHeight: h } = img;
+            if (w > maxEdge || h > maxEdge) {
+              const scale = maxEdge / Math.max(w, h);
+              w = Math.round(w * scale);
+              h = Math.round(h * scale);
+            }
+            const canvas = document.createElement("canvas");
+            canvas.width = w;
+            canvas.height = h;
+            const ctx = canvas.getContext("2d");
+            if (!ctx) { resolve(g.url); return; }
+            ctx.drawImage(img, 0, 0, w, h);
+            resolve(canvas.toDataURL("image/jpeg", 0.7));
+          };
+          img.onerror = () => resolve(g.url);
+          img.src = g.url;
+        })
+      ));
+      const result = await aiAnalyzePartImage(dataUris);
+      if (!result.success || !result.data) {
+        toast.error(result.error || t('form.aiAnalyzeFailed'), { id: toastId });
+        return;
+      }
+      const data = result.data;
+      const form = formRef.current;
+      if (!form) return;
+
+      const setIfEmpty = (name: string, value: string | undefined) => {
+        if (!value) return;
+        const input = form.elements.namedItem(name) as HTMLInputElement | HTMLTextAreaElement | null;
+        if (input && !input.value) {
+          input.value = value;
+        }
+      };
+
+      setIfEmpty("name", data.name);
+      setIfEmpty("partNumber", data.partNumber);
+      setIfEmpty("barcode", data.barcode);
+      setIfEmpty("category", data.category);
+      setIfEmpty("description", data.description);
+      setIfEmpty("supplier", data.supplier);
+      toast.success(t('form.aiAnalyzeSuccess'), { id: toastId });
+    } catch {
+      toast.error(t('form.aiAnalyzeFailed'), { id: toastId });
+    } finally {
+      setAnalyzing(false);
+    }
+  }, [gallery, t]);
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
@@ -189,7 +264,13 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
       supplierPhone: (formData.get("supplierPhone") as string) || undefined,
       supplierEmail: (formData.get("supplierEmail") as string) || undefined,
       supplierUrl: supplierUrl || undefined,
-      imageUrl: imageUrl || undefined,
+      gallery: gallery.map((g, i) => ({
+        id: g.id,
+        url: g.url,
+        fileName: g.fileName || undefined,
+        description: g.description || undefined,
+        sortOrder: i,
+      })),
       location: (formData.get("location") as string) || undefined,
     };
 
@@ -210,7 +291,13 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
   const handleOpenChange = (isOpen: boolean) => {
     if (isOpen) {
       setSupplierUrl(part?.supplierUrl ?? "");
-      setImageUrl(part?.imageUrl ?? "");
+      if (part?.gallery && part.gallery.length > 0) {
+        setGallery(part.gallery);
+      } else if (part?.imageUrl) {
+        setGallery([{ url: part.imageUrl, sortOrder: 0 }]);
+      } else {
+        setGallery([]);
+      }
     }
     onOpenChange(isOpen);
   };
@@ -222,6 +309,9 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
           <DialogTitle>
             {part ? t('form.editPart') : t('form.addNewPart')}
           </DialogTitle>
+          <DialogDescription className="sr-only">
+            {part ? t('form.editPart') : t('form.addNewPart')}
+          </DialogDescription>
         </DialogHeader>
 
         <form ref={formRef} onSubmit={handleSubmit} className="space-y-4">
@@ -264,89 +354,129 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
             </div>
           </div>
 
-          {/* Two-column layout: image left, fields right */}
-          <div className="flex gap-5">
+          {/* Image + fields: stacked on mobile, side-by-side on sm+ */}
+          <div className="flex flex-col gap-4 sm:flex-row sm:gap-5">
             {/* Image area */}
-            <div className="flex-shrink-0 w-44">
+            <div className={`flex-shrink-0 ${gallery.length > 0 ? "sm:w-52" : "sm:w-44"}`}>
               <Label className="mb-2 block">{t('form.image')}</Label>
-              <div
-                className={`relative h-44 w-44 overflow-hidden rounded-lg border-2 border-dashed bg-muted transition-colors ${
-                  dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
-                }`}
-                onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
-                onDragLeave={() => setDragOver(false)}
-                onDrop={handleDrop}
-              >
-                {imageUrl ? (
-                  <>
-                    <img
-                      src={imageUrl}
-                      alt="Part"
-                      className="h-full w-full object-cover"
-                    />
-                    <button
-                      type="button"
-                      onClick={() => setImageUrl("")}
-                      className="absolute right-1 top-1 rounded-full bg-background/80 p-0.5 text-muted-foreground hover:text-foreground"
+              {gallery.length > 0 ? (
+                <div className="grid grid-cols-3 gap-1.5 sm:grid-cols-2">
+                  {gallery.map((g, i) => (
+                    <div
+                      key={g.url}
+                      className="relative aspect-square overflow-hidden rounded-lg border bg-muted cursor-pointer"
+                      onClick={() => { onOpenChange(false); onViewImages?.(gallery.map(img => img.url), i); }}
                     >
-                      <X className="h-4 w-4" />
-                    </button>
-                  </>
-                ) : (
-                  <button
-                    type="button"
+                      <img src={g.url} alt={`Part ${i + 1}`} className="h-full w-full object-cover" />
+                      <button
+                        type="button"
+                        onClick={(e) => { e.stopPropagation(); setGallery((prev) => prev.filter((_, j) => j !== i)); }}
+                        className="absolute right-0.5 top-0.5 rounded-full bg-background/80 p-0.5 text-muted-foreground hover:text-foreground"
+                      >
+                        <X className="h-3 w-3" />
+                      </button>
+                    </div>
+                  ))}
+                  {/* Add more */}
+                  <div
+                    className={`flex aspect-square cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors ${
+                      dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
+                    }`}
+                    onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
+                    onDragLeave={() => setDragOver(false)}
+                    onDrop={handleDrop}
                     onClick={() => fileInputRef.current?.click()}
-                    className="flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground"
                   >
                     {uploading ? (
-                      <Loader2 className="h-8 w-8 animate-spin" />
+                      <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
                     ) : (
-                      <>
-                        <ImageIcon className="h-8 w-8" />
-                        <span className="text-xs">{t('form.dropOrClick')}</span>
-                      </>
+                      <Plus className="h-6 w-6 text-muted-foreground" />
                     )}
-                  </button>
-                )}
-              </div>
+                  </div>
+                </div>
+              ) : (
+                <div
+                  className={`flex h-36 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-muted transition-colors sm:h-44 ${
+                    dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
+                  }`}
+                  onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
+                  onDragLeave={() => setDragOver(false)}
+                  onDrop={handleDrop}
+                  onClick={() => fileInputRef.current?.click()}
+                >
+                  {uploading ? (
+                    <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+                  ) : (
+                    <>
+                      <ImageIcon className="h-8 w-8 text-muted-foreground" />
+                      <span className="mt-2 text-xs text-muted-foreground">{t('form.dropOrClick')}</span>
+                    </>
+                  )}
+                </div>
+              )}
+              {/* Hidden file inputs */}
               <input
                 ref={fileInputRef}
                 type="file"
                 accept="image/jpeg,image/png,image/webp,image/avif"
+                multiple
+                className="hidden"
+                onChange={handleFileChange}
+              />
+              <input
+                ref={cameraInputRef}
+                type="file"
+                accept="image/jpeg,image/png"
+                capture="environment"
                 className="hidden"
                 onChange={handleFileChange}
               />
               <div className="mt-2 flex gap-1">
-                {imageUrl ? (
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    className="flex-1 text-xs"
-                    onClick={() => fileInputRef.current?.click()}
-                  >
-                    <Upload className="mr-1 h-3 w-3" />
-                    {t('form.replace')}
-                  </Button>
-                ) : (
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    className="flex-1 text-xs"
-                    onClick={() => fileInputRef.current?.click()}
-                    disabled={uploading}
-                  >
-                    <Upload className="mr-1 h-3 w-3" />
-                    {t('form.upload')}
-                  </Button>
-                )}
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  className="flex-1 text-xs"
+                  onClick={() => cameraInputRef.current?.click()}
+                  disabled={uploading}
+                >
+                  <Camera className="mr-1 h-3 w-3" />
+                  {t('form.camera')}
+                </Button>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  className="flex-1 text-xs"
+                  onClick={() => fileInputRef.current?.click()}
+                  disabled={uploading}
+                >
+                  <Upload className="mr-1 h-3 w-3" />
+                  {t('form.upload')}
+                </Button>
               </div>
+              {gallery.length > 0 && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  className="mt-1 w-full text-xs"
+                  onClick={handleAiAnalyze}
+                  disabled={analyzing}
+                >
+                  {analyzing ? (
+                    <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+                  ) : (
+                    <Sparkles className="mr-1 h-3 w-3" />
+                  )}
+                  {t('form.aiAnalyze')}
+                </Button>
+              )}
             </div>
 
             {/* Fields */}
             <div className="flex-1 space-y-4">
-              <div className="grid grid-cols-3 gap-4">
+              <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
                 <div className="space-y-2">
                   <Label htmlFor="partNumber">{t('form.partNumber')}</Label>
                   <Input
@@ -377,7 +507,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
                 </div>
               </div>
 
-              <div className="grid grid-cols-2 gap-4">
+              <div className="grid grid-cols-2 gap-3 sm:gap-4">
                 <div className="space-y-2">
                   <Label htmlFor="category">{t('form.category')}</Label>
                   <Input
@@ -398,7 +528,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
                 </div>
               </div>
 
-              <div className="grid grid-cols-4 gap-4">
+              <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
                 <div className="space-y-2">
                   <Label htmlFor="quantity">{t('form.quantity')}</Label>
                   <Input
@@ -455,7 +585,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
             </div>
           </div>
 
-          <div className="grid grid-cols-3 gap-4">
+          <div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
             <div className="space-y-2">
               <Label htmlFor="supplier">{t('form.supplier')}</Label>
               <Input

+ 7 - 1
src/features/inventory/Schema/inventorySchema.ts

@@ -14,7 +14,13 @@ export const createInventoryPartSchema = z.object({
   supplierPhone: z.string().optional(),
   supplierEmail: z.string().optional(),
   supplierUrl: z.string().url().optional().or(z.literal("")),
-  imageUrl: z.string().url().optional().or(z.literal("")),
+  gallery: z.array(z.object({
+    id: z.string().optional(),
+    url: z.string(),
+    fileName: z.string().optional(),
+    description: z.string().optional(),
+    sortOrder: z.number().int().default(0),
+  })).optional().default([]),
   location: z.string().optional(),
 });
 

+ 7 - 7
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -36,17 +36,17 @@ export function PartsEditor({
     <div className="rounded-lg border p-3 space-y-2">
       <div className="flex items-center justify-between">
         <h3 className="text-sm font-semibold">{t('title')}</h3>
-        <div className="flex gap-2">
+        <div className="flex gap-1.5">
           {hasInventory && (
             <Button type="button" variant="outline" size="sm" onClick={onOpenInventory}>
-              <Package className="mr-1 h-3.5 w-3.5" />
-              {t('fromInventory')}
+              <Package className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('fromInventory')}</span>
             </Button>
           )}
           {onScanBarcode && (
             <Button type="button" variant="outline" size="sm" onClick={onScanBarcode}>
-              <ScanBarcode className="mr-1 h-3.5 w-3.5" />
-              {t('scanBarcode')}
+              <ScanBarcode className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('scanBarcode')}</span>
             </Button>
           )}
           <Button
@@ -55,8 +55,8 @@ export function PartsEditor({
             size="sm"
             onClick={() => setPartItems((prev) => [...prev, emptyPart()])}
           >
-            <Plus className="mr-1 h-3.5 w-3.5" />
-            {t('addPart')}
+            <Plus className="h-3.5 w-3.5 sm:mr-1" />
+            <span className="hidden sm:inline">{t('addPart')}</span>
           </Button>
         </div>
       </div>

+ 12 - 12
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -108,30 +108,30 @@ export function UnifiedServiceHeader({
               )}
               <Button type="submit" form="service-record-form" size="sm" disabled={saving} variant={hasUnsavedChanges ? "default" : "outline"} className={hasUnsavedChanges ? "animate-pulse" : ""}>
                 {saving ? (
-                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                  <Loader2 className="h-3.5 w-3.5 animate-spin sm:mr-1" />
                 ) : (
-                  <Save className="mr-1 h-3.5 w-3.5" />
+                  <Save className="h-3.5 w-3.5 sm:mr-1" />
                 )}
-                {t('save')}
+                <span className="hidden sm:inline">{t('save')}</span>
               </Button>
             </>
           )}
           <ButtonGroup>
             <Button variant="outline" size="sm" onClick={onDownloadPDF} disabled={downloading}>
               {downloading ? (
-                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                <Loader2 className="h-3.5 w-3.5 animate-spin sm:mr-1" />
               ) : (
-                <Download className="mr-1 h-3.5 w-3.5" />
+                <Download className="h-3.5 w-3.5 sm:mr-1" />
               )}
-              {t('pdf')}
+              <span className="hidden sm:inline">{t('pdf')}</span>
             </Button>
             <Button variant="outline" size="sm" onClick={onShowEmail}>
-              <Mail className="mr-1 h-3.5 w-3.5" />
-              {t('email')}
+              <Mail className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('email')}</span>
             </Button>
             <Button variant="outline" size="sm" onClick={onShowShare}>
-              <Globe className="mr-1 h-3.5 w-3.5" />
-              {t('share')}
+              <Globe className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('share')}</span>
             </Button>
             <Button
               variant="outline"
@@ -139,8 +139,8 @@ export function UnifiedServiceHeader({
               className="text-destructive hover:bg-destructive/10"
               onClick={onDelete}
             >
-              <Trash2 className="mr-1 h-3.5 w-3.5" />
-              {t('delete')}
+              <Trash2 className="h-3.5 w-3.5 sm:mr-1" />
+              <span className="hidden sm:inline">{t('delete')}</span>
             </Button>
           </ButtonGroup>
         </div>

+ 28 - 0
src/lib/ai.ts

@@ -84,6 +84,34 @@ async function chatCompletion(
   return response.choices[0]?.message?.content ?? "";
 }
 
+export async function visionCompletion(
+  organizationId: string,
+  systemPrompt: string,
+  userText: string,
+  imageUrls: string | string[],
+): Promise<string> {
+  const config = await getAiConfig(organizationId);
+  const client = createClient(config);
+
+  const urls = Array.isArray(imageUrls) ? imageUrls : [imageUrls];
+  const content: Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> = [
+    { type: "text", text: userText },
+    ...urls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
+  ];
+
+  const response = await client.chat.completions.create({
+    model: config.model,
+    messages: [
+      { role: "system", content: systemPrompt },
+      { role: "user", content },
+    ],
+    temperature: 0.3,
+    max_tokens: 1000,
+  });
+
+  return response.choices[0]?.message?.content ?? "";
+}
+
 // ─── AI Feature Functions ────────────────────────────────────────────────────
 
 export interface ServiceContext {