chore: normalize line endings (CRLF -> LF)
No content changes: git diff --ignore-all-space over these files is empty. The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
@@ -1,253 +1,253 @@
|
||||
"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<ScanLabelFormData>({
|
||||
filename: "",
|
||||
no_sku: "",
|
||||
nama_item: "",
|
||||
expiry_date: "",
|
||||
notes: ""
|
||||
});
|
||||
const [aiPredicted, setAiPredicted] = useState<AiPredictedData | null>(null);
|
||||
const [aiSource, setAiSource] = useState<{ type: "batch" | "live"; timestamp: string; method?: string; confidence?: number } | null>(null);
|
||||
|
||||
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
|
||||
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<string>((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 (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-950 font-sans overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-14 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 shrink-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center font-bold text-white text-xs shadow-md">
|
||||
SP
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-100">Product Scan Annotation</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<a href="/scan-pfm" className="text-slate-400 hover:text-slate-100 transition">← Back to Scanner</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
files={files.map(f => f.filename)}
|
||||
currentIndex={currentIndex}
|
||||
onSelect={setCurrentIndex}
|
||||
/>
|
||||
<ImageViewer src={currentIndex >= 0 ? files[currentIndex].url : null} />
|
||||
<Editor
|
||||
formData={formData}
|
||||
aiPredicted={aiPredicted}
|
||||
aiSource={aiSource}
|
||||
skuList={skuList}
|
||||
isScanning={isScanning}
|
||||
onScanWithAi={handleScanWithAi}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
savingGT={savingGT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-lg flex items-center gap-2.5 shadow-2xl font-medium z-[100] transition duration-300 ${toast.show ? "translate-y-0 opacity-100 scale-100" : "translate-y-12 opacity-0 scale-95 pointer-events-none"} ${toast.isError ? "bg-rose-600 text-white" : "bg-emerald-600 text-white"}`}>
|
||||
<span>{toast.isError ? "❌" : "✅"}</span>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"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<ScanLabelFormData>({
|
||||
filename: "",
|
||||
no_sku: "",
|
||||
nama_item: "",
|
||||
expiry_date: "",
|
||||
notes: ""
|
||||
});
|
||||
const [aiPredicted, setAiPredicted] = useState<AiPredictedData | null>(null);
|
||||
const [aiSource, setAiSource] = useState<{ type: "batch" | "live"; timestamp: string; method?: string; confidence?: number } | null>(null);
|
||||
|
||||
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
|
||||
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<string>((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 (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-950 font-sans overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-14 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 shrink-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center font-bold text-white text-xs shadow-md">
|
||||
SP
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-100">Product Scan Annotation</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<a href="/scan-pfm" className="text-slate-400 hover:text-slate-100 transition">← Back to Scanner</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
files={files.map(f => f.filename)}
|
||||
currentIndex={currentIndex}
|
||||
onSelect={setCurrentIndex}
|
||||
/>
|
||||
<ImageViewer src={currentIndex >= 0 ? files[currentIndex].url : null} />
|
||||
<Editor
|
||||
formData={formData}
|
||||
aiPredicted={aiPredicted}
|
||||
aiSource={aiSource}
|
||||
skuList={skuList}
|
||||
isScanning={isScanning}
|
||||
onScanWithAi={handleScanWithAi}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
savingGT={savingGT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-lg flex items-center gap-2.5 shadow-2xl font-medium z-[100] transition duration-300 ${toast.show ? "translate-y-0 opacity-100 scale-100" : "translate-y-12 opacity-0 scale-95 pointer-events-none"} ${toast.isError ? "bg-rose-600 text-white" : "bg-emerald-600 text-white"}`}>
|
||||
<span>{toast.isError ? "❌" : "✅"}</span>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user