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,419 @@
"use client";
import React, { useState, useEffect } from "react";
export default function MasterDataPage() {
const [token, setToken] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loginError, setLoginError] = useState("");
const [activeTab, setActiveTab] = useState<"stores" | "skus">("stores");
const [stores, setStores] = useState<any[]>([]);
const [skus, setSkus] = useState<any[]>([]);
useEffect(() => {
const savedToken = localStorage.getItem("adminToken");
if (savedToken) {
setToken(savedToken);
fetchData(savedToken, activeTab);
}
}, [activeTab]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError("");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Login failed");
const tokenStr = data.data?.token || data.token;
localStorage.setItem("adminToken", tokenStr);
setToken(tokenStr);
fetchData(tokenStr, activeTab);
} catch (err: any) {
setLoginError(err.message);
}
};
const handleLogout = () => {
localStorage.removeItem("adminToken");
setToken(null);
};
const fetchData = async (authToken: string, tab: "stores" | "skus") => {
try {
const res = await fetch(`/api/v1/master/${tab}`, {
headers: { "Authorization": `Bearer ${authToken}` }
});
if (res.status === 401 || res.status === 403) {
handleLogout();
return;
}
const data = await res.json();
if (res.ok) {
if (tab === "stores") setStores(data.data || []);
else setSkus(data.data || []);
}
} catch (err) {
console.error(err);
}
};
if (!token) {
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 flex items-center justify-center p-4">
<div className="bg-slate-900/40 border border-slate-800/80 shadow-2xl backdrop-blur-md rounded-2xl p-8 w-full max-w-md">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent text-center mb-6">
Admin Login
</h1>
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Username</label>
<input
type="text"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-600 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Enter admin username"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Password</label>
<input
type="password"
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-650 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{loginError && (
<div className="bg-rose-950/30 border border-rose-800/40 p-3 rounded-xl text-xs text-rose-450 flex items-center gap-2">
<span>⚠️</span>
<span>{loginError}</span>
</div>
)}
<button
type="submit"
className="w-full bg-teal-600 hover:bg-teal-500 text-slate-950 font-bold p-3 rounded-xl transition-all duration-200 shadow-lg shadow-teal-900/20 text-sm cursor-pointer"
>
Log In
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 p-8 text-slate-100">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8 border-b border-slate-800/60 pb-4">
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent flex items-center gap-2">
<span>⚙️</span> Master Data Management
</h1>
<button
onClick={handleLogout}
className="text-slate-400 hover:text-slate-100 bg-slate-900/60 hover:bg-slate-900 border border-slate-850 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 cursor-pointer"
>
Logout
</button>
</div>
<div className="flex space-x-2 mb-6 border-b border-slate-800/60 pb-px">
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'stores'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('stores')}
>
Stores
</button>
<button
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
activeTab === 'skus'
? 'border-teal-500 text-teal-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
onClick={() => setActiveTab('skus')}
>
SKUs
</button>
</div>
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl backdrop-blur-md">
{activeTab === 'stores' && <StoreManager stores={stores} token={token} onRefresh={() => fetchData(token, 'stores')} />}
{activeTab === 'skus' && <SkuManager skus={skus} token={token} onRefresh={() => fetchData(token, 'skus')} />}
</div>
</div>
</div>
);
}
function StoreManager({ stores, token, onRefresh }: { stores: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ kode_toko: "", nama_toko: "", alamat: "" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/v1/master/stores", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(form)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ kode_toko: "", nama_toko: "", alamat: "" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete store ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/stores/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>🏪</span> Store Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add Store
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4">
Add New Store <span className="text-[10px] text-teal-500 font-normal lowercase">(Will auto-generate account with "123" password)</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<input
placeholder="Kode Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.kode_toko}
onChange={e => setForm({...form, kode_toko: e.target.value})}
required
/>
<input
placeholder="Nama Toko"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_toko}
onChange={e => setForm({...form, nama_toko: e.target.value})}
required
/>
<input
placeholder="Alamat"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.alamat}
onChange={e => setForm({...form, alamat: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Kode Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Toko</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Alamat</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{stores.map(s => (
<tr key={s.kode_toko} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.kode_toko}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_toko}</td>
<td className="p-3.5 text-slate-400 truncate max-w-xs">{s.alamat}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.kode_toko)}
className="text-rose-400 hover:text-rose-355 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{stores.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-500">No stores found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SkuManager({ skus, token, onRefresh }: { skus: any[], token: string, onRefresh: () => void }) {
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const payload = { ...form, standar_jumlah: parseInt(form.standar_jumlah) || 1 };
const res = await fetch("/api/v1/master/skus", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.message);
setIsAdding(false);
setForm({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
onRefresh();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (kode: string) => {
if (!confirm(`Delete SKU ${kode}?`)) return;
try {
const res = await fetch(`/api/v1/master/skus/${kode}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
onRefresh();
} catch (err: any) {
alert(err.message);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<span>📦</span> SKU Master
</h2>
<button
onClick={() => setIsAdding(true)}
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
>
+ Add SKU
</button>
</div>
{isAdding && (
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4 font-mono">Add New SKU</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<input
placeholder="No SKU"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.no_sku}
onChange={e => setForm({...form, no_sku: e.target.value})}
required
/>
<input
placeholder="Nama Item"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.nama_item}
onChange={e => setForm({...form, nama_item: e.target.value})}
required
/>
<input
placeholder="Jenis Outer (e.g. DUS)"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.jenis_outer}
onChange={e => setForm({...form, jenis_outer: e.target.value})}
/>
<input
type="number"
placeholder="Std Qty"
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
value={form.standar_jumlah}
onChange={e => setForm({...form, standar_jumlah: e.target.value})}
/>
</div>
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
<div className="flex space-x-2">
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
</div>
</form>
)}
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
<tr>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">No SKU</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Item</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Outer</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Std Qty</th>
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
</tr>
</thead>
<tbody>
{skus.map(s => (
<tr key={s.no_sku} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.no_sku}</td>
<td className="p-3.5 text-slate-300 font-medium">{s.nama_item}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.jenis_outer}</td>
<td className="p-3.5 text-slate-400 font-mono">{s.standar_jumlah}</td>
<td className="p-3.5">
<button
onClick={() => handleDelete(s.no_sku)}
className="text-rose-400 hover:text-rose-350 transition-colors font-bold cursor-pointer font-mono"
>
Delete
</button>
</td>
</tr>
))}
{skus.length === 0 && (
<tr><td colSpan={5} className="p-6 text-center text-slate-500">No SKUs found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
// Separate from DO manual_labels.json - product scan ground truth only
@@ -54,12 +56,25 @@ function normalizeDateString(dateStr: string): string {
return trimmed;
}
let purged = false;
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
if (!raw.trim()) return [];
let labels: ProductScanLabel[] = JSON.parse(raw);
// Cleanup phantom uploaded-* entries once
if (!purged) {
const valid = labels.filter((l) => !l.filename.startsWith("uploaded-"));
if (valid.length !== labels.length) {
writeLabels(valid);
labels = valid;
}
purged = true;
}
return labels;
}
function writeLabels(labels: ProductScanLabel[]) {
@@ -76,23 +91,69 @@ export async function GET(req: NextRequest) {
const filename = searchParams.get("filename");
if (!filename) {
return errorResponse(400, "Filename parameter is required");
const labels = readLabels();
return NextResponse.json(labels);
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
if (existing) {
return NextResponse.json(existing);
// Inferred values from filename/directory structure
let inferredSku = "";
let inferredNamaItem = "";
const parts = safeFilename.split("/");
if (parts.length > 1) {
const folderName = parts[0];
const match = folderName.match(/^(\d{8})/);
if (match) {
inferredSku = match[1];
} else if (/^\d{8}$/.test(folderName)) {
inferredSku = folderName;
}
}
// Return empty default state if not found
if (inferredSku) {
try {
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
if (dbRes.rowCount > 0) {
inferredNamaItem = dbRes.rows[0].nama_item;
}
} catch (dbErr) {
console.error("Failed to query sku_master for manual label:", dbErr);
}
}
// Inferred expiry date from sibling files in the same parent directory
let siblingExpiry = "";
let parentFolder = "";
if (parts.length > 1) {
parentFolder = parts.slice(0, -1).join("/");
}
if (parentFolder) {
const sibling = labels.find(
(l) => l.filename.startsWith(parentFolder + "/") && l.expiry_date
);
if (sibling) {
siblingExpiry = sibling.expiry_date;
}
}
if (existing) {
return NextResponse.json({
...existing,
no_sku: existing.no_sku || inferredSku,
nama_item: existing.nama_item || inferredNamaItem,
expiry_date: existing.expiry_date || siblingExpiry
});
}
// Return empty default state if not found, with inferred metadata
return NextResponse.json({
filename: safeFilename,
no_sku: "",
nama_item: "",
expiry_date: "",
no_sku: inferredSku,
nama_item: inferredNamaItem,
expiry_date: siblingExpiry,
top1_confidence: null,
notes: "",
saved_at: ""
@@ -107,13 +168,28 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
const { filename, image } = body;
if (!filename) {
return errorResponse(400, "Filename is required in request body");
let safeFilename = sanitizeFilename(filename || "unknown.jpg");
if (image && image.startsWith("data:image/")) {
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const hash = crypto.createHash("md5").update(buffer).digest("hex");
const ext = image.match(/^data:image\/(\w+);base64,/)?.[1] || "jpg";
safeFilename = `${hash}.${ext}`;
const saveDir = path.join(process.cwd(), "..", "sources", "product-test-images");
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}
fs.writeFileSync(path.join(saveDir, safeFilename), buffer);
}
if (!safeFilename || safeFilename === "unknown.jpg") {
return errorResponse(400, "Filename or valid image is required in request body");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const index = labels.findIndex((l) => l.filename === safeFilename);
@@ -142,3 +218,21 @@ export async function POST(req: NextRequest) {
return errorResponse(500, message);
}
}
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) return errorResponse(400, "Filename parameter is required");
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const filtered = labels.filter((l) => l.filename !== safeFilename);
writeLabels(filtered);
return NextResponse.json({ success: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to delete label";
return errorResponse(500, message);
}
}
@@ -433,8 +433,8 @@ export async function POST(req: NextRequest) {
};
const insertDocRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (filename) DO UPDATE
SET upload_time = EXCLUDED.upload_time,
size = EXCLUDED.size,
@@ -443,7 +443,8 @@ export async function POST(req: NextRequest) {
layout_parsing_result = EXCLUDED.layout_parsing_result,
is_sample = EXCLUDED.is_sample,
file_hash = EXCLUDED.file_hash,
processing_logs = EXCLUDED.processing_logs
processing_logs = EXCLUDED.processing_logs,
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko)
RETURNING id
`, [
safeFile,
@@ -454,7 +455,8 @@ export async function POST(req: NextRequest) {
JSON.stringify(pipelineResult),
isSample,
fileHash,
JSON.stringify(logsPayload)
JSON.stringify(logsPayload),
kodeToko || null
]);
const docId = insertDocRes.rows[0].id;
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("filename");
const dirPath = path.join(process.cwd(), "..", "sources", "product-test-images");
// File serving mode
if (filename) {
const safeFile = path.basename(filename);
const filePath = path.join(dirPath, safeFile);
if (!fs.existsSync(filePath)) {
return errorResponse(404, "File not found");
}
const ext = path.extname(safeFile).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".png") contentType = "image/png";
else if (ext === ".webp") contentType = "image/webp";
const fileBuffer = fs.readFileSync(filePath);
return new Response(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
// List mode
if (!fs.existsSync(dirPath)) {
return NextResponse.json({ files: [] });
}
const files = fs.readdirSync(dirPath).filter((file) => {
const ext = path.extname(file).toLowerCase();
return [".jpg", ".jpeg", ".png", ".webp"].includes(ext);
});
files.sort();
return NextResponse.json({ files });
} catch (error: unknown) {
console.error("Error in product-images API:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -27,22 +27,42 @@ export async function POST(req: NextRequest) {
// carries that assignment so store name/address never need OCR
// detection later; whichever account uploads, its own store is used.
const accountRes = await query(
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
`SELECT a.id, a.username, a.password, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.username = $1`,
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko
kodeToko: account.kode_toko,
role: account.role
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: { token }
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { headers: corsHeaders });
}
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
const tokenPayload = getAccountFromAuthHeader(authHeader);
if (!tokenPayload) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const accountRes = await query(
`SELECT a.id, a.username, a.role, a.is_active,
s.kode_toko, s.nama_toko, s.alamat
FROM accounts a
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
WHERE a.id = $1`,
[tokenPayload.accountId]
);
if (accountRes.rowCount && accountRes.rowCount > 0) {
const account = accountRes.rows[0];
if (!account.is_active) {
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
}
// We extract the token exactly as passed in to echo it back in the same shape as login
const token = authHeader?.slice("Bearer ".length).trim();
return NextResponse.json({
status: "success",
message: "Profile retrieved successfully",
data: {
token,
profile: {
username: account.username,
role: account.role,
is_active: account.is_active,
kodeToko: account.kode_toko,
namaToko: account.nama_toko,
alamat: account.alamat
}
}
}, { headers: corsHeaders });
}
return errorResponse(401, "Account not found", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in auth/me API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -32,13 +32,17 @@ export async function PUT(
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
const checkRes = await query("SELECT id, filename, upload_time, kode_toko FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
return errorResponse(403, "Forbidden: You do not have permission to modify this document", { headers: corsHeaders });
}
const body = await req.json();
const {
tanggal,
@@ -21,12 +21,21 @@ export async function GET(req: NextRequest) {
}
// Retrieve all custom-uploaded documents
const docRes = await query(`
let docsQuery = `
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
FROM documents
WHERE is_sample = false AND parsed = true
ORDER BY upload_time DESC
`);
`;
const queryParams: any[] = [];
if (account.role !== 'admin') {
docsQuery += ` AND kode_toko = $1`;
queryParams.push(account.kodeToko);
}
docsQuery += ` ORDER BY upload_time DESC`;
const docRes = await query(docsQuery, queryParams);
const documents = docRes.rows;
const mappedList = [];
@@ -58,10 +58,13 @@ export async function POST(req: NextRequest) {
const latitude = latVal ? parseFloat(latVal.toString()) : null;
const longitude = lngVal ? parseFloat(lngVal.toString()) : null;
const existing = await query(
"SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 ORDER BY upload_time ASC LIMIT 1",
[fileHash]
);
// Basic dedup
const dedupQuery = account?.kodeToko
? "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
: "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
const existing = await query(dedupQuery, dedupParams);
if (existing.rows.length > 0) {
const existingDoc = existing.rows[0];
@@ -98,8 +101,8 @@ export async function POST(req: NextRequest) {
let finalFilename = filename;
const insertRes = await query(`
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, [
filename,
@@ -109,7 +112,8 @@ export async function POST(req: NextRequest) {
false,
fileHash,
latitude,
longitude
longitude,
account?.kodeToko || null
]);
docId = insertRes.rows[0].id;
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { query } from "../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
let dbHealthy = false;
let pipelineHealthy = false;
// Check Database
try {
const res = await query("SELECT 1 as healthy");
if (res.rowCount && res.rows[0].healthy === 1) {
dbHealthy = true;
}
} catch (err) {
console.error("Health check - DB ping failed:", err);
}
// Check Pipeline API
try {
const pipelineUrl = process.env.PIPELINE_URL;
// e.g. http://paddleocr-pipeline-api:8090/layout-parsing
if (pipelineUrl) {
const healthUrl = new URL("/", pipelineUrl).toString();
const response = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(3000) });
// As long as the server responds (even with 404 or 405), it is running.
if (response.status) {
pipelineHealthy = true;
}
} else {
console.warn("Health check - PIPELINE_URL not configured in environment");
}
} catch (err) {
console.error("Health check - Pipeline ping failed:", err);
}
const isHealthy = dbHealthy && pipelineHealthy;
return NextResponse.json({
status: isHealthy ? "ok" : "error",
db: dbHealthy,
pipeline: pipelineHealthy,
}, {
status: isHealthy ? 200 : 503,
headers: corsHeaders
});
}
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const {
nama_item,
jenis_outer,
standar_jumlah
} = body;
const res = await query(
`UPDATE sku_master
SET nama_item = $1, jenis_outer = $2, standar_jumlah = $3
WHERE no_sku = $4 RETURNING *`,
[
nama_item,
jenis_outer || '',
String(standar_jumlah || '1'),
kode
]
);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const res = await query("DELETE FROM sku_master WHERE no_sku = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
return errorResponse(404, "SKU not found");
}
return NextResponse.json({ status: "success", message: "SKU deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation
return errorResponse(409, "Cannot delete SKU because it is referenced in documents");
}
return errorResponse(500, err.message);
}
}
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function GET(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const res = await query(`
SELECT no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
FROM sku_master
ORDER BY no_sku ASC
`);
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const {
kode_item,
no_sku,
nama_item,
jenis_outer,
standar_jumlah
} = body;
const skuCode = no_sku || kode_item;
if (!skuCode || !nama_item) {
return errorResponse(400, "no_sku and nama_item are required");
}
await query(
`INSERT INTO sku_master
(no_sku, nama_item, jenis_outer, standar_jumlah)
VALUES ($1, $2, $3, $4)`,
[
skuCode,
nama_item,
jenis_outer || '',
String(standar_jumlah || '1')
]
);
return NextResponse.json({ status: "success", message: "SKU created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "SKU with this kode_item already exists");
}
return errorResponse(500, err.message);
}
}
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
const body = await req.json();
const { nama_toko, alamat } = body;
const res = await query(
"UPDATE store_master SET nama_toko = $1, alamat = $2 WHERE kode_toko = $3 RETURNING *",
[nama_toko, alamat || '', kode]
);
if (res.rowCount === 0) {
return errorResponse(404, "Store not found");
}
return NextResponse.json({ status: "success", data: res.rows[0] });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ kode: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const { kode } = await context.params;
await withTransaction(async (client) => {
// Delete associated account first due to FK account -> store_master
await client.query("DELETE FROM accounts WHERE kode_toko = $1", [kode]);
const res = await client.query("DELETE FROM store_master WHERE kode_toko = $1 RETURNING *", [kode]);
if (res.rowCount === 0) {
throw new Error("Store not found");
}
});
return NextResponse.json({ status: "success", message: "Store and associated account deleted successfully" });
} catch (err: any) {
if (err.code === '23503') { // foreign key violation (e.g. documents exist)
return errorResponse(409, "Cannot delete store because it has associated documents");
}
if (err.message === "Store not found") {
return errorResponse(404, err.message);
}
return errorResponse(500, err.message);
}
}
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { query, withTransaction } from "@/db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
import bcrypt from "bcryptjs";
export async function GET(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization");
console.log("Auth Header in GET:", authHeader);
const account = getAccountFromAuthHeader(authHeader);
console.log("Decoded Account:", account);
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const res = await query("SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY kode_toko ASC");
return NextResponse.json({ status: "success", data: res.rows });
} catch (err: any) {
return errorResponse(500, err.message);
}
}
export async function POST(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account || account.role !== 'admin') {
return errorResponse(403, "Forbidden: Admin access required");
}
const body = await req.json();
const { kode_toko, nama_toko, alamat } = body;
if (!kode_toko || !nama_toko) {
return errorResponse(400, "kode_toko and nama_toko are required");
}
await withTransaction(async (client) => {
// 1. Insert store
await client.query(
"INSERT INTO store_master (kode_toko, nama_toko, alamat) VALUES ($1, $2, $3)",
[kode_toko, nama_toko, alamat || '']
);
// 2. Hash default password
const hashedPassword = await bcrypt.hash('123', 10);
// 3. Create default account
await client.query(
`INSERT INTO accounts (username, password, role, is_active, kode_toko)
VALUES ($1, $2, 'store', true, $3)`,
[kode_toko, hashedPassword, kode_toko]
);
});
return NextResponse.json({ status: "success", message: "Store and account created successfully" });
} catch (err: any) {
if (err.code === '23505') { // unique violation
return errorResponse(409, "Store with this kode_toko already exists");
}
return errorResponse(500, err.message);
}
}
@@ -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>
);
}
+14 -1
View File
@@ -123,6 +123,7 @@ export default function ScanPfmPage() {
const [editedProductName, setEditedProductName] = useState<string>("");
const [isEditingSku, setIsEditingSku] = useState<boolean>(false);
const [editedSku, setEditedSku] = useState<string>("");
const [gtNotes, setGtNotes] = useState<string>("");
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
@@ -346,7 +347,8 @@ export default function ScanPfmPage() {
nama_item: scanResult.classification?.top1_name || "",
expiry_date: normalizeDateString(scanResult.ocr?.extracted_expired_date || ""),
top1_confidence: scanResult.classification?.top1_confidence ?? null,
notes: ""
notes: gtNotes,
image: selectedImage?.startsWith("data:image/") ? selectedImage : undefined
};
const res = await fetch("/api/manual-label-scan", {
@@ -984,6 +986,17 @@ export default function ScanPfmPage() {
</span>
</div>
</div>
<div className="mb-4">
<span className="text-[9px] text-slate-500 block mb-1 uppercase font-semibold">Notes (Optional)</span>
<input
type="text"
value={gtNotes}
onChange={(e) => setGtNotes(e.target.value)}
placeholder="e.g. blurry, wrong classification, etc."
className="bg-slate-900 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 w-full transition-all"
/>
</div>
<button
id="btn-save-ground-truth"
onClick={handleSaveGroundTruth}
@@ -0,0 +1,147 @@
import React from "react";
export interface ScanLabelFormData {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
notes: string;
}
export interface AiPredictedData {
no_sku?: string;
nama_item?: string;
expiry_date?: string;
}
interface EditorProps {
formData: ScanLabelFormData;
aiPredicted: AiPredictedData | null;
skuList: Array<{ no_sku: string; nama_item: string }>;
isScanning: boolean;
onScanWithAi: () => void;
onChange: (field: keyof ScanLabelFormData, value: string) => void;
onSave: () => void;
savingGT: boolean;
}
export function Editor({
formData,
aiPredicted,
skuList,
isScanning,
onScanWithAi,
onChange,
onSave,
savingGT
}: EditorProps) {
// Autofill item name based on SKU if available
const handleSkuChange = (value: string) => {
onChange("no_sku", value);
const matched = skuList.find(s => s.no_sku === value);
if (matched && !formData.nama_item) {
onChange("nama_item", matched.nama_item);
}
};
const AiNote = ({ current, aiValue }: { current: string; aiValue: string | undefined }) => {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<div className={`text-[10px] leading-tight mt-1 ${differs ? "text-amber-500" : "text-slate-500"}`}>
AI: {aiValue || "(not detected)"}
</div>
);
};
return (
<div className="w-[400px] border-l border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0 overflow-y-auto">
<div className="p-5 space-y-6">
{/* Header & AI Action */}
<div className="flex flex-col gap-3 pb-4 border-b border-slate-800">
<div>
<h3 className="text-sm font-bold text-slate-100">Ground Truth Editor</h3>
<p className="text-[11px] text-slate-500 truncate mt-0.5">{formData.filename || "No file selected"}</p>
</div>
<button
onClick={onScanWithAi}
disabled={isScanning || !formData.filename}
className="w-full bg-teal-600/20 text-teal-400 hover:bg-teal-600/30 disabled:opacity-50 border border-teal-500/30 rounded-lg py-2 text-xs font-semibold transition flex items-center justify-center gap-2"
>
{isScanning ? "Scanning with Pipeline..." : "Scan with AI 🤖"}
</button>
</div>
{/* Form Fields */}
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">SKU</label>
<input
type="text"
list="skuOptions"
value={formData.no_sku}
onChange={(e) => handleSkuChange(e.target.value)}
placeholder="e.g. 12010119"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<datalist id="skuOptions">
{skuList.map((s) => (
<option key={s.no_sku} value={s.no_sku}>
{s.nama_item}
</option>
))}
</datalist>
<AiNote current={formData.no_sku} aiValue={aiPredicted?.no_sku} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Product Name</label>
<input
type="text"
value={formData.nama_item}
onChange={(e) => onChange("nama_item", e.target.value)}
placeholder="e.g. FIESTA NUGGET 400 GR"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.nama_item} aiValue={aiPredicted?.nama_item} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Expiry Date</label>
<input
type="text"
value={formData.expiry_date}
onChange={(e) => onChange("expiry_date", e.target.value)}
placeholder="e.g. 05/11/2026"
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
<AiNote current={formData.expiry_date} aiValue={aiPredicted?.expiry_date} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => onChange("notes", e.target.value)}
placeholder="Optional notes..."
rows={3}
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors resize-none"
/>
</div>
</div>
</div>
<div className="mt-auto p-5 border-t border-slate-800 bg-slate-900">
<button
onClick={onSave}
disabled={savingGT || !formData.filename}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl py-3 text-sm font-bold shadow-lg shadow-emerald-500/20 disabled:opacity-50 transition-all flex items-center justify-center"
>
{savingGT ? "Saving..." : "Save Ground Truth"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import React, { useState } from "react";
interface ImageViewerProps {
src: string | null;
}
export function ImageViewer({ src }: ImageViewerProps) {
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
if (!src) {
return (
<div className="flex-1 flex items-center justify-center bg-slate-950">
<span className="text-slate-600 text-sm">No image selected</span>
</div>
);
}
return (
<div className="flex-1 relative flex flex-col bg-slate-950 overflow-hidden">
{/* Controls */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 bg-slate-900/80 backdrop-blur border border-slate-700 p-1.5 rounded-xl shadow-xl">
<button
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
-
</button>
<span className="text-xs font-medium text-slate-400 w-12 text-center">
{Math.round(scale * 100)}%
</span>
<button
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
>
+
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => setRotation((r) => r - 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Left"
>
↺
</button>
<button
onClick={() => setRotation((r) => r + 90)}
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
title="Rotate Right"
>
↻
</button>
<div className="w-px h-5 bg-slate-700 mx-1" />
<button
onClick={() => { setScale(1); setRotation(0); }}
className="px-3 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-xs font-medium"
>
Reset
</button>
</div>
{/* Viewport */}
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{/* Using standard img for easy rotation/scaling without Next.js Image component strictness */}
<img
src={src}
alt="Product Scan"
style={{
transform: `scale(${scale}) rotate(${rotation}deg)`,
transition: "transform 0.2s ease-out",
maxHeight: "80vh"
}}
className="shadow-2xl rounded-sm object-contain"
crossOrigin="anonymous"
/>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import React from "react";
interface SidebarProps {
files: string[];
currentIndex: number;
onSelect: (index: number) => void;
}
export function Sidebar({ files, currentIndex, onSelect }: SidebarProps) {
return (
<div className="w-64 border-r border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-slate-800">
<h2 className="text-sm font-bold text-slate-100">Dataset Images</h2>
<p className="text-xs text-slate-500 mt-1">{files.length} files found</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{files.map((file, idx) => {
const isSelected = idx === currentIndex;
// Extract just the filename for display
const display = file.split("/").pop() || file;
return (
<button
key={file}
onClick={() => onSelect(idx)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs truncate transition-colors ${
isSelected
? "bg-emerald-500/20 text-emerald-400 font-medium"
: "text-slate-400 hover:bg-slate-800/50 hover:text-slate-200"
}`}
title={file}
>
{idx + 1}. {display}
</button>
);
})}
{files.length === 0 && (
<div className="text-center text-xs text-slate-500 mt-4">
No images found.
</div>
)}
</div>
</div>
);
}
+62 -4
View File
@@ -21,7 +21,8 @@ export async function initDb(pool: Pool) {
layout_parsing_result JSONB,
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
file_hash VARCHAR(64),
processing_logs JSONB
processing_logs JSONB,
kode_toko VARCHAR(255)
);
`);
@@ -29,6 +30,8 @@ export async function initDb(pool: Pool) {
try {
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS kode_toko VARCHAR(255);");
await pool.query("CREATE INDEX IF NOT EXISTS idx_documents_file_hash ON documents(file_hash);");
} catch (alterErr) {
console.error("Failed to alter documents table for schema upgrade:", alterErr);
}
@@ -104,6 +107,42 @@ export async function initDb(pool: Pool) {
);
`);
// Seed store_master from toko_aktif.json
const sourcesDir = fs.existsSync("/sources/toko_aktif.json")
? "/sources"
: path.join(process.cwd(), "..", "sources");
const tokoPath = path.join(sourcesDir, "toko_aktif.json");
if (fs.existsSync(tokoPath)) {
console.log("Loading store_master from toko_aktif.json...");
try {
const tokoData = JSON.parse(fs.readFileSync(tokoPath, "utf-8"));
for (const toko of tokoData) {
if (toko.kodeToko && toko.namaToko && toko.alamat) {
await pool.query(`
INSERT INTO store_master (kode_toko, nama_toko, alamat)
VALUES ($1, $2, $3)
ON CONFLICT (kode_toko) DO UPDATE SET
nama_toko = EXCLUDED.nama_toko,
alamat = EXCLUDED.alamat;
`, [toko.kodeToko, toko.namaToko, toko.alamat]);
}
}
console.log(`Loaded ${tokoData.length} stores into store_master.`);
} catch (err) {
console.error("Failed to parse or load toko_aktif.json:", err);
}
} else {
console.warn(`toko_aktif.json not found at ${tokoPath}`);
}
// Ensure WH_JOFFICE exists for admin account foreign key
await pool.query(`
INSERT INTO store_master (kode_toko, nama_toko, alamat)
VALUES ('WH_JOFFICE', 'Head Office', 'Jakarta')
ON CONFLICT (kode_toko) DO NOTHING;
`);
// Create accounts table - each account is assigned one store (kode_toko),
// so store name/address never need to be OCR-detected: whichever account
// uploads a document, that account's assigned store is used directly.
@@ -113,16 +152,35 @@ export async function initDb(pool: Pool) {
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
role VARCHAR(50) NOT NULL DEFAULT 'store',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
try {
await pool.query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'store';");
await pool.query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;");
} catch (alterErr) {
console.error("Failed to alter accounts table for schema upgrade:", alterErr);
}
// Seed store accounts from store_master
console.log("Seeding store accounts from store_master...");
const defaultPasswordHash = bcrypt.hashSync("password", 10);
await pool.query(`
INSERT INTO accounts (username, password, kode_toko, role)
SELECT kode_toko, $1, kode_toko, 'store' FROM store_master
WHERE kode_toko != 'WH_JOFFICE'
ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password;
`, [defaultPasswordHash]);
// Seed the demo account, assigned to the head office store. Hashed here
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
await pool.query(
`INSERT INTO accounts (username, password, kode_toko)
VALUES ('admin', $1, 'WH_JOFFICE')
ON CONFLICT (username) DO NOTHING;`,
`INSERT INTO accounts (username, password, kode_toko, role)
VALUES ('admin', $1, 'WH_JOFFICE', 'admin')
ON CONFLICT (username) DO UPDATE SET role = EXCLUDED.role, password = EXCLUDED.password;`,
[bcrypt.hashSync("password", 10)]
);
@@ -13,6 +13,7 @@ export interface ActiveUploadLog {
stage_1_output?: any;
stage_2_output?: any;
frontend_response?: any;
pipeline_info?: any;
}
// Store active logs in global context as a Map keyed by filename
+1
View File
@@ -6,6 +6,7 @@ export interface AccountTokenPayload {
accountId: number;
username: string;
kodeToko: string | null;
role?: string;
}
export function signAccountToken(payload: AccountTokenPayload): string {