feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-09 12:36:06 +07:00
1 parent 9ff4a4a922
commit 577be04308
69 files changed
+5645 -1657

No files matched your search

@@ -0,0 +1,225 @@
"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 [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 Training Images
const pfmRes = await fetch("/api/produk-pfm");
let trainingFiles: { url: string; filename: string }[] = [];
if (pfmRes.ok) {
const pfmData = await pfmRes.json();
trainingFiles = (pfmData.products || []).flatMap((p: any) =>
p.images.map((url: string) => ({
url,
filename: url.replace(/^\/produk-pfm\/foto-kemasan-v2\//, "")
}))
);
}
// Fetch Test Images
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
}));
}
const combined = [...testFiles, ...trainingFiles];
setFiles(combined);
if (combined.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 || ""
});
setAiPredicted(null); // Reset AI predictions on new file load
}
} catch (err) {
console.error("Error fetching label", 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();
setAiPredicted({
no_sku: scanData.classification?.top1_name ? skuList.find(s => s.nama_item === scanData.classification.top1_name)?.no_sku : undefined,
nama_item: scanData.classification?.top1_name,
expiry_date: scanData.ocr?.extracted_expired_date
});
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}
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>
);
}