feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user