feat: implement manual labeling web UI and APIs in Next.js
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user