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

Improve mobile barcode scanning with quantity selection and drawer UI (#97)

* barcode scanner

* parts

* ports

* stricter barcode

* drawer

* mobile

* translations
Bernt Christian Egeland пре 6 месеци
родитељ
комит
709a56df05

+ 12 - 8
messages/de/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Meine aktiven Aufträge",
+    "description": "Ihnen zugewiesene Arbeitsaufträge.",
+    "uploadFailed": "Bild konnte nicht hochgeladen werden",
+    "uploadSuccess": "{count} Bild(er) hochgeladen",
+    "takePhoto": "Foto aufnehmen",
+    "addPhoto": "Foto hinzufügen",
+    "scanPart": "Teil scannen",
+    "partAdded": "\"{name}\" zum Auftrag hinzugefügt",
+    "partAddFailed": "Teil konnte nicht hinzugefügt werden",
+    "inStock": "Auf Lager: {quantity}",
+    "quantity": "Menge",
+    "addToJob": "{qty} zum Auftrag hinzufügen",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 4 - 0
messages/en/dashboard.json

@@ -5,9 +5,13 @@
     "uploadFailed": "Failed to upload image",
     "uploadFailed": "Failed to upload image",
     "uploadSuccess": "{count} image(s) uploaded",
     "uploadSuccess": "{count} image(s) uploaded",
     "takePhoto": "Take photo",
     "takePhoto": "Take photo",
+    "addPhoto": "Add photo",
     "scanPart": "Scan part",
     "scanPart": "Scan part",
     "partAdded": "Added \"{name}\" to work order",
     "partAdded": "Added \"{name}\" to work order",
     "partAddFailed": "Failed to add part",
     "partAddFailed": "Failed to add part",
+    "inStock": "In stock: {quantity}",
+    "quantity": "Quantity",
+    "addToJob": "Add {qty} to job",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/es/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Mis trabajos activos",
+    "description": "Órdenes de trabajo asignadas a usted.",
+    "uploadFailed": "Error al subir la imagen",
+    "uploadSuccess": "{count} imagen(es) subida(s)",
+    "takePhoto": "Tomar foto",
+    "addPhoto": "Añadir foto",
+    "scanPart": "Escanear pieza",
+    "partAdded": "\"{name}\" añadido a la orden de trabajo",
+    "partAddFailed": "Error al añadir la pieza",
+    "inStock": "En stock: {quantity}",
+    "quantity": "Cantidad",
+    "addToJob": "Añadir {qty} al trabajo",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/fr/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Mes travaux actifs",
+    "description": "Ordres de travail qui vous sont assignés.",
+    "uploadFailed": "Échec du téléversement de l'image",
+    "uploadSuccess": "{count} image(s) téléversée(s)",
+    "takePhoto": "Prendre une photo",
+    "addPhoto": "Ajouter photo",
+    "scanPart": "Scanner la pièce",
+    "partAdded": "\"{name}\" ajouté à l'ordre de travail",
+    "partAddFailed": "Échec de l'ajout de la pièce",
+    "inStock": "En stock : {quantity}",
+    "quantity": "Quantité",
+    "addToJob": "Ajouter {qty} au travail",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/it/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "I miei lavori attivi",
+    "description": "Ordini di lavoro assegnati a te.",
+    "uploadFailed": "Caricamento immagine non riuscito",
+    "uploadSuccess": "{count} immagine/i caricata/e",
+    "takePhoto": "Scatta foto",
+    "addPhoto": "Aggiungi foto",
+    "scanPart": "Scansiona ricambio",
+    "partAdded": "\"{name}\" aggiunto all'ordine di lavoro",
+    "partAddFailed": "Impossibile aggiungere il ricambio",
+    "inStock": "Disponibile: {quantity}",
+    "quantity": "Quantità",
+    "addToJob": "Aggiungi {qty} al lavoro",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/nb/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Mine aktive jobber",
+    "description": "Arbeidsordrer tildelt deg.",
+    "uploadFailed": "Kunne ikke laste opp bilde",
+    "uploadSuccess": "{count} bilde(r) lastet opp",
+    "takePhoto": "Ta bilde",
+    "addPhoto": "Legg til bilde",
+    "scanPart": "Skann del",
+    "partAdded": "\"{name}\" lagt til i arbeidsordre",
+    "partAddFailed": "Kunne ikke legge til del",
+    "inStock": "På lager: {quantity}",
+    "quantity": "Antall",
+    "addToJob": "Legg til {qty} i jobb",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/nl/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Mijn actieve opdrachten",
+    "description": "Werkorders die aan u zijn toegewezen.",
+    "uploadFailed": "Afbeelding uploaden mislukt",
+    "uploadSuccess": "{count} afbeelding(en) geüpload",
+    "takePhoto": "Foto maken",
+    "addPhoto": "Foto toevoegen",
+    "scanPart": "Onderdeel scannen",
+    "partAdded": "\"{name}\" toegevoegd aan werkorder",
+    "partAddFailed": "Onderdeel toevoegen mislukt",
+    "inStock": "Op voorraad: {quantity}",
+    "quantity": "Aantal",
+    "addToJob": "{qty} toevoegen aan klus",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/pl/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Moje aktywne zlecenia",
+    "description": "Zlecenia pracy przypisane do Ciebie.",
+    "uploadFailed": "Nie udało się przesłać zdjęcia",
+    "uploadSuccess": "Przesłano {count} zdjęcie/zdjęcia",
+    "takePhoto": "Zrób zdjęcie",
+    "addPhoto": "Dodaj zdjęcie",
+    "scanPart": "Skanuj część",
+    "partAdded": "Dodano \"{name}\" do zlecenia",
+    "partAddFailed": "Nie udało się dodać części",
+    "inStock": "Na stanie: {quantity}",
+    "quantity": "Ilość",
+    "addToJob": "Dodaj {qty} do zlecenia",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/pt-BR/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Meus trabalhos ativos",
+    "description": "Ordens de serviço atribuídas a você.",
+    "uploadFailed": "Falha ao enviar imagem",
+    "uploadSuccess": "{count} imagem(ns) enviada(s)",
+    "takePhoto": "Tirar foto",
+    "addPhoto": "Adicionar foto",
+    "scanPart": "Escanear peça",
+    "partAdded": "\"{name}\" adicionado à ordem de serviço",
+    "partAddFailed": "Falha ao adicionar peça",
+    "inStock": "Em estoque: {quantity}",
+    "quantity": "Quantidade",
+    "addToJob": "Adicionar {qty} ao serviço",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/ru/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Мои активные работы",
+    "description": "Назначенные вам заказ-наряды.",
+    "uploadFailed": "Не удалось загрузить изображение",
+    "uploadSuccess": "Загружено изображений: {count}",
+    "takePhoto": "Сделать фото",
+    "addPhoto": "Добавить фото",
+    "scanPart": "Сканировать запчасть",
+    "partAdded": "\"{name}\" добавлено в заказ-наряд",
+    "partAddFailed": "Не удалось добавить запчасть",
+    "inStock": "В наличии: {quantity}",
+    "quantity": "Количество",
+    "addToJob": "Добавить {qty} к заказу",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 12 - 8
messages/tr/dashboard.json

@@ -1,13 +1,17 @@
 {
 {
   "myJobs": {
   "myJobs": {
-    "title": "My Active Jobs",
-    "description": "Work orders assigned to you.",
-    "uploadFailed": "Failed to upload image",
-    "uploadSuccess": "{count} image(s) uploaded",
-    "takePhoto": "Take photo",
-    "scanPart": "Scan part",
-    "partAdded": "Added \"{name}\" to work order",
-    "partAddFailed": "Failed to add part",
+    "title": "Aktif İşlerim",
+    "description": "Size atanan iş emirleri.",
+    "uploadFailed": "Görsel yüklenemedi",
+    "uploadSuccess": "{count} görsel yüklendi",
+    "takePhoto": "Fotoğraf çek",
+    "addPhoto": "Fotoğraf ekle",
+    "scanPart": "Parça tara",
+    "partAdded": "\"{name}\" iş emrine eklendi",
+    "partAddFailed": "Parça eklenemedi",
+    "inStock": "Stokta: {quantity}",
+    "quantity": "Miktar",
+    "addToJob": "{qty} işe ekle",
     "createPart": {
     "createPart": {
       "title": "Part Not Found",
       "title": "Part Not Found",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",
       "description": "No part found for barcode \"{barcode}\". Create a new one?",

+ 32 - 3
src/components/barcode-scanner-dialog.tsx

@@ -32,10 +32,26 @@ export function BarcodeScannerDialog({
   const [manualValue, setManualValue] = useState('')
   const [manualValue, setManualValue] = useState('')
   const hasScannedRef = useRef(false)
   const hasScannedRef = useRef(false)
 
 
+  const scanBufferRef = useRef<string[]>([])
+  const REQUIRED_CONSISTENT_READS = 3
+
   const handleScan = useCallback(
   const handleScan = useCallback(
     (decodedText: string) => {
     (decodedText: string) => {
       if (hasScannedRef.current) return
       if (hasScannedRef.current) return
+
+      const buffer = scanBufferRef.current
+      if (buffer.length > 0 && buffer[buffer.length - 1] !== decodedText) {
+        // Different barcode read — reset the buffer
+        scanBufferRef.current = [decodedText]
+        return
+      }
+
+      buffer.push(decodedText)
+      if (buffer.length < REQUIRED_CONSISTENT_READS) return
+
+      // Same barcode confirmed N times in a row — accept it
       hasScannedRef.current = true
       hasScannedRef.current = true
+      scanBufferRef.current = []
       onScan(decodedText)
       onScan(decodedText)
       onOpenChange(false)
       onOpenChange(false)
     },
     },
@@ -51,10 +67,11 @@ export function BarcodeScannerDialog({
     let scanner: any = null
     let scanner: any = null
     let mounted = true
     let mounted = true
     let running = false
     let running = false
+    scanBufferRef.current = []
 
 
     const startScanner = async () => {
     const startScanner = async () => {
       try {
       try {
-        const { Html5Qrcode } = await import('html5-qrcode')
+        const { Html5Qrcode, Html5QrcodeSupportedFormats } = await import('html5-qrcode')
 
 
         if (!mounted || !scannerRef.current) return
         if (!mounted || !scannerRef.current) return
 
 
@@ -65,11 +82,23 @@ export function BarcodeScannerDialog({
           scannerRef.current.appendChild(div)
           scannerRef.current.appendChild(div)
         }
         }
 
 
-        scanner = new Html5Qrcode(containerId)
+        scanner = new Html5Qrcode(containerId, {
+          verbose: false,
+          formatsToSupport: [
+            Html5QrcodeSupportedFormats.CODE_128,
+            Html5QrcodeSupportedFormats.CODE_39,
+            Html5QrcodeSupportedFormats.EAN_13,
+            Html5QrcodeSupportedFormats.EAN_8,
+            Html5QrcodeSupportedFormats.UPC_A,
+            Html5QrcodeSupportedFormats.UPC_E,
+            Html5QrcodeSupportedFormats.ITF,
+            Html5QrcodeSupportedFormats.QR_CODE,
+          ],
+        })
         html5QrRef.current = scanner
         html5QrRef.current = scanner
 
 
         const scanConfig = {
         const scanConfig = {
-          fps: 15,
+          fps: 10,
           qrbox: (viewfinderWidth: number, viewfinderHeight: number) => ({
           qrbox: (viewfinderWidth: number, viewfinderHeight: number) => ({
             width: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.8),
             width: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.8),
             height: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.3),
             height: Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.3),

+ 157 - 9
src/components/mobile-bottom-nav.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { useState } from 'react'
+import { useState, useCallback } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
 import { usePathname, useRouter } from 'next/navigation'
 import { usePathname, useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -14,8 +14,11 @@ import {
   FileText,
   FileText,
   History,
   History,
   Layers,
   Layers,
+  Loader2,
   Menu,
   Menu,
   Package,
   Package,
+  Pencil,
+  Plus,
   Receipt,
   Receipt,
   ScanBarcode,
   ScanBarcode,
   Settings,
   Settings,
@@ -26,10 +29,14 @@ import {
   DrawerContent,
   DrawerContent,
   DrawerHeader,
   DrawerHeader,
   DrawerTitle,
   DrawerTitle,
+  DrawerFooter,
 } from '@/components/ui/drawer'
 } from '@/components/ui/drawer'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
-import { adjustInventoryStock } from '@/features/inventory/Actions/inventoryActions'
+import { adjustInventoryStock, getInventoryPart } from '@/features/inventory/Actions/inventoryActions'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
@@ -60,22 +67,36 @@ export function MobileBottomNav() {
   const tScan = useTranslations('inventory.barcodeScan')
   const tScan = useTranslations('inventory.barcodeScan')
   const [drawerOpen, setDrawerOpen] = useState(false)
   const [drawerOpen, setDrawerOpen] = useState(false)
   const [scannerOpen, setScannerOpen] = useState(false)
   const [scannerOpen, setScannerOpen] = 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 [showPartForm, setShowPartForm] = useState(false)
+  const [editPartData, setEditPartData] = useState<Parameters<typeof InventoryPartForm>[0]['part']>(undefined)
+  const [addQty, setAddQty] = useState(1)
+  const [addingStock, setAddingStock] = useState(false)
 
 
   const isMoreActive = drawerItems.some(
   const isMoreActive = drawerItems.some(
     (item) => pathname === item.href || pathname.startsWith(`${item.href}/`)
     (item) => pathname === item.href || pathname.startsWith(`${item.href}/`)
   )
   )
 
 
-  const handleBarcodeScan = async (barcode: string) => {
+  const handleBarcodeScan = useCallback(async (barcode: string) => {
     const result = await lookupPartByBarcode(barcode)
     const result = await lookupPartByBarcode(barcode)
+    setScannedBarcode(barcode)
+    setAddQty(1)
     if (result.success && result.data) {
     if (result.success && result.data) {
-      const part = result.data
-      await adjustInventoryStock({ id: part.id, adjustment: 1 })
-      toast.success(tScan('addedStock', { amount: 1 }))
-      router.refresh()
+      setScannedPart(result.data)
     } else {
     } else {
-      toast.error(tScan('notFound', { barcode }))
+      setScannedPart(null)
     }
     }
-  }
+    setShowScanActions(true)
+  }, [])
 
 
   return (
   return (
     <>
     <>
@@ -163,6 +184,133 @@ export function MobileBottomNav() {
         onOpenChange={setScannerOpen}
         onOpenChange={setScannerOpen}
         onScan={handleBarcodeScan}
         onScan={handleBarcodeScan}
       />
       />
+
+      <Drawer open={showScanActions} onOpenChange={(open) => {
+        setShowScanActions(open)
+        if (!open) router.refresh()
+      }}>
+        <DrawerContent>
+          <DrawerHeader>
+            <DrawerTitle>{tScan('title')}</DrawerTitle>
+          </DrawerHeader>
+          {scannedPart ? (
+            <div className="px-4">
+              <div className="flex items-center gap-3 rounded-lg border p-3">
+                <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
+                  <Package className="h-5 w-5 text-primary" />
+                </div>
+                <div className="min-w-0 flex-1">
+                  <p className="font-medium truncate">{scannedPart.name}</p>
+                  <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                    {scannedPart.partNumber && <span className="font-mono text-xs">{scannedPart.partNumber}</span>}
+                    {scannedPart.category && <Badge variant="secondary" className="text-[10px]">{scannedPart.category}</Badge>}
+                  </div>
+                </div>
+                <div className="text-right text-sm text-muted-foreground shrink-0">
+                  {tScan('currentStock', { quantity: scannedPart.quantity })}
+                </div>
+              </div>
+
+              <div className="flex items-center justify-center gap-4 py-6">
+                <Button
+                  variant="outline"
+                  size="icon"
+                  className="h-12 w-12 rounded-full text-lg"
+                  onClick={() => setAddQty(Math.max(1, addQty - 1))}
+                  disabled={addingStock || addQty <= 1}
+                >
+                  -
+                </Button>
+                <span className="w-16 text-center text-3xl font-semibold tabular-nums">{addQty}</span>
+                <Button
+                  variant="outline"
+                  size="icon"
+                  className="h-12 w-12 rounded-full text-lg"
+                  onClick={() => setAddQty(addQty + 1)}
+                  disabled={addingStock}
+                >
+                  +
+                </Button>
+              </div>
+            </div>
+          ) : (
+            <div className="px-4">
+              <div className="flex flex-col items-center rounded-lg border border-dashed p-6">
+                <Package className="h-8 w-8 text-muted-foreground" />
+                <p className="mt-2 text-sm text-muted-foreground">
+                  {tScan('notFound', { barcode: scannedBarcode })}
+                </p>
+              </div>
+            </div>
+          )}
+          <DrawerFooter>
+            {scannedPart ? (
+              <div className="flex gap-2">
+                <Button
+                  size="lg"
+                  className="flex-1"
+                  onClick={async () => {
+                    setAddingStock(true)
+                    const result = await adjustInventoryStock({ id: scannedPart.id, adjustment: addQty })
+                    setAddingStock(false)
+                    if (result.success) {
+                      toast.success(tScan('addedStock', { amount: addQty }))
+                      setShowScanActions(false)
+                      router.refresh()
+                    }
+                  }}
+                  disabled={addingStock}
+                >
+                  {addingStock ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
+                  {tScan('addStock')} (+{addQty})
+                </Button>
+                <Button
+                  size="lg"
+                  variant="outline"
+                  onClick={async () => {
+                    const result = await getInventoryPart(scannedPart.id)
+                    if (result.success && result.data) {
+                      setEditPartData(result.data)
+                    }
+                    setShowScanActions(false)
+                    setShowPartForm(true)
+                  }}
+                >
+                  <Pencil className="h-4 w-4" />
+                </Button>
+              </div>
+            ) : (
+              <Button
+                size="lg"
+                className="w-full"
+                onClick={() => {
+                  setShowScanActions(false)
+                  setShowPartForm(true)
+                }}
+              >
+                <Plus className="mr-2 h-4 w-4" />
+                {tScan('createNew')}
+              </Button>
+            )}
+          </DrawerFooter>
+        </DrawerContent>
+      </Drawer>
+
+      <InventoryPartForm
+        key={editPartData?.id ?? scannedBarcode ?? 'mobile-new'}
+        open={showPartForm}
+        onOpenChange={(open) => {
+          setShowPartForm(open)
+          if (!open) {
+            setScannedBarcode('')
+            setScannedPart(null)
+            setEditPartData(undefined)
+            router.refresh()
+          }
+        }}
+        part={editPartData}
+        initialBarcode={!editPartData ? scannedBarcode : undefined}
+      />
     </>
     </>
   )
   )
 }
 }

+ 149 - 66
src/features/vehicles/Components/MyActiveJobs.tsx

@@ -10,18 +10,27 @@ import {
   Wrench,
   Wrench,
   Clock,
   Clock,
   Pause,
   Pause,
+  Plus,
   ScanBarcode,
   ScanBarcode,
   Package,
   Package,
 } from 'lucide-react'
 } from 'lucide-react'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerTitle,
+  DrawerFooter,
+} from '@/components/ui/drawer'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { compressImage } from '@/lib/compress-image'
 import { compressImage } from '@/lib/compress-image'
 import { addServiceAttachment } from '@/features/vehicles/Actions/addServiceAttachment'
 import { addServiceAttachment } from '@/features/vehicles/Actions/addServiceAttachment'
 import { addPartToServiceRecord } from '@/features/vehicles/Actions/addPartToServiceRecord'
 import { addPartToServiceRecord } from '@/features/vehicles/Actions/addPartToServiceRecord'
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
-import { CreatePartDialog } from './CreatePartDialog'
+import { InventoryPartForm } from '@/features/inventory/Components/InventoryPartForm'
 import type { MyActiveJob } from '@/features/vehicles/Actions/getMyActiveJobs'
 import type { MyActiveJob } from '@/features/vehicles/Actions/getMyActiveJobs'
 
 
 interface MyActiveJobsProps {
 interface MyActiveJobsProps {
@@ -52,8 +61,21 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
   // Barcode scanner state
   // Barcode scanner state
   const [scannerOpen, setScannerOpen] = useState(false)
   const [scannerOpen, setScannerOpen] = useState(false)
   const [scanJobId, setScanJobId] = useState<string | null>(null)
   const [scanJobId, setScanJobId] = useState<string | null>(null)
-  const [createPartOpen, setCreatePartOpen] = useState(false)
   const [pendingBarcode, setPendingBarcode] = useState('')
   const [pendingBarcode, setPendingBarcode] = useState('')
+  const [showPartForm, setShowPartForm] = useState(false)
+  const [showAddDialog, setShowAddDialog] = useState(false)
+  const [scannedPart, setScannedPart] = useState<{
+    id: string
+    name: string
+    partNumber: string | null
+    barcode: string | null
+    quantity: number
+    category: string | null
+    sellPrice: number
+    unitCost: number
+  } | null>(null)
+  const [addQty, setAddQty] = useState(1)
+  const [addingPart, setAddingPart] = useState(false)
   const [partCounts, setPartCounts] = useState<Record<string, number>>(() =>
   const [partCounts, setPartCounts] = useState<Record<string, number>>(() =>
     Object.fromEntries(jobs.map((j) => [j.id, j.partCount]))
     Object.fromEntries(jobs.map((j) => [j.id, j.partCount]))
   )
   )
@@ -69,72 +91,67 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
     setScannerOpen(true)
     setScannerOpen(true)
   }
   }
 
 
+  const handleAddPartToJob = async (qty: number) => {
+    if (!scannedPart || !scanJobId) return
+    setAddingPart(true)
+    const price = scannedPart.sellPrice > 0 ? scannedPart.sellPrice : scannedPart.unitCost
+    const addResult = await addPartToServiceRecord({
+      serviceRecordId: scanJobId,
+      partNumber: scannedPart.partNumber || undefined,
+      name: scannedPart.name,
+      quantity: qty,
+      unitPrice: price,
+      total: price * qty,
+      unitCost: scannedPart.unitCost,
+      inventoryPartId: scannedPart.id,
+    })
+    setAddingPart(false)
+    if (addResult.success) {
+      toast.success(t('partAdded', { name: scannedPart.name }))
+      setPartCounts((prev) => ({ ...prev, [scanJobId]: (prev[scanJobId] || 0) + qty }))
+      setShowAddDialog(false)
+      router.refresh()
+    } else {
+      toast.error(addResult.error || t('partAddFailed'))
+    }
+  }
+
   const handleBarcodeScan = useCallback(
   const handleBarcodeScan = useCallback(
     async (barcode: string) => {
     async (barcode: string) => {
       if (!scanJobId) return
       if (!scanJobId) return
       setScannerOpen(false)
       setScannerOpen(false)
 
 
       const result = await lookupPartByBarcode(barcode)
       const result = await lookupPartByBarcode(barcode)
+      setPendingBarcode(barcode)
       if (result.success && result.data) {
       if (result.success && result.data) {
-        const part = result.data
-        const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
-        const addResult = await addPartToServiceRecord({
-          serviceRecordId: scanJobId,
-          partNumber: part.partNumber || undefined,
-          name: part.name,
-          quantity: 1,
-          unitPrice: price,
-          total: price,
-          unitCost: part.unitCost,
-          inventoryPartId: part.id,
-        })
-        if (addResult.success) {
-          toast.success(t('partAdded', { name: part.name }))
-          setPartCounts((prev) => ({ ...prev, [scanJobId]: (prev[scanJobId] || 0) + 1 }))
-          router.refresh()
-        } else {
-          toast.error(addResult.error || t('partAddFailed'))
-        }
+        setScannedPart(result.data)
+        setAddQty(1)
+        setShowAddDialog(true)
       } else {
       } else {
         // Part not found — offer to create it
         // Part not found — offer to create it
-        setPendingBarcode(barcode)
-        setCreatePartOpen(true)
+        setScannedPart(null)
+        setShowPartForm(true)
       }
       }
     },
     },
-    [scanJobId, t, router]
+    [scanJobId]
   )
   )
 
 
-  const handlePartCreated = useCallback(
-    async (part: {
-      id: string
-      name: string
-      partNumber: string | null
-      sellPrice: number
-      unitCost: number
-    }) => {
-      if (!scanJobId) return
-      setCreatePartOpen(false)
-
-      const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
-      const addResult = await addPartToServiceRecord({
-        serviceRecordId: scanJobId,
-        partNumber: part.partNumber || undefined,
-        name: part.name,
-        quantity: 1,
-        unitPrice: price,
-        total: price,
-        unitCost: part.unitCost,
-        inventoryPartId: part.id,
-      })
-      if (addResult.success) {
-        toast.success(t('partAdded', { name: part.name }))
-        setPartCounts((prev) => ({ ...prev, [scanJobId]: (prev[scanJobId] || 0) + 1 }))
+  const handlePartFormClose = useCallback(
+    async (open: boolean) => {
+      setShowPartForm(open)
+      if (!open && pendingBarcode && scanJobId) {
+        // After creating a part, look it up and add to job
+        const result = await lookupPartByBarcode(pendingBarcode)
+        if (result.success && result.data) {
+          setScannedPart(result.data)
+          setAddQty(1)
+          setShowAddDialog(true)
+        }
+        setPendingBarcode('')
         router.refresh()
         router.refresh()
-      } else {
-        toast.error(addResult.error || t('partAddFailed'))
       }
       }
     },
     },
-    [scanJobId, t, router]
+    [pendingBarcode, scanJobId, router]
   )
   )
 
 
   const handleFileSelect = async (jobId: string, files: FileList | null) => {
   const handleFileSelect = async (jobId: string, files: FileList | null) => {
@@ -251,26 +268,26 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                       </div>
                       </div>
                       <Button
                       <Button
                         variant="outline"
                         variant="outline"
-                        size="icon"
-                        className="h-9 w-9"
+                        size="sm"
+                        className="h-9"
                         disabled={isUploading}
                         disabled={isUploading}
                         onClick={() => handleCameraClick(job.id)}
                         onClick={() => handleCameraClick(job.id)}
-                        title={t('takePhoto')}
                       >
                       >
                         {isUploading ? (
                         {isUploading ? (
-                          <Loader2 className="h-4 w-4 animate-spin" />
+                          <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
                         ) : (
                         ) : (
-                          <Camera className="h-4 w-4" />
+                          <Camera className="mr-1.5 h-4 w-4" />
                         )}
                         )}
+                        {t('addPhoto')}
                       </Button>
                       </Button>
                       <Button
                       <Button
                         variant="outline"
                         variant="outline"
-                        size="icon"
-                        className="h-9 w-9"
+                        size="sm"
+                        className="h-9"
                         onClick={() => handleScanClick(job.id)}
                         onClick={() => handleScanClick(job.id)}
-                        title={t('scanPart')}
                       >
                       >
-                        <ScanBarcode className="h-4 w-4" />
+                        <ScanBarcode className="mr-1.5 h-4 w-4" />
+                        {t('scanPart')}
                       </Button>
                       </Button>
                     </div>
                     </div>
                     {/* Mobile: counters only */}
                     {/* Mobile: counters only */}
@@ -340,11 +357,77 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
         title={t('scanPart')}
         title={t('scanPart')}
       />
       />
 
 
-      <CreatePartDialog
-        open={createPartOpen}
-        onOpenChange={setCreatePartOpen}
-        barcode={pendingBarcode}
-        onCreated={handlePartCreated}
+      {/* Quantity selection drawer when part is found */}
+      <Drawer open={showAddDialog} onOpenChange={setShowAddDialog}>
+        <DrawerContent>
+          <DrawerHeader>
+            <DrawerTitle>{t('scanPart')}</DrawerTitle>
+          </DrawerHeader>
+          {scannedPart && (
+            <div className="px-4">
+              <div className="flex items-center gap-3 rounded-lg border p-3">
+                <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
+                  <Package className="h-5 w-5 text-primary" />
+                </div>
+                <div className="min-w-0 flex-1">
+                  <p className="font-medium truncate">{scannedPart.name}</p>
+                  <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                    {scannedPart.partNumber && <span className="font-mono text-xs">{scannedPart.partNumber}</span>}
+                    {scannedPart.category && <Badge variant="secondary" className="text-[10px]">{scannedPart.category}</Badge>}
+                  </div>
+                </div>
+                <div className="text-right text-sm text-muted-foreground shrink-0">
+                  {t('inStock', { quantity: scannedPart.quantity })}
+                </div>
+              </div>
+
+              <div className="flex items-center justify-center gap-4 py-6">
+                <Button
+                  variant="outline"
+                  size="icon"
+                  className="h-12 w-12 rounded-full text-lg"
+                  onClick={() => setAddQty(Math.max(1, addQty - 1))}
+                  disabled={addingPart || addQty <= 1}
+                >
+                  -
+                </Button>
+                <span className="w-16 text-center text-3xl font-semibold tabular-nums">{addQty}</span>
+                <Button
+                  variant="outline"
+                  size="icon"
+                  className="h-12 w-12 rounded-full text-lg"
+                  onClick={() => setAddQty(addQty + 1)}
+                  disabled={addingPart}
+                >
+                  +
+                </Button>
+              </div>
+            </div>
+          )}
+          <DrawerFooter>
+            <Button
+              size="lg"
+              className="w-full"
+              onClick={() => handleAddPartToJob(addQty)}
+              disabled={addingPart}
+            >
+              {addingPart ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Plus className="mr-2 h-4 w-4" />
+              )}
+              {t('addToJob', { qty: addQty })}
+            </Button>
+          </DrawerFooter>
+        </DrawerContent>
+      </Drawer>
+
+      {/* Full part form for creating new parts */}
+      <InventoryPartForm
+        key={pendingBarcode || 'job-new'}
+        open={showPartForm}
+        onOpenChange={handlePartFormClose}
+        initialBarcode={pendingBarcode}
       />
       />
     </>
     </>
   )
   )