feat: implement manual labeling web UI and APIs in Next.js

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-02 15:39:21 +07:00
1 parent 056a650885
commit 9c7f3368e5
3 files changed
+709

No files matched your search

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
const testFiles = [
"1782870899198-sample_do.jpeg",
"1782872141829-rotated_1782872118246_rotated_1782872112296_rotated_1782872107513_CAP3481952560331460805.jpg",
"1782875271815-rotated_1782875258464_rotated_1782875254091_rotated_1782875250123_CAP1599226410575173446.jpg",
"1782884664859-rotated_1782884661516_rotated_1782884658097_rotated_1782884654697_CAP5956452738616729026.jpg",
"1782888127716-CAP6161747431193129837.jpg",
"1782888211457-rotated_1782888204591_rotated_1782888199962_rotated_1782888195402_CAP7202641176939787142.jpg",
"1782888609885-rotated_1782888593813_1000000454.jpg",
"1782890303600-1000000465.jpg",
"1782892728794-1000000466.jpg",
"1782909791970-sample_do.jpeg",
"IMG_20260630_145445.jpg",
"IMG_20260701_134446.jpg",
"IMG_20260701_134504.jpg",
"IMG_20260701_134533.jpg",
"IMG_20260701_134555.jpg",
"IMG_20260701_134555~2.jpg",
"IMG_20260701_134635.jpg",
"IMG_20260701_134636.jpg",
"IMG_20260701_134646.jpg",
"IMG_20260701_134646~2.jpg",
"IMG_20260701_134704.jpg",
"IMG_20260701_134704~2.jpg",
"IMG_20260701_134810.jpg",
"IMG_20260701_134817.jpg",
"IMG_20260701_135900.jpg",
"IMG_20260701_135910.jpg",
"IMG_20260701_135923.jpg",
"IMG_20260701_135938.jpg",
"IMG_20260701_135950.jpg",
"IMG_20260701_140005.jpg"
];
export async function GET() {
return NextResponse.json({ files: testFiles });
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const UPLOADS_DIR = "/uploads";
function getFilePath(filename: string) {
// Sanitize filename to prevent directory traversal
const safeFilename = path.basename(filename);
return path.join(UPLOADS_DIR, `manual_label_${safeFilename}.json`);
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json({ error: "Filename parameter is required" }, { status: 400 });
}
const filePath = getFilePath(filename);
if (fs.existsSync(filePath)) {
const fileData = fs.readFileSync(filePath, "utf8");
return NextResponse.json(JSON.parse(fileData));
} else {
// Return empty default state if not found
return NextResponse.json({
filename,
noPO: "",
noSO: "",
noDO: "",
tanggal: "",
customer: "",
store: "",
alamat: "",
plat: "",
items: []
});
}
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to load manual label" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
if (!filename) {
return NextResponse.json({ error: "Filename is required in request body" }, { status: 400 });
}
// Ensure uploads directory exists (just in case)
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
const filePath = getFilePath(filename);
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), "utf8");
return NextResponse.json({ success: true, filePath });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to save manual label" }, { status: 500 });
}
}
@@ -0,0 +1,601 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
interface ItemRow {
kodeBarang: string;
namaBarang: string;
banyak: string;
jumlah: string;
}
interface FormData {
filename: string;
noPO: string;
noSO: string;
noDO: string;
tanggal: string;
customer: string;
store: string;
alamat: string;
plat: string;
items: ItemRow[];
}
export default function ManualLabelPage() {
const [files, setFiles] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(-1);
const [formData, setFormData] = useState<FormData>({
filename: "",
noPO: "",
noSO: "",
noDO: "",
tanggal: "",
customer: "",
store: "",
alamat: "",
plat: "",
items: [{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" }]
});
// Transform states
const [scale, setScale] = useState<number>(1.0);
const [rotation, setRotation] = useState<number>(0);
const [isDragging, setIsDragging] = useState<boolean>(false);
const dragStart = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
const viewportRef = useRef<HTMLDivElement>(null);
// Status indicators
const [toast, setToast] = useState<{ message: string; show: boolean; isError: boolean }>({
message: "",
show: false,
isError: false
});
// Fetch file list on mount
useEffect(() => {
const fetchFiles = async () => {
try {
const res = await fetch("/api/manual-images");
if (res.ok) {
const data = await res.json();
const fileList = data.files || [];
setFiles(fileList);
if (fileList.length > 0) {
setCurrentIndex(0);
}
}
} catch (err) {
console.error("Error loading files list:", err);
}
};
fetchFiles();
}, []);
// Fetch existing manual label when currentIndex changes
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
const file = files[currentIndex];
const fetchLabel = async () => {
try {
const res = await fetch(`/api/manual-label?filename=${encodeURIComponent(file)}`);
if (res.ok) {
const data = await res.json();
// Ensure we have at least 3 default rows if empty for easy typing
let itemsList = data.items || [];
if (itemsList.length === 0) {
itemsList = [
{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" },
{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" },
{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" }
];
}
setFormData({
filename: file,
noPO: data.noPO || "",
noSO: data.noSO || "",
noDO: data.noDO || "",
tanggal: data.tanggal || "",
customer: data.customer || "",
store: data.store || "",
alamat: data.alamat || "",
plat: data.plat || "",
items: itemsList
});
}
} catch (err) {
console.error("Error fetching label data:", err);
}
};
// Reset view
setScale(1.0);
setRotation(0);
fetchLabel();
}, [currentIndex, files]);
// Handle Ctrl+S keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveData();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [formData]);
const showToast = (message: string, isError = false) => {
setToast({ message, show: true, isError });
setTimeout(() => {
setToast(prev => ({ ...prev, show: false }));
}, 2500);
};
const handleInputChange = (field: keyof FormData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleItemChange = (index: number, field: keyof ItemRow, value: string) => {
setFormData(prev => {
const updatedItems = [...prev.items];
updatedItems[index] = {
...updatedItems[index],
[field]: value
};
return {
...prev,
items: updatedItems
};
});
};
const addItemRow = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" }]
}));
};
const removeItemRow = (index: number) => {
setFormData(prev => {
const updatedItems = prev.items.filter((_, i) => i !== index);
return {
...prev,
items: updatedItems.length > 0 ? updatedItems : [{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" }]
};
});
};
const saveData = async () => {
if (currentIndex < 0) return false;
// Filter out rows that are entirely empty
const activeItems = formData.items.filter(
item => item.kodeBarang.trim() || item.namaBarang.trim()
);
const payload = {
...formData,
items: activeItems
};
try {
const res = await fetch("/api/manual-label", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (res.ok) {
showToast("Ground truth data saved successfully!");
return true;
} else {
const errText = await res.text();
showToast(`Error: ${errText}`, true);
return false;
}
} catch (err: any) {
showToast(`Save error: ${err.message}`, true);
return false;
}
};
const saveAndNext = async () => {
const success = await saveData();
if (success && currentIndex < files.length - 1) {
setTimeout(() => {
setCurrentIndex(prev => prev + 1);
}, 300);
}
};
// Panning functionality
const handleMouseDown = (e: React.MouseEvent) => {
if (e.target instanceof HTMLImageElement || e.target === viewportRef.current) {
setIsDragging(true);
const vp = viewportRef.current;
if (vp) {
dragStart.current = {
x: e.pageX - vp.offsetLeft,
y: e.pageY - vp.offsetTop,
scrollLeft: vp.scrollLeft,
scrollTop: vp.scrollTop
};
}
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
e.preventDefault();
const vp = viewportRef.current;
if (vp) {
const x = e.pageX - vp.offsetLeft;
const y = e.pageY - vp.offsetTop;
const walkX = (x - dragStart.current.x) * 1.5;
const walkY = (y - dragStart.current.y) * 1.5;
vp.scrollLeft = dragStart.current.scrollLeft - walkX;
vp.scrollTop = dragStart.current.scrollTop - walkY;
}
};
const handleMouseUpOrLeave = () => {
setIsDragging(false);
};
return (
<div className="h-screen w-screen flex flex-col bg-slate-950 text-slate-100 overflow-hidden font-sans">
{/* Top Header */}
<header className="h-16 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 z-10 shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center font-bold text-white shadow-md shadow-blue-500/10 text-sm">
DO
</div>
<span className="text-lg font-semibold tracking-tight text-slate-100">
Manual Annotation Ground Truth
</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setCurrentIndex(prev => Math.max(0, prev - 1))}
disabled={currentIndex <= 0}
className="px-4 py-2 rounded-lg border border-slate-700 bg-slate-800 text-slate-200 text-sm font-medium hover:bg-slate-700 disabled:opacity-40 disabled:hover:bg-slate-800 transition duration-150"
>
← Prev
</button>
<select
value={files[currentIndex] || ""}
onChange={(e) => {
const idx = files.indexOf(e.target.value);
if (idx !== -1) setCurrentIndex(idx);
}}
className="px-3 py-2 rounded-lg border border-slate-700 bg-slate-800 text-slate-200 text-sm font-medium focus:outline-none focus:border-blue-500 max-w-xs"
>
{files.map((file, i) => (
<option key={file} value={file}>
{i + 1}. {file}
</option>
))}
</select>
<span className="text-sm font-medium text-slate-400">
{currentIndex + 1} / {files.length}
</span>
<button
onClick={() => setCurrentIndex(prev => Math.min(files.length - 1, prev + 1))}
disabled={currentIndex >= files.length - 1}
className="px-4 py-2 rounded-lg border border-slate-700 bg-slate-800 text-slate-200 text-sm font-medium hover:bg-slate-700 disabled:opacity-40 disabled:hover:bg-slate-800 transition duration-150"
>
Next →
</button>
<button
onClick={saveData}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium shadow-md shadow-blue-600/10 hover:shadow-blue-500/20 active:scale-[0.98] transition duration-150"
>
Save Data
</button>
<button
onClick={saveAndNext}
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium shadow-md shadow-emerald-600/10 hover:shadow-emerald-500/20 active:scale-[0.98] transition duration-150"
>
Save & Next
</button>
</div>
</header>
{/* Main Split Layout */}
<div className="flex-1 flex overflow-hidden w-full">
{/* Left Form Viewport */}
<div className="w-[45%] border-r border-slate-800 overflow-y-auto p-6 space-y-6 flex flex-col bg-slate-950">
{/* Metadata Card */}
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-5 space-y-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 border-b border-slate-800 pb-2">
Document Metadata
</h3>
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Filename</label>
<input
type="text"
value={formData.filename}
readOnly
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-950/60 text-slate-400 text-sm focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">No. PO (Purchase Order)</label>
<input
type="text"
value={formData.noPO}
onChange={(e) => handleInputChange("noPO", e.target.value)}
placeholder="e.g. PO/26/0000241938"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">No. SO (Sales Order)</label>
<input
type="text"
value={formData.noSO}
onChange={(e) => handleInputChange("noSO", e.target.value)}
placeholder="e.g. 1691972227"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">No. DO (Delivery Order)</label>
<input
type="text"
value={formData.noDO}
onChange={(e) => handleInputChange("noDO", e.target.value)}
placeholder="e.g. 1659990561"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Tanggal DO</label>
<input
type="text"
value={formData.tanggal}
onChange={(e) => handleInputChange("tanggal", e.target.value)}
placeholder="e.g. 30 June 2026"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Customer Name (Vendor)</label>
<input
type="text"
value={formData.customer}
onChange={(e) => handleInputChange("customer", e.target.value)}
placeholder="e.g. PT.PRIMAFOOD INTERNATIONAL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Store (Tujuan Kirim)</label>
<input
type="text"
value={formData.store}
onChange={(e) => handleInputChange("store", e.target.value)}
placeholder="e.g. PX HEAD OFFICE ANCOL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Plat Truk (Plat Nomor)</label>
<input
type="text"
value={formData.plat}
onChange={(e) => handleInputChange("plat", e.target.value)}
placeholder="e.g. B 9256 JXR"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Alamat Pengiriman</label>
<textarea
rows={2}
value={formData.alamat}
onChange={(e) => handleInputChange("alamat", e.target.value)}
placeholder="Complete address text..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 resize-none"
/>
</div>
</div>
</div>
{/* Items SKU Table Card */}
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-5 space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 border-b border-slate-800 pb-2">
Parsed Item SKUs
</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-slate-800 text-slate-400 text-xs uppercase text-left">
<th className="py-2.5 px-2 font-medium w-[25%]">Code (SKU)</th>
<th className="py-2.5 px-2 font-medium w-[45%]">Nama Barang</th>
<th className="py-2.5 px-2 font-medium w-[15%]">Banyak</th>
<th className="py-2.5 px-2 font-medium w-[15%]">Jumlah</th>
<th className="py-2.5 px-2 font-medium w-[50px] text-center">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{formData.items.map((item, idx) => (
<tr key={idx} className="group hover:bg-slate-900/30">
<td className="py-2 px-1">
<input
type="text"
value={item.kodeBarang}
onChange={(e) => handleItemChange(idx, "kodeBarang", e.target.value)}
placeholder="SKU Code"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
</td>
<td className="py-2 px-1">
<input
type="text"
value={item.namaBarang}
onChange={(e) => handleItemChange(idx, "namaBarang", e.target.value)}
placeholder="Description"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
</td>
<td className="py-2 px-1">
<input
type="text"
value={item.banyak}
onChange={(e) => handleItemChange(idx, "banyak", e.target.value)}
placeholder="e.g. 2 KRG"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
</td>
<td className="py-2 px-1">
<input
type="text"
value={item.jumlah}
onChange={(e) => handleItemChange(idx, "jumlah", e.target.value)}
placeholder="e.g. 40 PC"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
</td>
<td className="py-2 px-1 text-center">
<button
onClick={() => removeItemRow(idx)}
className="w-8 h-8 rounded-full flex items-center justify-center text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 active:scale-95 transition"
>
❌
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-between items-center pt-2">
<button
onClick={addItemRow}
className="px-3.5 py-1.5 text-xs font-semibold rounded-lg border border-slate-700 hover:border-slate-600 bg-slate-800 hover:bg-slate-700 text-slate-200 transition"
>
+ Add Item Row
</button>
</div>
</div>
</div>
{/* Right Image Panning Viewport */}
<div className="w-[55%] bg-slate-950 flex flex-col relative select-none">
<div
ref={viewportRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUpOrLeave}
onMouseLeave={handleMouseUpOrLeave}
className="flex-1 overflow-auto flex items-center justify-center cursor-grab active:cursor-grabbing p-6"
>
{files[currentIndex] ? (
<img
src={`/api/history?file=${encodeURIComponent(files[currentIndex])}`}
alt={files[currentIndex]}
style={{
transform: `scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: "center center",
transition: isDragging ? "none" : "transform 0.1s ease-out"
}}
draggable={false}
className="max-w-[90%] max-h-[90%] object-contain rounded shadow-2xl shadow-black/80 border border-slate-900"
/>
) : (
<div className="text-slate-500 text-sm font-medium">Select an image to load</div>
)}
</div>
{/* Interactive controls */}
<div className="absolute bottom-6 right-6 flex items-center gap-2 px-3 py-1.5 bg-slate-900/90 backdrop-blur-md border border-slate-800 rounded-full shadow-lg shadow-black/40">
<button
onClick={() => setScale(s => Math.min(4.0, s + 0.15))}
title="Zoom In"
className="w-8 h-8 rounded-full flex items-center justify-center text-sm hover:bg-slate-800 active:scale-95 text-slate-100"
>
➕
</button>
<button
onClick={() => setScale(s => Math.max(0.2, s - 0.15))}
title="Zoom Out"
className="w-8 h-8 rounded-full flex items-center justify-center text-sm hover:bg-slate-800 active:scale-95 text-slate-100"
>
➖
</button>
<button
onClick={() => {
setScale(1.0);
setRotation(0);
}}
title="Reset View"
className="w-8 h-8 rounded-full flex items-center justify-center text-sm hover:bg-slate-800 active:scale-95 text-slate-100"
>
🔄
</button>
<button
onClick={() => setRotation(r => (r + 90) % 360)}
title="Rotate Clockwise"
className="w-8 h-8 rounded-full flex items-center justify-center text-sm hover:bg-slate-800 active:scale-95 text-slate-100"
>
↪️
</button>
</div>
</div>
</div>
{/* Alert toast notifications */}
<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>
);
}