refactor: extract useAIExtraction hook from AIOnboarding.tsx

This commit is contained in:
2026-04-19 12:31:21 +03:00
parent cf45437bb0
commit a520b1ba2b
2 changed files with 277 additions and 213 deletions

View File

@@ -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">

View 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
};
}