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,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>
);
}