refactor: extract useAIExtraction hook from AIOnboarding.tsx
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import { useAIExtraction } from '@/hooks/useAIExtraction';
|
||||
|
||||
interface AIOnboardingProps {
|
||||
onCancel: () => void;
|
||||
@@ -13,221 +13,37 @@ interface AIOnboardingProps {
|
||||
}
|
||||
|
||||
export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) {
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedItems, setExtractedItems] = useState<any[]>([]);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const startLiveCamera = async () => {
|
||||
try {
|
||||
setIsLive(true);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||
audio: false
|
||||
});
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
streamRef.current = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Camera access error:", err);
|
||||
toast.error("Could not access camera for live scan.");
|
||||
setIsLive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveCamera = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsLive(false);
|
||||
};
|
||||
|
||||
const captureSnapshot = () => {
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
setImage(dataUrl);
|
||||
stopLiveCamera();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processImage = async () => {
|
||||
if (!image) return;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(image)).blob();
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData, mode);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedData = data;
|
||||
if (typeof data === 'string') {
|
||||
try { parsedData = JSON.parse(data); } catch (e) {}
|
||||
}
|
||||
|
||||
const d = parsedData;
|
||||
|
||||
// HYPER-ROBUST: Find ANY array in the response if it's not a direct array
|
||||
let items: any[] = [];
|
||||
if (Array.isArray(d)) {
|
||||
items = d;
|
||||
} else {
|
||||
const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k]));
|
||||
if (potentialArrayKey) {
|
||||
items = d[potentialArrayKey];
|
||||
} else {
|
||||
// Check for singular object (must have at least name or Item or PN)
|
||||
const target = d.data || d;
|
||||
if (target.name || target.Item || target.PartNr || target.part_number) {
|
||||
items = [target];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
toast.error("No relevant items detected. Try a closer photo.");
|
||||
} else {
|
||||
setExtractedItems(items);
|
||||
if (items.length === 1) {
|
||||
setEditingIndex(0);
|
||||
toast.success("Item identified!");
|
||||
} else {
|
||||
toast.success(`Found ${items.length} items!`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to process image with AI");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSingleItem = (index: number) => {
|
||||
const data = extractedItems[index];
|
||||
const newItem = {
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
onComplete(newItem);
|
||||
|
||||
if (extractedItems.length > 1) {
|
||||
const remaining = [...extractedItems];
|
||||
remaining.splice(index, 1);
|
||||
setExtractedItems(remaining);
|
||||
setEditingIndex(null);
|
||||
} else {
|
||||
setExtractedItems([]);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
};
|
||||
const {
|
||||
image,
|
||||
setImage,
|
||||
uploading,
|
||||
extractedItems,
|
||||
setExtractedItems,
|
||||
editingIndex,
|
||||
setEditingIndex,
|
||||
mode,
|
||||
setMode,
|
||||
isLive,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
existingTypes,
|
||||
existingBoxes,
|
||||
startLiveCamera,
|
||||
stopLiveCamera,
|
||||
captureSnapshot,
|
||||
processImage,
|
||||
confirmSingleItem,
|
||||
confirmAllItems: hookConfirmAllItems,
|
||||
updateEditingItem,
|
||||
handleFileChange
|
||||
} = useAIExtraction(inventory, onComplete);
|
||||
|
||||
const confirmAllItems = async () => {
|
||||
// Clone items and process them sequentially
|
||||
const itemsToProcess = [...extractedItems];
|
||||
setUploading(true);
|
||||
const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < itemsToProcess.length; i++) {
|
||||
const data = itemsToProcess[i];
|
||||
const newItem = {
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
// Wait for parent to process each one
|
||||
await onComplete(newItem);
|
||||
}
|
||||
toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId });
|
||||
setExtractedItems([]);
|
||||
onCancel(); // Close the modal after bulk completion
|
||||
} catch (err) {
|
||||
toast.error("Error during batch add", { id: toastId });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
await hookConfirmAllItems();
|
||||
onCancel(); // Close modal after bulk completion
|
||||
};
|
||||
|
||||
const updateEditingItem = (fields: any) => {
|
||||
if (editingIndex === null) return;
|
||||
const newItems = [...extractedItems];
|
||||
newItems[editingIndex] = { ...newItems[editingIndex], ...fields };
|
||||
setExtractedItems(newItems);
|
||||
};
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
||||
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-testid="ai-extraction-overlay" className="fixed inset-0 z-50 bg-background flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||
|
||||
248
frontend/hooks/useAIExtraction.ts
Normal file
248
frontend/hooks/useAIExtraction.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import { Item } from '@/lib/db';
|
||||
|
||||
export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) => void) {
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedItems, setExtractedItems] = useState<any[]>([]);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const existingTypes = useMemo(
|
||||
() => Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[],
|
||||
[inventory]
|
||||
);
|
||||
|
||||
const existingBoxes = useMemo(
|
||||
() => Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[],
|
||||
[inventory]
|
||||
);
|
||||
|
||||
const startLiveCamera = async () => {
|
||||
try {
|
||||
setIsLive(true);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||
audio: false
|
||||
});
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
streamRef.current = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Camera access error:", err);
|
||||
toast.error("Could not access camera for live scan.");
|
||||
setIsLive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveCamera = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsLive(false);
|
||||
};
|
||||
|
||||
const captureSnapshot = () => {
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
setImage(dataUrl);
|
||||
stopLiveCamera();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processImage = async () => {
|
||||
if (!image) return;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(image)).blob();
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData, mode);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedData = data;
|
||||
if (typeof data === 'string') {
|
||||
try { parsedData = JSON.parse(data); } catch (e) {}
|
||||
}
|
||||
|
||||
const d = parsedData;
|
||||
|
||||
// Find ANY array in the response if it's not a direct array
|
||||
let items: any[] = [];
|
||||
if (Array.isArray(d)) {
|
||||
items = d;
|
||||
} else {
|
||||
const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k]));
|
||||
if (potentialArrayKey) {
|
||||
items = d[potentialArrayKey];
|
||||
} else {
|
||||
// Check for singular object (must have at least name or Item or PN)
|
||||
const target = d.data || d;
|
||||
if (target.name || target.Item || target.PartNr || target.part_number) {
|
||||
items = [target];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
toast.error("No relevant items detected. Try a closer photo.");
|
||||
} else {
|
||||
setExtractedItems(items);
|
||||
if (items.length === 1) {
|
||||
setEditingIndex(0);
|
||||
toast.success("Item identified!");
|
||||
} else {
|
||||
toast.success(`Found ${items.length} items!`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to process image with AI");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSingleItem = (index: number) => {
|
||||
const data = extractedItems[index];
|
||||
const newItem = {
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
onComplete(newItem);
|
||||
|
||||
if (extractedItems.length > 1) {
|
||||
const remaining = [...extractedItems];
|
||||
remaining.splice(index, 1);
|
||||
setExtractedItems(remaining);
|
||||
setEditingIndex(null);
|
||||
} else {
|
||||
setExtractedItems([]);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAllItems = async () => {
|
||||
const itemsToProcess = [...extractedItems];
|
||||
setUploading(true);
|
||||
const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < itemsToProcess.length; i++) {
|
||||
const data = itemsToProcess[i];
|
||||
const newItem = {
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
await onComplete(newItem);
|
||||
}
|
||||
toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId });
|
||||
setExtractedItems([]);
|
||||
} catch (err) {
|
||||
toast.error("Error during batch add", { id: toastId });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditingItem = (fields: any) => {
|
||||
if (editingIndex === null) return;
|
||||
const newItems = [...extractedItems];
|
||||
newItems[editingIndex] = { ...newItems[editingIndex], ...fields };
|
||||
setExtractedItems(newItems);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
image,
|
||||
setImage,
|
||||
uploading,
|
||||
extractedItems,
|
||||
setExtractedItems,
|
||||
editingIndex,
|
||||
setEditingIndex,
|
||||
mode,
|
||||
setMode,
|
||||
isLive,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
existingTypes,
|
||||
existingBoxes,
|
||||
startLiveCamera,
|
||||
stopLiveCamera,
|
||||
captureSnapshot,
|
||||
processImage,
|
||||
confirmSingleItem,
|
||||
confirmAllItems,
|
||||
updateEditingItem,
|
||||
handleFileChange
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user