From 9c7f3368e5ace166555cc510cf86203accd37b0b Mon Sep 17 00:00:00 2001 From: Rafhan Mazaya Fathurrahman Date: Thu, 2 Jul 2026 15:39:21 +0700 Subject: [PATCH] feat: implement manual labeling web UI and APIs in Next.js --- .../src/app/api/manual-images/route.ts | 38 ++ .../src/app/api/manual-label/route.ts | 70 ++ .../pfm-web-app/src/app/manual-label/page.tsx | 601 ++++++++++++++++++ 3 files changed, 709 insertions(+) create mode 100644 backend/pfm-web-app/src/app/api/manual-images/route.ts create mode 100644 backend/pfm-web-app/src/app/api/manual-label/route.ts create mode 100644 backend/pfm-web-app/src/app/manual-label/page.tsx diff --git a/backend/pfm-web-app/src/app/api/manual-images/route.ts b/backend/pfm-web-app/src/app/api/manual-images/route.ts new file mode 100644 index 0000000..fd74b47 --- /dev/null +++ b/backend/pfm-web-app/src/app/api/manual-images/route.ts @@ -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 }); +} diff --git a/backend/pfm-web-app/src/app/api/manual-label/route.ts b/backend/pfm-web-app/src/app/api/manual-label/route.ts new file mode 100644 index 0000000..1594a59 --- /dev/null +++ b/backend/pfm-web-app/src/app/api/manual-label/route.ts @@ -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 }); + } +} diff --git a/backend/pfm-web-app/src/app/manual-label/page.tsx b/backend/pfm-web-app/src/app/manual-label/page.tsx new file mode 100644 index 0000000..e65657b --- /dev/null +++ b/backend/pfm-web-app/src/app/manual-label/page.tsx @@ -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([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const [formData, setFormData] = useState({ + filename: "", + noPO: "", + noSO: "", + noDO: "", + tanggal: "", + customer: "", + store: "", + alamat: "", + plat: "", + items: [{ kodeBarang: "", namaBarang: "", banyak: "", jumlah: "" }] + }); + + // Transform states + const [scale, setScale] = useState(1.0); + const [rotation, setRotation] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 }); + const viewportRef = useRef(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 ( +
+ + {/* Top Header */} +
+
+
+ DO +
+ + Manual Annotation Ground Truth + +
+ +
+ + + + + + {currentIndex + 1} / {files.length} + + + + + + + +
+
+ + {/* Main Split Layout */} +
+ + {/* Left Form Viewport */} +
+ + {/* Metadata Card */} +
+

+ Document Metadata +

+ +
+
+ + +
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ +