"use client"; import React, { useState, useEffect } from "react"; import { Sidebar } from "@/components/manual-label-scan/Sidebar"; import { Editor, ScanLabelFormData, AiPredictedData } from "@/components/manual-label-scan/Editor"; import { ImageViewer } from "@/components/manual-label-scan/ImageViewer"; import { getErrorMessage } from "@/utils/client-error"; export default function ManualLabelScanPage() { const [files, setFiles] = useState<{ url: string; filename: string }[]>([]); const [currentIndex, setCurrentIndex] = useState(-1); const [formData, setFormData] = useState({ filename: "", no_sku: "", nama_item: "", expiry_date: "", notes: "" }); const [aiPredicted, setAiPredicted] = useState(null); const [aiSource, setAiSource] = useState<{ type: "batch" | "live"; timestamp: string; method?: string; confidence?: number } | null>(null); const [skuList, setSkuList] = useState>([]); const [isScanning, setIsScanning] = useState(false); const [savingGT, setSavingGT] = useState(false); const [toast, setToast] = useState({ message: "", show: false, isError: false }); const showToast = (message: string, isError = false) => { setToast({ message, show: true, isError }); setTimeout(() => setToast(p => ({ ...p, show: false })), 2500); }; useEffect(() => { const fetchAllData = async () => { try { // Fetch Skus const skuRes = await fetch("/api/skus"); if (skuRes.ok) { const skuData = await skuRes.json(); setSkuList(skuData.skus || []); } // Fetch Test Images — the frozen 79-image Validation Set // (product-test-images-fixed/), the only set the accuracy harness // scores. Gallery/training photos (foto-kemasan-v2/) are not shown // here: they don't need per-photo ground truth, only correct // SKU-folder placement for classifier training. const testRes = await fetch("/api/product-images"); let testFiles: { url: string; filename: string }[] = []; if (testRes.ok) { const testData = await testRes.json(); testFiles = (testData.files || []).map((f: string) => ({ url: `/api/product-images?filename=${encodeURIComponent(f)}`, filename: f })); } setFiles(testFiles); if (testFiles.length > 0) setCurrentIndex(0); } catch (err) { console.error("Error initializing page", err); showToast("Error loading dataset files", true); } }; fetchAllData(); }, []); useEffect(() => { if (currentIndex < 0 || currentIndex >= files.length) return; const file = files[currentIndex]; const loadLabel = async () => { try { const res = await fetch(`/api/manual-label-scan?filename=${encodeURIComponent(file.filename)}`); if (res.ok) { const data = await res.json(); setFormData({ filename: data.filename || file.filename, no_sku: data.no_sku || "", nama_item: data.nama_item || "", expiry_date: data.expiry_date || "", notes: data.notes || "" }); } } catch (err) { console.error("Error fetching label", err); } // Default-load the AI prediction from the last batch accuracy run // (not a live re-scan) so failures are visible immediately while // browsing - "Scan with AI" below can still be used to get a fresh // live result for this exact image. setAiPredicted(null); setAiSource(null); try { const aiRes = await fetch(`/api/product-scan-results?filename=${encodeURIComponent(file.filename)}`); if (aiRes.ok) { const aiData = await aiRes.json(); if (aiData.found) { setAiPredicted({ no_sku: aiData.no_sku, nama_item: aiData.nama_item, expiry_date: aiData.expiry_date }); setAiSource({ type: "batch", timestamp: aiData.timestamp, method: aiData.method, confidence: aiData.confidence }); } } } catch (err) { console.error("Error fetching batch AI result", err); } }; loadLabel(); }, [currentIndex, files]); // Handle Ctrl+S keyboard shortcut useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); handleSave(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [formData]); const handleChange = (field: keyof ScanLabelFormData, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); }; const handleScanWithAi = async () => { if (currentIndex < 0) return; const currentFile = files[currentIndex]; setIsScanning(true); try { // Fetch image as base64 const imgRes = await fetch(currentFile.url); const blob = await imgRes.blob(); const base64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); const scanRes = await fetch("/api/scan-pfm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: base64 }) }); if (!scanRes.ok) throw new Error("Pipeline API error"); const scanData = await scanRes.json(); // Compare against the sku_master-resolved best match (what the app // actually shows/saves as nama_item, and what the accuracy harness // scores), not classification.top1_name - that's the classifier's raw // internal class label (e.g. the foto-kemasan-v2 folder name), which // structurally never matches a sku_master-style ground truth string // even when the classification itself is correct. const bestMatch = (scanData.possibleMatches || []).find((m: { isBestMatch?: boolean }) => m.isBestMatch); setAiPredicted({ no_sku: bestMatch?.no_sku, nama_item: bestMatch?.nama_item, expiry_date: scanData.ocr?.extracted_expired_date }); setAiSource({ type: "live", timestamp: new Date().toISOString(), method: scanData.classification?.method, confidence: scanData.classification?.top1_confidence }); showToast("AI Scan complete!"); } catch (err) { showToast(getErrorMessage(err, undefined, "AI Scan failed"), true); } finally { setIsScanning(false); } }; const handleSave = async () => { setSavingGT(true); try { const res = await fetch("/api/manual-label-scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData) }); if (res.ok) { showToast("Ground truth saved successfully!"); if (currentIndex < files.length - 1) { setCurrentIndex(prev => prev + 1); } else { showToast("All images completed!"); } } else { const errData = await res.json().catch(() => ({})); showToast(getErrorMessage(null, errData, "Save failed"), true); } } catch (err) { showToast(getErrorMessage(err, undefined, "Save failed"), true); } finally { setSavingGT(false); } }; return (
{/* Header */}
SP
Product Scan Annotation
{/* Main Body */}
f.filename)} currentIndex={currentIndex} onSelect={setCurrentIndex} /> = 0 ? files[currentIndex].url : null} />
{/* Toast */}
{toast.isError ? "❌" : "✅"} {toast.message}
); }