Переглянути джерело

Add barcode scanning for inventory and work orders (#90)

* barcode prisma and translations

* barcode scanner

* adding part

* barcode area

* lint
Bernt Christian Egeland 6 місяців тому
батько
коміт
d0bf1574d5
41 змінених файлів з 850 додано та 29 видалено
  1. 15 1
      messages/de/inventory.json
  2. 5 1
      messages/de/service.json
  3. 15 1
      messages/en/inventory.json
  4. 5 1
      messages/en/service.json
  5. 15 1
      messages/es/inventory.json
  6. 5 1
      messages/es/service.json
  7. 15 1
      messages/fr/inventory.json
  8. 5 1
      messages/fr/service.json
  9. 15 1
      messages/it/inventory.json
  10. 5 1
      messages/it/service.json
  11. 15 1
      messages/nb/inventory.json
  12. 5 1
      messages/nb/service.json
  13. 15 1
      messages/nl/inventory.json
  14. 5 1
      messages/nl/service.json
  15. 15 1
      messages/pl/inventory.json
  16. 5 1
      messages/pl/service.json
  17. 15 1
      messages/pt-BR/inventory.json
  18. 5 1
      messages/pt-BR/service.json
  19. 15 1
      messages/ru/inventory.json
  20. 5 1
      messages/ru/service.json
  21. 15 1
      messages/tr/inventory.json
  22. 5 1
      messages/tr/service.json
  23. 7 0
      package-lock.json
  24. 1 0
      package.json
  25. 2 0
      prisma/migrations/20260324171951_barcode/migration.sql
  26. 1 0
      prisma/schema.prisma
  27. 75 2
      src/app/(authenticated)/inventory/inventory-client.tsx
  28. 1 1
      src/components/app-sidebar.tsx
  29. 206 0
      src/components/barcode-scanner-dialog.tsx
  30. 4 0
      src/features/inventory/Actions/inventoryActions.ts
  31. 33 0
      src/features/inventory/Actions/lookupPartByBarcode.ts
  32. 141 0
      src/features/inventory/Components/BarcodeScanActionDialog.tsx
  33. 15 2
      src/features/inventory/Components/InventoryPartForm.tsx
  34. 1 0
      src/features/inventory/Schema/inventorySchema.ts
  35. 6 0
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  36. 9 1
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  37. 1 0
      src/features/vehicles/Components/service-edit/form-types.ts
  38. 3 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  39. 34 1
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  40. 2 0
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  41. 88 0
      src/hooks/use-hardware-scanner.ts

+ 15 - 1
messages/de/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Verkaufspreis",
     "supplier": "Lieferant",
     "location": "Lagerort",
-    "low": "Niedrig"
+    "low": "Niedrig",
+    "barcode": "Barcode"
   },
   "empty": {
     "noMatch": "Keine Teile entsprechen Ihrer Suche.",
@@ -43,6 +44,17 @@
     "saveFailed": "Teil konnte nicht gespeichert werden",
     "error": "Fehler"
   },
+  "scanBarcode": "Scannen",
+  "barcodeScan": {
+    "title": "Gescanntes Teil",
+    "notFound": "Kein Teil für Barcode \"{barcode}\" gefunden",
+    "createNew": "Neues Teil anlegen",
+    "addStock": "Bestand auffüllen",
+    "editPart": "Teil bearbeiten",
+    "currentStock": "Aktueller Bestand: {quantity}",
+    "addedStock": "{amount} zum Bestand hinzugefügt",
+    "customAmount": "Menge..."
+  },
   "form": {
     "editPart": "Teil bearbeiten",
     "addNewPart": "Neues Teil hinzufügen",
@@ -62,6 +74,8 @@
     "imageSizeError": "Das Bild darf maximal 5 MB groß sein",
     "partNumber": "Teilenummer",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Barcode",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Name *",
     "namePlaceholder": "Bremsbelagsatz",
     "category": "Kategorie",

+ 5 - 1
messages/de/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Teil aus Bestand auswählen",
     "searchInventory": "Bestand durchsuchen...",
     "inStock": "{quantity} auf Lager",
-    "noInventory": "Keine Bestandteile gefunden."
+    "noInventory": "Keine Bestandteile gefunden.",
+    "scanBarcode": "Scannen",
+    "scanTitle": "Barcode scannen",
+    "partFound": "\"{name}\" aus dem Lager hinzugefügt",
+    "partNotFound": "Kein Teil für Barcode \"{barcode}\" gefunden"
   },
   "labor": {
     "title": "Arbeit",

+ 15 - 1
messages/en/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Sell Price",
     "supplier": "Supplier",
     "location": "Location",
-    "low": "Low"
+    "low": "Low",
+    "barcode": "Barcode"
   },
   "empty": {
     "noMatch": "No parts match your search.",
@@ -43,6 +44,17 @@
     "saveFailed": "Failed to save part",
     "error": "Error"
   },
+  "scanBarcode": "Scan",
+  "barcodeScan": {
+    "title": "Scanned Part",
+    "notFound": "No part found for barcode \"{barcode}\"",
+    "createNew": "Create New Part",
+    "addStock": "Add to Stock",
+    "editPart": "Edit Part",
+    "currentStock": "Current stock: {quantity}",
+    "addedStock": "Added {amount} to stock",
+    "customAmount": "Qty..."
+  },
   "form": {
     "editPart": "Edit Part",
     "addNewPart": "Add New Part",
@@ -62,6 +74,8 @@
     "imageSizeError": "Image must be under 5MB",
     "partNumber": "Part Number",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Barcode",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Name *",
     "namePlaceholder": "Brake Pad Set",
     "category": "Category",

+ 5 - 1
messages/en/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Select Part from Inventory",
     "searchInventory": "Search inventory...",
     "inStock": "{quantity} in stock",
-    "noInventory": "No inventory parts found."
+    "noInventory": "No inventory parts found.",
+    "scanBarcode": "Scan",
+    "scanTitle": "Scan Barcode",
+    "partFound": "Added \"{name}\" from inventory",
+    "partNotFound": "No part found for barcode \"{barcode}\""
   },
   "labor": {
     "title": "Labor",

+ 15 - 1
messages/es/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Precio de venta",
     "supplier": "Proveedor",
     "location": "Ubicación",
-    "low": "Bajo"
+    "low": "Bajo",
+    "barcode": "Código de barras"
   },
   "empty": {
     "noMatch": "Ninguna pieza coincide con su búsqueda.",
@@ -43,6 +44,17 @@
     "saveFailed": "No se pudo guardar la pieza",
     "error": "Error"
   },
+  "scanBarcode": "Escanear",
+  "barcodeScan": {
+    "title": "Pieza escaneada",
+    "notFound": "No se encontró pieza para el código \"{barcode}\"",
+    "createNew": "Crear pieza nueva",
+    "addStock": "Agregar al stock",
+    "editPart": "Editar pieza",
+    "currentStock": "Stock actual: {quantity}",
+    "addedStock": "{amount} agregados al stock",
+    "customAmount": "Cant..."
+  },
   "form": {
     "editPart": "Editar pieza",
     "addNewPart": "Añadir nueva pieza",
@@ -62,6 +74,8 @@
     "imageSizeError": "La imagen debe ser menor de 5 MB",
     "partNumber": "Número de pieza",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Código de barras",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Nombre *",
     "namePlaceholder": "Juego de pastillas de freno",
     "category": "Categoría",

+ 5 - 1
messages/es/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Seleccionar Pieza del Inventario",
     "searchInventory": "Buscar en inventario...",
     "inStock": "{quantity} en stock",
-    "noInventory": "No se encontraron piezas de inventario."
+    "noInventory": "No se encontraron piezas de inventario.",
+    "scanBarcode": "Escanear",
+    "scanTitle": "Escanear código de barras",
+    "partFound": "\"{name}\" añadido del inventario",
+    "partNotFound": "No se encontró pieza para el código de barras \"{barcode}\""
   },
   "labor": {
     "title": "Mano de Obra",

+ 15 - 1
messages/fr/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Prix de vente",
     "supplier": "Fournisseur",
     "location": "Emplacement",
-    "low": "Bas"
+    "low": "Bas",
+    "barcode": "Code-barres"
   },
   "empty": {
     "noMatch": "Aucune pièce ne correspond à votre recherche.",
@@ -43,6 +44,17 @@
     "saveFailed": "Impossible d'enregistrer la pièce",
     "error": "Erreur"
   },
+  "scanBarcode": "Scanner",
+  "barcodeScan": {
+    "title": "Pièce scannée",
+    "notFound": "Aucune pièce trouvée pour le code-barres \"{barcode}\"",
+    "createNew": "Créer une nouvelle pièce",
+    "addStock": "Ajouter au stock",
+    "editPart": "Modifier la pièce",
+    "currentStock": "Stock actuel : {quantity}",
+    "addedStock": "{amount} ajoutés au stock",
+    "customAmount": "Qté..."
+  },
   "form": {
     "editPart": "Modifier la pièce",
     "addNewPart": "Ajouter une nouvelle pièce",
@@ -62,6 +74,8 @@
     "imageSizeError": "L'image doit faire moins de 5 Mo",
     "partNumber": "Numéro de pièce",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Code-barres",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Nom *",
     "namePlaceholder": "Jeu de plaquettes de frein",
     "category": "Catégorie",

+ 5 - 1
messages/fr/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Sélectionner une Pièce de l'Inventaire",
     "searchInventory": "Rechercher l'inventaire...",
     "inStock": "{quantity} en stock",
-    "noInventory": "Aucune pièce d'inventaire trouvée."
+    "noInventory": "Aucune pièce d'inventaire trouvée.",
+    "scanBarcode": "Scanner",
+    "scanTitle": "Scanner le code-barres",
+    "partFound": "\"{name}\" ajouté depuis l'inventaire",
+    "partNotFound": "Aucune pièce trouvée pour le code-barres \"{barcode}\""
   },
   "labor": {
     "title": "Main-d'œuvre",

+ 15 - 1
messages/it/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Prezzo di vendita",
     "supplier": "Fornitore",
     "location": "Posizione",
-    "low": "Basso"
+    "low": "Basso",
+    "barcode": "Codice a barre"
   },
   "empty": {
     "noMatch": "Nessun ricambio corrisponde alla ricerca.",
@@ -43,6 +44,17 @@
     "saveFailed": "Impossibile salvare il ricambio",
     "error": "Errore"
   },
+  "scanBarcode": "Scansiona",
+  "barcodeScan": {
+    "title": "Pezzo scansionato",
+    "notFound": "Nessun pezzo trovato per il codice \"{barcode}\"",
+    "createNew": "Crea nuovo pezzo",
+    "addStock": "Aggiungi allo stock",
+    "editPart": "Modifica pezzo",
+    "currentStock": "Stock attuale: {quantity}",
+    "addedStock": "{amount} aggiunti allo stock",
+    "customAmount": "Qtà..."
+  },
   "form": {
     "editPart": "Modifica ricambio",
     "addNewPart": "Aggiungi nuovo ricambio",
@@ -62,6 +74,8 @@
     "imageSizeError": "L'immagine deve essere inferiore a 5 MB",
     "partNumber": "Numero ricambio",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Codice a barre",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Nome *",
     "namePlaceholder": "Kit pastiglie freno",
     "category": "Categoria",

+ 5 - 1
messages/it/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Seleziona Pezzo da Inventario",
     "searchInventory": "Cerca inventario...",
     "inStock": "{quantity} in magazzino",
-    "noInventory": "Nessun pezzo inventario trovato."
+    "noInventory": "Nessun pezzo inventario trovato.",
+    "scanBarcode": "Scansiona",
+    "scanTitle": "Scansiona codice a barre",
+    "partFound": "\"{name}\" aggiunto dall'inventario",
+    "partNotFound": "Nessun pezzo trovato per il codice a barre \"{barcode}\""
   },
   "labor": {
     "title": "Manodopera",

+ 15 - 1
messages/nb/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Salgspris",
     "supplier": "Leverandør",
     "location": "Plassering",
-    "low": "Lav"
+    "low": "Lav",
+    "barcode": "Strekkode"
   },
   "empty": {
     "noMatch": "Ingen deler samsvarer med søket ditt.",
@@ -43,6 +44,17 @@
     "saveFailed": "Kunne ikke lagre delen",
     "error": "Feil"
   },
+  "scanBarcode": "Skann",
+  "barcodeScan": {
+    "title": "Skannet del",
+    "notFound": "Ingen del funnet for strekkode \"{barcode}\"",
+    "createNew": "Opprett ny del",
+    "addStock": "Legg til på lager",
+    "editPart": "Rediger del",
+    "currentStock": "Nåværende beholdning: {quantity}",
+    "addedStock": "{amount} lagt til på lager",
+    "customAmount": "Antall..."
+  },
   "form": {
     "editPart": "Rediger del",
     "addNewPart": "Legg til ny del",
@@ -62,6 +74,8 @@
     "imageSizeError": "Bildet må være under 5 MB",
     "partNumber": "Delenummer",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Strekkode",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Navn *",
     "namePlaceholder": "Bremseklosssett",
     "category": "Kategori",

+ 5 - 1
messages/nb/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Velg del fra beholdning",
     "searchInventory": "Søk beholdning...",
     "inStock": "{quantity} på lager",
-    "noInventory": "Ingen beholdningsdeler funnet."
+    "noInventory": "Ingen beholdningsdeler funnet.",
+    "scanBarcode": "Skann",
+    "scanTitle": "Skann strekkode",
+    "partFound": "\"{name}\" lagt til fra lageret",
+    "partNotFound": "Ingen del funnet for strekkode \"{barcode}\""
   },
   "labor": {
     "title": "Arbeid",

+ 15 - 1
messages/nl/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Verkoopprijs",
     "supplier": "Leverancier",
     "location": "Locatie",
-    "low": "Laag"
+    "low": "Laag",
+    "barcode": "Barcode"
   },
   "empty": {
     "noMatch": "Geen onderdelen gevonden voor uw zoekopdracht.",
@@ -43,6 +44,17 @@
     "saveFailed": "Kan onderdeel niet opslaan",
     "error": "Fout"
   },
+  "scanBarcode": "Scannen",
+  "barcodeScan": {
+    "title": "Gescand onderdeel",
+    "notFound": "Geen onderdeel gevonden voor barcode \"{barcode}\"",
+    "createNew": "Nieuw onderdeel aanmaken",
+    "addStock": "Toevoegen aan voorraad",
+    "editPart": "Onderdeel bewerken",
+    "currentStock": "Huidige voorraad: {quantity}",
+    "addedStock": "{amount} toegevoegd aan voorraad",
+    "customAmount": "Aantal..."
+  },
   "form": {
     "editPart": "Onderdeel bewerken",
     "addNewPart": "Nieuw onderdeel toevoegen",
@@ -62,6 +74,8 @@
     "imageSizeError": "De afbeelding mag maximaal 5 MB zijn",
     "partNumber": "Onderdeelnummer",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Barcode",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Naam *",
     "namePlaceholder": "Remblokkenset",
     "category": "Categorie",

+ 5 - 1
messages/nl/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Onderdeel uit voorraad selecteren",
     "searchInventory": "Voorraad zoeken...",
     "inStock": "{quantity} in voorraad",
-    "noInventory": "Geen voorraadarticelen gevonden."
+    "noInventory": "Geen voorraadarticelen gevonden.",
+    "scanBarcode": "Scannen",
+    "scanTitle": "Barcode scannen",
+    "partFound": "\"{name}\" toegevoegd uit voorraad",
+    "partNotFound": "Geen onderdeel gevonden voor barcode \"{barcode}\""
   },
   "labor": {
     "title": "Arbeidskosten",

+ 15 - 1
messages/pl/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Cena sprzedaży",
     "supplier": "Dostawca",
     "location": "Lokalizacja",
-    "low": "Niski"
+    "low": "Niski",
+    "barcode": "Kod kreskowy"
   },
   "empty": {
     "noMatch": "Żadne części nie pasują do wyszukiwania.",
@@ -43,6 +44,17 @@
     "saveFailed": "Nie udało się zapisać części",
     "error": "Błąd"
   },
+  "scanBarcode": "Skanuj",
+  "barcodeScan": {
+    "title": "Zeskanowana część",
+    "notFound": "Nie znaleziono części dla kodu \"{barcode}\"",
+    "createNew": "Utwórz nową część",
+    "addStock": "Dodaj do stanu",
+    "editPart": "Edytuj część",
+    "currentStock": "Aktualny stan: {quantity}",
+    "addedStock": "Dodano {amount} do stanu",
+    "customAmount": "Ilość..."
+  },
   "form": {
     "editPart": "Edytuj część",
     "addNewPart": "Dodaj nową część",
@@ -62,6 +74,8 @@
     "imageSizeError": "Zdjęcie musi mieć mniej niż 5 MB",
     "partNumber": "Numer części",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Kod kreskowy",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Nazwa *",
     "namePlaceholder": "Klocki hamulcowe",
     "category": "Kategoria",

+ 5 - 1
messages/pl/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Wybierz Część z Magazynu",
     "searchInventory": "Szukaj w magazynie...",
     "inStock": "{quantity} w magazynie",
-    "noInventory": "Brak części w magazynie."
+    "noInventory": "Brak części w magazynie.",
+    "scanBarcode": "Skanuj",
+    "scanTitle": "Skanuj kod kreskowy",
+    "partFound": "Dodano \"{name}\" z magazynu",
+    "partNotFound": "Nie znaleziono części dla kodu kreskowego \"{barcode}\""
   },
   "labor": {
     "title": "Praca",

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

@@ -13,7 +13,8 @@
     "sellPrice": "Preço de venda",
     "supplier": "Fornecedor",
     "location": "Localização",
-    "low": "Baixo"
+    "low": "Baixo",
+    "barcode": "Código de barras"
   },
   "empty": {
     "noMatch": "Nenhuma peça corresponde à sua busca.",
@@ -43,6 +44,17 @@
     "saveFailed": "Não foi possível salvar a peça",
     "error": "Erro"
   },
+  "scanBarcode": "Escanear",
+  "barcodeScan": {
+    "title": "Peça escaneada",
+    "notFound": "Nenhuma peça encontrada para o código \"{barcode}\"",
+    "createNew": "Criar nova peça",
+    "addStock": "Adicionar ao estoque",
+    "editPart": "Editar peça",
+    "currentStock": "Estoque atual: {quantity}",
+    "addedStock": "{amount} adicionados ao estoque",
+    "customAmount": "Qtd..."
+  },
   "form": {
     "editPart": "Editar peça",
     "addNewPart": "Adicionar nova peça",
@@ -62,6 +74,8 @@
     "imageSizeError": "A imagem deve ter menos de 5 MB",
     "partNumber": "Número da peça",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Código de barras",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Nome *",
     "namePlaceholder": "Jogo de pastilhas de freio",
     "category": "Categoria",

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

@@ -77,7 +77,11 @@
     "inventoryTitle": "Selecionar Peça do Inventário",
     "searchInventory": "Pesquisar inventário...",
     "inStock": "{quantity} em estoque",
-    "noInventory": "Nenhuma peça de inventário encontrada."
+    "noInventory": "Nenhuma peça de inventário encontrada.",
+    "scanBarcode": "Escanear",
+    "scanTitle": "Escanear código de barras",
+    "partFound": "\"{name}\" adicionado do estoque",
+    "partNotFound": "Nenhuma peça encontrada para o código de barras \"{barcode}\""
   },
   "labor": {
     "title": "Mão de Obra",

+ 15 - 1
messages/ru/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Цена продажи",
     "supplier": "Поставщик",
     "location": "Расположение",
-    "low": "Мало"
+    "low": "Мало",
+    "barcode": "Штрихкод"
   },
   "empty": {
     "noMatch": "Нет запчастей, соответствующих поиску.",
@@ -43,6 +44,17 @@
     "saveFailed": "Не удалось сохранить запчасть",
     "error": "Ошибка"
   },
+  "scanBarcode": "Сканировать",
+  "barcodeScan": {
+    "title": "Отсканированная деталь",
+    "notFound": "Деталь для штрихкода \"{barcode}\" не найдена",
+    "createNew": "Создать новую деталь",
+    "addStock": "Добавить на склад",
+    "editPart": "Редактировать деталь",
+    "currentStock": "Текущий запас: {quantity}",
+    "addedStock": "{amount} добавлено на склад",
+    "customAmount": "Кол-во..."
+  },
   "form": {
     "editPart": "Редактировать запчасть",
     "addNewPart": "Добавить новую запчасть",
@@ -62,6 +74,8 @@
     "imageSizeError": "Изображение должно быть менее 5МБ",
     "partNumber": "Артикул",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Штрихкод",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Название *",
     "namePlaceholder": "Комплект тормозных колодок",
     "category": "Категория",

+ 5 - 1
messages/ru/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Выбор запчасти со склада",
     "searchInventory": "Поиск на складе...",
     "inStock": "{quantity} в наличии",
-    "noInventory": "Запчасти на складе не найдены."
+    "noInventory": "Запчасти на складе не найдены.",
+    "scanBarcode": "Сканировать",
+    "scanTitle": "Сканировать штрихкод",
+    "partFound": "\"{name}\" добавлено из склада",
+    "partNotFound": "Деталь для штрихкода \"{barcode}\" не найдена"
   },
   "labor": {
     "title": "Работа",

+ 15 - 1
messages/tr/inventory.json

@@ -13,7 +13,8 @@
     "sellPrice": "Satış fiyatı",
     "supplier": "Tedarikçi",
     "location": "Konum",
-    "low": "Düşük"
+    "low": "Düşük",
+    "barcode": "Barkod"
   },
   "empty": {
     "noMatch": "Aramanızla eşleşen parça bulunamadı.",
@@ -43,6 +44,17 @@
     "saveFailed": "Parça kaydedilemedi",
     "error": "Hata"
   },
+  "scanBarcode": "Tara",
+  "barcodeScan": {
+    "title": "Taranan parça",
+    "notFound": "\"{barcode}\" barkodu için parça bulunamadı",
+    "createNew": "Yeni parça oluştur",
+    "addStock": "Stoka ekle",
+    "editPart": "Parçayı düzenle",
+    "currentStock": "Mevcut stok: {quantity}",
+    "addedStock": "Stoka {amount} eklendi",
+    "customAmount": "Adet..."
+  },
   "form": {
     "editPart": "Parçayı düzenle",
     "addNewPart": "Yeni parça ekle",
@@ -62,6 +74,8 @@
     "imageSizeError": "Görsel 5 MB'den küçük olmalıdır",
     "partNumber": "Parça numarası",
     "partNumberPlaceholder": "BRK-001",
+    "barcode": "Barkod",
+    "barcodePlaceholder": "123456789012",
     "nameLabel": "Ad *",
     "namePlaceholder": "Fren balatası seti",
     "category": "Kategori",

+ 5 - 1
messages/tr/service.json

@@ -77,7 +77,11 @@
     "inventoryTitle": "Stoktan Parça Seç",
     "searchInventory": "Stokte ara...",
     "inStock": "{quantity} stokta",
-    "noInventory": "Stok parçası bulunamadı."
+    "noInventory": "Stok parçası bulunamadı.",
+    "scanBarcode": "Tara",
+    "scanTitle": "Barkod tara",
+    "partFound": "\"{name}\" envanterden eklendi",
+    "partNotFound": "\"{barcode}\" barkodu için parça bulunamadı"
   },
   "labor": {
     "title": "İşçilik",

+ 7 - 0
package-lock.json

@@ -34,6 +34,7 @@
         "cron": "^4.4.0",
         "date-fns": "^4.1.0",
         "form-data": "^4.0.5",
+        "html5-qrcode": "^2.3.8",
         "jszip": "^3.10.1",
         "lucide-react": "^0.563.0",
         "mailgun.js": "^12.7.0",
@@ -11846,6 +11847,12 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/html5-qrcode": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
+      "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
+      "license": "Apache-2.0"
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

+ 1 - 0
package.json

@@ -42,6 +42,7 @@
     "cron": "^4.4.0",
     "date-fns": "^4.1.0",
     "form-data": "^4.0.5",
+    "html5-qrcode": "^2.3.8",
     "jszip": "^3.10.1",
     "lucide-react": "^0.563.0",
     "mailgun.js": "^12.7.0",

+ 2 - 0
prisma/migrations/20260324171951_barcode/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "inventory_parts" ADD COLUMN     "barcode" TEXT;

+ 1 - 0
prisma/schema.prisma

@@ -402,6 +402,7 @@ model LaborPresetItem {
 model InventoryPart {
   id            String   @id @default(cuid())
   partNumber    String?
+  barcode       String?
   name          String
   description   String?
   category      String?

+ 75 - 2
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -1,6 +1,10 @@
 "use client";
 
 import { useState, useCallback, useTransition } from "react";
+import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog';
+import { BarcodeScanActionDialog } from '@/features/inventory/Components/BarcodeScanActionDialog';
+import { useHardwareScanner } from '@/hooks/use-hardware-scanner';
+import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode';
 import { useTranslations } from "next-intl";
 import { useRouter, usePathname, useSearchParams } from "next/navigation";
 import { Button } from "@/components/ui/button";
@@ -48,6 +52,7 @@ import {
   Pencil,
   Percent,
   Plus,
+  ScanBarcode,
   Search,
   Trash2,
 } from "lucide-react";
@@ -56,6 +61,7 @@ import { formatCurrency } from "@/lib/format";
 interface InventoryPart {
   id: string;
   partNumber: string | null;
+  barcode: string | null;
   name: string;
   description: string | null;
   category: string | null;
@@ -105,9 +111,33 @@ export function InventoryClient({
   const [showMarkup, setShowMarkup] = useState(false);
   const [markupValue, setMarkupValue] = useState(String(initialMarkup));
   const [applyingMarkup, setApplyingMarkup] = useState(false);
+  const [showScanner, setShowScanner] = useState(false);
+  const [scannedPart, setScannedPart] = useState<{
+    id: string;
+    name: string;
+    partNumber: string | null;
+    barcode: string | null;
+    quantity: number;
+    category: string | null;
+  } | null>(null);
+  const [scannedBarcode, setScannedBarcode] = useState('');
+  const [showScanActions, setShowScanActions] = useState(false);
   const modal = useGlassModal();
   const confirm = useConfirm();
 
+  const handleBarcodeScan = useCallback(async (barcode: string) => {
+    const result = await lookupPartByBarcode(barcode);
+    setScannedBarcode(barcode);
+    if (result.success && result.data) {
+      setScannedPart(result.data);
+    } else {
+      setScannedPart(null);
+    }
+    setShowScanActions(true);
+  }, []);
+
+  useHardwareScanner({ onScan: handleBarcodeScan });
+
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
       const newParams = new URLSearchParams(searchParams.toString());
@@ -210,6 +240,10 @@ export function InventoryClient({
           {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
         </div>
         <div className="flex items-center 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')}
@@ -227,6 +261,7 @@ export function InventoryClient({
           <TableHeader>
             <TableRow>
               <TableHead>{t('table.partNumber')}</TableHead>
+              <TableHead className="hidden lg:table-cell">{t('table.barcode')}</TableHead>
               <TableHead>{t('table.name')}</TableHead>
               <TableHead className="hidden sm:table-cell">{t('table.category')}</TableHead>
               <TableHead>{t('table.inStock')}</TableHead>
@@ -240,7 +275,7 @@ export function InventoryClient({
           <TableBody>
             {data.parts.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={9} className="h-32 text-center text-muted-foreground">
+                <TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
                   {search || category ? t('empty.noMatch') : t('empty.noParts')}
                 </TableCell>
               </TableRow>
@@ -263,6 +298,13 @@ export function InventoryClient({
                         <span className="text-muted-foreground">-</span>
                       )}
                     </TableCell>
+                    <TableCell className="hidden lg:table-cell">
+                      {part.barcode ? (
+                        <span className="font-mono text-sm">{part.barcode}</span>
+                      ) : (
+                        <span className="text-muted-foreground">-</span>
+                      )}
+                    </TableCell>
                     <TableCell className="font-medium">
                       <div className="flex items-center gap-2">
                         {part.imageUrl && (
@@ -372,13 +414,18 @@ export function InventoryClient({
       />
 
       <InventoryPartForm
+        key={editPart?.id ?? scannedBarcode ?? 'new'}
         open={showForm}
         onOpenChange={(open) => {
           setShowForm(open);
-          if (!open) setEditPart(null);
+          if (!open) {
+            setEditPart(null);
+            setScannedBarcode('');
+          }
         }}
         part={editPart ?? undefined}
         markupMultiplier={initialMarkup}
+        initialBarcode={!editPart ? scannedBarcode : undefined}
       />
 
       {/* Bulk Markup Dialog */}
@@ -415,6 +462,32 @@ export function InventoryClient({
           </div>
         </MarkupDialogContent>
       </MarkupDialog>
+
+      <BarcodeScannerDialog
+        open={showScanner}
+        onOpenChange={setShowScanner}
+        onScan={handleBarcodeScan}
+        title={t('scanBarcode')}
+      />
+
+      <BarcodeScanActionDialog
+        open={showScanActions}
+        onOpenChange={setShowScanActions}
+        part={scannedPart}
+        barcode={scannedBarcode}
+        onEditPart={(partId) => {
+          const part = data.parts.find((p) => p.id === partId);
+          if (part) {
+            setEditPart(part);
+            setShowForm(true);
+          }
+        }}
+        onCreatePart={(barcode) => {
+          setEditPart(null);
+          setScannedBarcode(barcode);
+          setShowForm(true);
+        }}
+      />
     </div>
   );
 }

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

@@ -210,7 +210,7 @@ export function AppSidebar({
                       width={38}
                       height={38}
                       unoptimized
-                      className="object-contain"
+                      className="h-auto w-auto object-contain"
                     />
                   </div>
                   <div className="flex flex-col gap-0.5 leading-none">

+ 206 - 0
src/components/barcode-scanner-dialog.tsx

@@ -0,0 +1,206 @@
+'use client'
+
+import { useEffect, useRef, useState, useCallback } from 'react'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Camera, Keyboard } from 'lucide-react'
+
+interface BarcodeScannerDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onScan: (barcode: string) => void
+  title?: string
+}
+
+export function BarcodeScannerDialog({
+  open,
+  onOpenChange,
+  onScan,
+  title = 'Scan Barcode',
+}: BarcodeScannerDialogProps) {
+  const scannerRef = useRef<HTMLDivElement>(null)
+  const html5QrRef = useRef<any>(null)
+  const [error, setError] = useState<string | null>(null)
+  const [showManual, setShowManual] = useState(false)
+  const [manualValue, setManualValue] = useState('')
+  const hasScannedRef = useRef(false)
+
+  const handleScan = useCallback(
+    (decodedText: string) => {
+      if (hasScannedRef.current) return
+      hasScannedRef.current = true
+      onScan(decodedText)
+      onOpenChange(false)
+    },
+    [onScan, onOpenChange]
+  )
+
+  useEffect(() => {
+    if (!open || showManual) {
+      hasScannedRef.current = false
+      return
+    }
+
+    let scanner: any = null
+    let mounted = true
+    let running = false
+
+    const startScanner = async () => {
+      try {
+        const { Html5Qrcode } = await import('html5-qrcode')
+
+        if (!mounted || !scannerRef.current) return
+
+        const containerId = 'barcode-scanner-region'
+        if (!document.getElementById(containerId)) {
+          const div = document.createElement('div')
+          div.id = containerId
+          scannerRef.current.appendChild(div)
+        }
+
+        scanner = new Html5Qrcode(containerId)
+        html5QrRef.current = scanner
+
+        const scanConfig = {
+          fps: 15,
+          qrbox: (viewfinderWidth: number, viewfinderHeight: number) => ({
+            width: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.8),
+            height: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.3),
+          }),
+        }
+        const onSuccess = (decodedText: string) => {
+          if (mounted) handleScan(decodedText)
+        }
+        const onError = () => { /* expected: fires on each frame without a match */ }
+
+        try {
+          // Try rear camera first (mobile)
+          await scanner.start({ facingMode: 'environment' }, scanConfig, onSuccess, onError)
+        } catch {
+          // Fall back to any available camera (desktop USB/webcam)
+          const devices = await Html5Qrcode.getCameras()
+          if (!devices.length) throw new Error('NotFoundError')
+          await scanner.start(devices[0].id, scanConfig, onSuccess, onError)
+        }
+        running = true
+      } catch (err: any) {
+        if (!mounted) return
+        console.error('[BarcodeScan] Camera error:', err?.name, err?.message, err)
+
+        const isInsecure = window.location.protocol === 'http:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'
+
+        if (isInsecure) {
+          setError(`Camera requires HTTPS. You are on HTTP (${window.location.hostname}). Use localhost or enable HTTPS for camera access. You can enter the barcode manually below.`)
+        } else if (err?.name === 'NotAllowedError' || err?.message?.includes('Permission')) {
+          setError('Camera access denied. Please allow camera permissions or enter the barcode manually.')
+        } else if (err?.name === 'NotFoundError' || err?.message === 'NotFoundError') {
+          setError('No camera found on this device. Please enter the barcode manually.')
+        } else {
+          setError(`Could not start camera: ${err?.message || 'Unknown error'}. Please enter the barcode manually.`)
+        }
+        setShowManual(true)
+      }
+    }
+
+    const timer = setTimeout(startScanner, 100)
+
+    return () => {
+      mounted = false
+      clearTimeout(timer)
+      const inst = html5QrRef.current
+      html5QrRef.current = null
+      if (inst) {
+        const state = inst.getState?.()
+        // State 2 = SCANNING, State 3 = PAUSED
+        if (state === 2 || state === 3) {
+          inst.stop().then(() => inst.clear()).catch(() => { /* scanner already stopped */ })
+        }
+      }
+    }
+  }, [open, showManual, handleScan])
+
+  const handleManualSubmit = (e: React.FormEvent) => {
+    e.preventDefault()
+    const trimmed = manualValue.trim()
+    if (trimmed) {
+      handleScan(trimmed)
+    }
+  }
+
+  const handleOpenChange = (v: boolean) => {
+    if (!v) {
+      setError(null)
+      setShowManual(false)
+      setManualValue('')
+    }
+    onOpenChange(v)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{title}</DialogTitle>
+          <DialogDescription className="sr-only">
+            Point your camera at a barcode or enter it manually
+          </DialogDescription>
+        </DialogHeader>
+
+        {!showManual ? (
+          <div className="space-y-3">
+            <div
+              ref={scannerRef}
+              className="relative aspect-square w-full overflow-hidden rounded-lg bg-black"
+            />
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              className="w-full"
+              onClick={() => setShowManual(true)}
+            >
+              <Keyboard className="mr-2 h-4 w-4" />
+              Enter manually
+            </Button>
+          </div>
+        ) : (
+          <div className="space-y-3">
+            {error && (
+              <p className="text-sm text-muted-foreground">{error}</p>
+            )}
+            <form onSubmit={handleManualSubmit} className="flex gap-2">
+              <Input
+                placeholder="Enter barcode..."
+                value={manualValue}
+                onChange={(e) => setManualValue(e.target.value)}
+                autoFocus
+              />
+              <Button type="submit" disabled={!manualValue.trim()}>
+                Go
+              </Button>
+            </form>
+            {!error && (
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                className="w-full"
+                onClick={() => setShowManual(false)}
+              >
+                <Camera className="mr-2 h-4 w-4" />
+                Use camera
+              </Button>
+            )}
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 4 - 0
src/features/inventory/Actions/inventoryActions.ts

@@ -30,6 +30,7 @@ export async function getInventoryPartsPaginated(params: {
       where.OR = [
         { name: { contains: q, mode } },
         { partNumber: { contains: q, mode } },
+        { barcode: { contains: q, mode } },
         { description: { contains: q, mode } },
         { supplier: { contains: q, mode } },
         { location: { contains: q, mode } },
@@ -77,6 +78,7 @@ export async function createInventoryPart(input: unknown) {
       data: {
         ...data,
         partNumber: data.partNumber || undefined,
+        barcode: data.barcode || undefined,
         description: data.description || undefined,
         category: data.category || undefined,
         supplier: data.supplier || undefined,
@@ -128,6 +130,7 @@ export async function updateInventoryPart(input: unknown) {
       data: {
         ...updateData,
         partNumber: updateData.partNumber !== undefined ? (updateData.partNumber || null) : undefined,
+        barcode: updateData.barcode !== undefined ? (updateData.barcode || null) : undefined,
         description: updateData.description !== undefined ? (updateData.description || null) : undefined,
         category: updateData.category !== undefined ? (updateData.category || null) : undefined,
         supplier: updateData.supplier !== undefined ? (updateData.supplier || null) : undefined,
@@ -225,6 +228,7 @@ export async function getInventoryPartsList() {
       select: {
         id: true,
         partNumber: true,
+        barcode: true,
         name: true,
         description: true,
         unitCost: true,

+ 33 - 0
src/features/inventory/Actions/lookupPartByBarcode.ts

@@ -0,0 +1,33 @@
+"use server"
+
+import { db } from "@/lib/db"
+import { withAuth } from "@/lib/with-auth"
+import { PermissionAction, PermissionSubject } from "@/lib/permissions"
+
+export async function lookupPartByBarcode(barcode: string) {
+  return withAuth(async ({ organizationId }) => {
+    const part = await db.inventoryPart.findFirst({
+      where: {
+        organizationId,
+        barcode: { equals: barcode, mode: "insensitive" },
+        isArchived: false,
+      },
+      select: {
+        id: true,
+        partNumber: true,
+        barcode: true,
+        name: true,
+        description: true,
+        unitCost: true,
+        sellPrice: true,
+        quantity: true,
+        category: true,
+      },
+    })
+    return part
+  }, {
+    requiredPermissions: [
+      { action: PermissionAction.READ, subject: PermissionSubject.INVENTORY },
+    ],
+  })
+}

+ 141 - 0
src/features/inventory/Components/BarcodeScanActionDialog.tsx

@@ -0,0 +1,141 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { Pencil, Plus, Package } from 'lucide-react'
+import { adjustInventoryStock } from '../Actions/inventoryActions'
+import { toast } from 'sonner'
+
+interface BarcodeScanActionDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  part: {
+    id: string
+    name: string
+    partNumber: string | null
+    barcode: string | null
+    quantity: number
+    category: string | null
+  } | null
+  barcode: string
+  onEditPart: (partId: string) => void
+  onCreatePart: (barcode: string) => void
+}
+
+export function BarcodeScanActionDialog({
+  open,
+  onOpenChange,
+  part,
+  barcode,
+  onEditPart,
+  onCreatePart,
+}: BarcodeScanActionDialogProps) {
+  const t = useTranslations('inventory.barcodeScan')
+  const [customQty, setCustomQty] = useState('')
+  const [loading, setLoading] = useState(false)
+
+  const handleAddStock = async (amount: number) => {
+    if (!part) return
+    setLoading(true)
+    const result = await adjustInventoryStock({ id: part.id, adjustment: amount })
+    setLoading(false)
+    if (result.success) {
+      toast.success(t('addedStock', { amount }))
+      onOpenChange(false)
+    }
+  }
+
+  const handleCustomAdd = async () => {
+    const qty = parseInt(customQty, 10)
+    if (qty > 0) await handleAddStock(qty)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-sm">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+        </DialogHeader>
+
+        {part ? (
+          <div className="space-y-4">
+            <div className="rounded-lg border p-3">
+              <p className="font-medium">{part.name}</p>
+              <div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
+                {part.partNumber && <span className="font-mono">{part.partNumber}</span>}
+                {part.category && <Badge variant="secondary" className="text-xs">{part.category}</Badge>}
+              </div>
+              <p className="mt-1 text-sm">{t('currentStock', { quantity: part.quantity })}</p>
+            </div>
+
+            <div className="space-y-2">
+              <Label className="text-sm font-medium">{t('addStock')}</Label>
+              <div className="flex gap-2">
+                <Button size="sm" variant="outline" onClick={() => handleAddStock(1)} disabled={loading}>
+                  +1
+                </Button>
+                <Button size="sm" variant="outline" onClick={() => handleAddStock(5)} disabled={loading}>
+                  +5
+                </Button>
+                <div className="flex flex-1 gap-1">
+                  <Input
+                    type="number"
+                    min="1"
+                    placeholder={t('customAmount')}
+                    value={customQty}
+                    onChange={(e) => setCustomQty(e.target.value)}
+                    className="h-8"
+                  />
+                  <Button size="sm" onClick={handleCustomAdd} disabled={loading || !customQty}>
+                    <Plus className="h-3.5 w-3.5" />
+                  </Button>
+                </div>
+              </div>
+            </div>
+
+            <Button
+              variant="outline"
+              className="w-full"
+              onClick={() => {
+                onOpenChange(false)
+                onEditPart(part.id)
+              }}
+            >
+              <Pencil className="mr-2 h-4 w-4" />
+              {t('editPart')}
+            </Button>
+          </div>
+        ) : (
+          <div className="space-y-4">
+            <div className="rounded-lg border border-dashed p-4 text-center">
+              <Package className="mx-auto h-8 w-8 text-muted-foreground" />
+              <p className="mt-2 text-sm text-muted-foreground">
+                {t('notFound', { barcode })}
+              </p>
+            </div>
+            <Button
+              className="w-full"
+              onClick={() => {
+                onOpenChange(false)
+                onCreatePart(barcode)
+              }}
+            >
+              <Plus className="mr-2 h-4 w-4" />
+              {t('createNew')}
+            </Button>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 15 - 2
src/features/inventory/Components/InventoryPartForm.tsx

@@ -23,9 +23,11 @@ interface InventoryPartFormProps {
   open: boolean;
   onOpenChange: (open: boolean) => void;
   markupMultiplier?: number;
+  initialBarcode?: string;
   part?: {
     id: string;
     partNumber: string | null;
+    barcode: string | null;
     name: string;
     description: string | null;
     category: string | null;
@@ -42,7 +44,7 @@ interface InventoryPartFormProps {
   };
 }
 
-export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier }: InventoryPartFormProps) {
+export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier, initialBarcode }: InventoryPartFormProps) {
   const router = useRouter();
   const modal = useGlassModal();
   const t = useTranslations('inventory');
@@ -149,6 +151,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier }
       setIfEmpty("name", metadata.name);
       setIfEmpty("description", metadata.description);
       setIfEmpty("partNumber", metadata.partNumber);
+      setIfEmpty("barcode", metadata.barcode);
       setIfEmpty("supplier", metadata.supplier);
       setIfEmpty("category", metadata.category);
       if (metadata.unitCost !== undefined) {
@@ -175,6 +178,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier }
     const data = {
       name: formData.get("name") as string,
       partNumber: (formData.get("partNumber") as string) || undefined,
+      barcode: (formData.get("barcode") as string) || undefined,
       description: (formData.get("description") as string) || undefined,
       category: (formData.get("category") as string) || undefined,
       quantity: Number(formData.get("quantity")) || 0,
@@ -342,7 +346,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier }
 
             {/* Fields */}
             <div className="flex-1 space-y-4">
-              <div className="grid grid-cols-2 gap-4">
+              <div className="grid grid-cols-3 gap-4">
                 <div className="space-y-2">
                   <Label htmlFor="partNumber">{t('form.partNumber')}</Label>
                   <Input
@@ -352,6 +356,15 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier }
                     defaultValue={part?.partNumber ?? ""}
                   />
                 </div>
+                <div className="space-y-2">
+                  <Label htmlFor="barcode">{t('form.barcode')}</Label>
+                  <Input
+                    id="barcode"
+                    name="barcode"
+                    placeholder={t('form.barcodePlaceholder')}
+                    defaultValue={part?.barcode ?? initialBarcode ?? ""}
+                  />
+                </div>
                 <div className="space-y-2">
                   <Label htmlFor="name">{t('form.nameLabel')}</Label>
                   <Input

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

@@ -3,6 +3,7 @@ import { z } from "zod";
 export const createInventoryPartSchema = z.object({
   name: z.string().min(1, "Part name is required"),
   partNumber: z.string().optional(),
+  barcode: z.string().optional(),
   description: z.string().optional(),
   category: z.string().optional(),
   quantity: z.coerce.number().int().min(0, "Quantity must be 0 or more").default(0),

+ 6 - 0
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -37,6 +37,7 @@ export function InventoryPickerDialog({
     return (
       p.name.toLowerCase().includes(q) ||
       (p.partNumber && p.partNumber.toLowerCase().includes(q)) ||
+      (p.barcode && p.barcode.toLowerCase().includes(q)) ||
       (p.category && p.category.toLowerCase().includes(q))
     )
   })
@@ -107,6 +108,11 @@ export function InventoryPickerDialog({
                       {ip.partNumber}
                     </span>
                   )}
+                  {ip.barcode && (
+                    <span className="text-xs font-mono text-muted-foreground shrink-0">
+                      {ip.barcode}
+                    </span>
+                  )}
                   {ip.category && (
                     <span className="text-xs text-muted-foreground shrink-0">{ip.category}</span>
                   )}

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

@@ -3,7 +3,7 @@
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { Package, Plus, Trash2 } from 'lucide-react'
+import { Package, Plus, ScanBarcode, Trash2 } from 'lucide-react'
 import { formatCurrency } from '@/lib/format'
 import { useTranslations } from 'next-intl'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
@@ -17,6 +17,7 @@ interface PartsEditorProps {
   currencyCode: string
   hasInventory: boolean
   onOpenInventory: () => void
+  onScanBarcode?: () => void
 }
 
 export function PartsEditor({
@@ -27,6 +28,7 @@ export function PartsEditor({
   currencyCode,
   hasInventory,
   onOpenInventory,
+  onScanBarcode,
 }: PartsEditorProps) {
   const t = useTranslations('service.parts')
 
@@ -41,6 +43,12 @@ export function PartsEditor({
               {t('fromInventory')}
             </Button>
           )}
+          {onScanBarcode && (
+            <Button type="button" variant="outline" size="sm" onClick={onScanBarcode}>
+              <ScanBarcode className="mr-1 h-3.5 w-3.5" />
+              {t('scanBarcode')}
+            </Button>
+          )}
           <Button
             type="button"
             variant="outline"

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

@@ -7,6 +7,7 @@ import type {
 export interface InventoryPartOption {
   id: string
   partNumber: string | null
+  barcode: string | null
   name: string
   description: string | null
   unitCost: number

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

@@ -17,6 +17,7 @@ interface DetailsLeftColumnProps {
   inventoryParts: InventoryPartOption[]
   hasPresets?: boolean
   onOpenPresets?: () => void
+  onScanBarcode?: () => void
   aiEnabled?: boolean
 }
 
@@ -29,6 +30,7 @@ export function DetailsLeftColumn({
   inventoryParts,
   hasPresets,
   onOpenPresets,
+  onScanBarcode,
   aiEnabled,
 }: DetailsLeftColumnProps) {
   return (
@@ -41,6 +43,7 @@ export function DetailsLeftColumn({
         currencyCode={currencyCode}
         hasInventory={inventoryParts.length > 0}
         onOpenInventory={() => formState.setShowInventoryPicker(true)}
+        onScanBarcode={onScanBarcode}
       />
       <LaborEditor
         laborItems={formState.laborItems}

+ 34 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useState, useCallback } from 'react'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
 import { useTranslations } from 'next-intl'
@@ -10,6 +10,10 @@ import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
 import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
 import { InventoryPickerDialog } from '../service-edit/InventoryPickerDialog'
+import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
+import { useHardwareScanner } from '@/hooks/use-hardware-scanner'
+import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
+import { toast } from 'sonner'
 import { LaborPresetPickerDialog } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 import type { LaborPresetOption } from './service-page-types'
 import { ServiceImagesManager } from '../service-images-manager'
@@ -61,6 +65,27 @@ export function ServicePageClient({
     record,
   })
 
+  const handleBarcodeScan = useCallback(async (barcode: string) => {
+    const result = await lookupPartByBarcode(barcode)
+    if (result.success && result.data) {
+      const part = result.data
+      const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
+      formState.dirtySetPartItems((prev) => [...prev, {
+        partNumber: part.partNumber || '',
+        name: part.name,
+        quantity: 1,
+        unitPrice: price,
+        total: price,
+        inventoryPartId: part.id,
+      }])
+      toast.success(t('parts.partFound', { name: part.name }))
+    } else {
+      toast.error(t('parts.partNotFound', { barcode }))
+    }
+  }, [formState, t])
+
+  useHardwareScanner({ onScan: handleBarcodeScan, enabled: activeTab === 'details' })
+
   const handleSelectPreset = (preset: LaborPresetOption) => {
     const newItems = preset.items.map((item) => ({
       description: item.description,
@@ -116,6 +141,7 @@ export function ServicePageClient({
                 inventoryParts={inventoryParts}
                 hasPresets={laborPresets.length > 0}
                 onOpenPresets={() => formState.setShowPresetPicker(true)}
+                onScanBarcode={() => formState.setShowBarcodeScanner(true)}
                 aiEnabled={aiEnabled}
               />
             }
@@ -181,6 +207,13 @@ export function ServicePageClient({
         onSelectPreset={handleSelectPreset}
       />
 
+      <BarcodeScannerDialog
+        open={formState.showBarcodeScanner}
+        onOpenChange={formState.setShowBarcodeScanner}
+        onScan={handleBarcodeScan}
+        title={t('parts.scanTitle')}
+      />
+
       <ImageCarousel
         images={formState.imageAttachments}
         currentIndex={actions.carouselIndex}

+ 2 - 0
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -28,6 +28,7 @@ export function useServiceFormState({
   const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
   const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
   const [showInventoryPicker, setShowInventoryPicker] = useState(false)
+  const [showBarcodeScanner, setShowBarcodeScanner] = useState(false)
   const [showPresetPicker, setShowPresetPicker] = useState(false)
 
   // Autosave state
@@ -182,6 +183,7 @@ export function useServiceFormState({
     partItems, laborItems,
     taxRate, discountType, discountValue,
     showInventoryPicker, setShowInventoryPicker,
+    showBarcodeScanner, setShowBarcodeScanner,
     showPresetPicker, setShowPresetPicker,
     // Autosave
     hasUnsavedChanges, setHasUnsavedChanges, showSaved,

+ 88 - 0
src/hooks/use-hardware-scanner.ts

@@ -0,0 +1,88 @@
+import { useEffect, useRef, useCallback } from 'react'
+
+interface UseHardwareScannerOptions {
+  onScan: (barcode: string) => void
+  enabled?: boolean
+}
+
+export function useHardwareScanner({ onScan, enabled = true }: UseHardwareScannerOptions) {
+  const bufferRef = useRef('')
+  const lastKeystrokeRef = useRef(0)
+  const timestampsRef = useRef<number[]>([])
+  const cooldownRef = useRef(false)
+
+  const reset = useCallback(() => {
+    bufferRef.current = ''
+    timestampsRef.current = []
+  }, [])
+
+  useEffect(() => {
+    if (!enabled) {
+      reset()
+      return
+    }
+
+    let timeoutId: ReturnType<typeof setTimeout> | null = null
+
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (cooldownRef.current) return
+
+      // Ignore when user is typing in input/textarea
+      const target = e.target as HTMLElement
+      const tag = target.tagName
+      if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) {
+        // Allow if it looks like a scanner (very rapid input)
+        const now = Date.now()
+        const gap = now - lastKeystrokeRef.current
+        if (gap > 100 && bufferRef.current.length === 0) return
+      }
+
+      const now = Date.now()
+
+      if (e.key === 'Enter') {
+        e.preventDefault()
+        const buffer = bufferRef.current
+        const timestamps = timestampsRef.current
+
+        if (buffer.length >= 4 && timestamps.length >= 2) {
+          // Check average inter-keystroke time
+          let totalGap = 0
+          for (let i = 1; i < timestamps.length; i++) {
+            totalGap += timestamps[i] - timestamps[i - 1]
+          }
+          const avgGap = totalGap / (timestamps.length - 1)
+
+          if (avgGap < 80) {
+            cooldownRef.current = true
+            onScan(buffer)
+            setTimeout(() => { cooldownRef.current = false }, 500)
+          }
+        }
+        reset()
+        return
+      }
+
+      // Only accumulate printable single characters
+      if (e.key.length === 1) {
+        // Reset if gap too large (new sequence)
+        if (now - lastKeystrokeRef.current > 300 && bufferRef.current.length > 0) {
+          reset()
+        }
+
+        bufferRef.current += e.key
+        timestampsRef.current.push(now)
+        lastKeystrokeRef.current = now
+
+        // Auto-clear after 300ms of no input
+        if (timeoutId) clearTimeout(timeoutId)
+        timeoutId = setTimeout(reset, 300)
+      }
+    }
+
+    window.addEventListener('keydown', handleKeyDown, true)
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown, true)
+      if (timeoutId) clearTimeout(timeoutId)
+    }
+  }, [enabled, onScan, reset])
+}