Files
pfm-ocr/backend/pfm-web-app/src/app/page.tsx
T

698 lines
32 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
interface HistoryItem {
id: number;
filename: string;
uploadTime: string;
size: number;
parsed: boolean;
isSample: boolean;
metadata: any;
totalItems: number;
flaggedItems: number;
}
export default function Home() {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
const [selectedDetails, setSelectedDetails] = useState<any>(null);
const [selectedLogs, setSelectedLogs] = useState<any>(null);
const [activeStep, setActiveStep] = useState<number>(0);
const [uploading, setUploading] = useState<boolean>(false);
const [dragActive, setDragActive] = useState<boolean>(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch history list
const fetchHistory = async () => {
try {
const res = await fetch("/api/history?all=true");
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
}
} catch (err) {
console.error("Failed to fetch history:", err);
}
};
useEffect(() => {
fetchHistory();
}, []);
// Fetch details and logs for a selected document
const handleSelectItem = async (item: HistoryItem) => {
setSelectedItem(item);
setSelectedDetails(null);
setSelectedLogs(null);
setErrorMsg(null);
setActiveStep(0);
try {
// 1. Fetch items and doc details
const detailsRes = await fetch(`/api/history?file=${encodeURIComponent(item.filename)}`);
if (detailsRes.ok) {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
}
// 2. Fetch processing logs
const logsRes = await fetch(`/api/documents/${item.id}/logs`);
if (logsRes.ok) {
const logsData = await logsRes.json();
setSelectedLogs(logsData.processing_logs);
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
}
};
// Handle Drag & Drop events
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await handleUploadFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
await handleUploadFile(e.target.files[0]);
}
};
const handleUploadFile = async (file: File) => {
setUploading(true);
setErrorMsg(null);
try {
const formData = new FormData();
formData.append("file", file);
console.log("Uploading file to gateway...");
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
}
const data = await res.json();
console.log("Upload response received:", data);
// Refresh list and select new document
await fetchHistory();
// Attempt to find the newly uploaded item in history list
// Next.js history is sorted descending, so it will likely be first
const updatedRes = await fetch("/api/history?all=true");
if (updatedRes.ok) {
const updatedData = await updatedRes.json();
const newHistory = updatedData.history || [];
setHistory(newHistory);
const match = newHistory.find((h: HistoryItem) => h.filename === data.filename);
if (match) {
handleSelectItem(match);
}
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
} finally {
setUploading(false);
}
};
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const handleDeleteItem = async (e: React.MouseEvent, item: HistoryItem) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete ${item.filename}?`)) return;
try {
const res = await fetch("/api/history", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: item.filename })
});
if (res.ok) {
if (selectedItem?.id === item.id) {
setSelectedItem(null);
setSelectedDetails(null);
setSelectedLogs(null);
}
fetchHistory();
}
} catch (err) {
console.error("Failed to delete document:", err);
}
};
// Format bytes to human readable format
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Setup Stepper Steps
const getSteps = () => {
const vllmCallsCount = selectedLogs?.vllm_calls?.length || 0;
return [
{
title: "1. Upload & Preprocess",
description: "Verify document metadata & files",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Uploaded Document File Info</h4>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs space-y-2 text-slate-300">
<p><span className="text-slate-500">Filename:</span> {selectedItem?.filename}</p>
<p><span className="text-slate-500">File Size:</span> {formatBytes(selectedItem?.size || 0)}</p>
<p><span className="text-slate-500">Upload Date:</span> {selectedItem?.uploadTime ? new Date(selectedItem.uploadTime).toLocaleString() : "N/A"}</p>
<p><span className="text-slate-500">File Hash:</span> {selectedItem?.metadata?.fileHash || "Processed content hash saved"}</p>
<p><span className="text-slate-500">Is Sample DO:</span> {selectedItem?.isSample ? "True" : "False"}</p>
</div>
{selectedItem?.filename && (
<div>
<h4 className="text-sm font-semibold text-teal-400 mb-2">Original Document Source View</h4>
<div className="border border-slate-700 rounded-lg overflow-hidden max-h-96 flex justify-center bg-slate-950">
<img
src={selectedItem.isSample ? `/do-pfm/${selectedItem.filename}` : `/api/history?file=${encodeURIComponent(selectedItem.filename)}`}
alt="Original Document"
className="object-contain max-h-96 w-full"
onError={(e) => {
// Fallback if direct image access requires alternative endpoint
(e.target as HTMLImageElement).src = `https://placehold.co/600x800/1e293b/f8fafc?text=${encodeURIComponent(selectedItem.filename)}`;
}}
/>
</div>
</div>
)}
</div>
)
},
{
title: `2. vLLM Prompt ("Before")`,
description: `Inspect prompt parameters & system role (${vllmCallsCount} call${vllmCallsCount > 1 ? "s" : ""})`,
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Payload Sent to vLLM Server</h4>
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
selectedLogs.vllm_calls.map((call: any, idx: number) => (
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-400">
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Call #{idx + 1}</span>
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
</div>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(call.request, null, 2)}</pre>
</div>
</div>
))
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No vLLM API calls logged. (If this is a sample/re-indexed document, logs may not be recorded yet).
</div>
)}
</div>
)
},
{
title: `3. vLLM Response ("After")`,
description: "Inspect raw output returned by VLM",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Raw Text Generated by vLLM (PaddleOCR-VL)</h4>
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
selectedLogs.vllm_calls.map((call: any, idx: number) => (
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-400">
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Response #{idx + 1}</span>
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
</div>
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(call.response, null, 2)}</pre>
</div>
</div>
))
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No vLLM API responses logged.
</div>
)}
</div>
)
},
{
title: "4. Raw OCR Layout Markdown",
description: "View parsed layout markdown structure",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Document Layout Parsing Markdown Output</h4>
{selectedDetails?.result || selectedLogs?.ocr_raw ? (
<div className="space-y-2">
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre className="whitespace-pre-wrap">
{(() => {
const res = selectedDetails?.result || selectedLogs?.ocr_raw;
const page0 = res?.layoutParsingResults?.[0] || {};
return page0?.markdown?.text || "No markdown text output found in results.";
})()}
</pre>
</div>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No raw layout results parsed.
</div>
)}
</div>
)
},
{
title: "5. Stage 1 Extracted Fields",
description: "View output of regex schema matching",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Regex Extracted Fields (Metadata & Items)</h4>
{selectedLogs?.stage_1_output ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(selectedLogs.stage_1_output, null, 2)}</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No Stage 1 log output saved.
</div>
)}
</div>
)
},
{
title: "6. Stage 2 Sanitized Output",
description: "View results after validation checks",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Sanitized & Validated Metadata (Final database fields)</h4>
{selectedLogs?.stage_2_output ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>{JSON.stringify(selectedLogs.stage_2_output, null, 2)}</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No Stage 2 log output saved.
</div>
)}
</div>
)
},
{
title: "7. Client Gateway Response",
description: "Inspect JSON payload sent to frontends",
content: (
<div className="space-y-4">
<h4 className="text-sm font-semibold text-teal-400">Final Gateway JSON Response</h4>
{selectedLogs?.frontend_response || selectedDetails ? (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
<pre>
{JSON.stringify(
selectedLogs?.frontend_response || {
filename: selectedItem?.filename,
result: selectedDetails
},
null,
2
)}
</pre>
</div>
) : (
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
No response payload logged.
</div>
)}
</div>
)
}
];
};
const steps = getSteps();
return (
<div className="flex-1 flex flex-col bg-slate-950 text-slate-100 min-h-screen">
{/* Sleek Top Navigation Bar */}
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md px-6 py-4 flex justify-between items-center sticky top-0 z-50">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-gradient-to-tr from-teal-500 to-indigo-600 rounded-lg flex items-center justify-center font-bold text-white shadow-lg shadow-teal-500/20">
P
</div>
<div>
<h1 className="text-md font-bold bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
Prima Fresh Mart AI OCR Gateway
</h1>
<p className="text-[10px] text-teal-400 font-mono tracking-wider uppercase">
Pipeline Process Layers Dashboard
</p>
</div>
</div>
{/* Status Indicators */}
<div className="flex items-center space-x-4 text-xs">
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-slate-400 font-mono">DB: Online</span>
</div>
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-slate-400 font-mono">vLLM: Connected</span>
</div>
</div>
</header>
{/* Main Layout Grid */}
<main className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 p-6 overflow-hidden">
{/* LEFT COLUMN: Upload Panel & History List (4 Cols) */}
<section className="lg:col-span-4 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)] pr-2">
{/* Upload Zone Card */}
<div
className={`border-2 border-dashed rounded-2xl p-6 transition-all duration-300 cursor-pointer flex flex-col items-center justify-center ${
dragActive
? "border-teal-400 bg-teal-950/20 shadow-md shadow-teal-500/10"
: "border-slate-800 hover:border-slate-700 bg-slate-900/40 hover:bg-slate-900/60"
}`}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={triggerFileInput}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
{uploading ? (
<div className="flex flex-col items-center py-4 space-y-3">
<div className="h-8 w-8 border-4 border-t-teal-400 border-slate-700 rounded-full animate-spin"></div>
<p className="text-xs text-teal-400 font-mono">Running OCR Pipeline...</p>
</div>
) : (
<div className="text-center py-4 space-y-3">
<div className="text-3xl text-slate-400">📤</div>
<div>
<p className="text-sm font-semibold text-slate-200">Upload Delivery Order Image</p>
<p className="text-xs text-slate-500 mt-1">Drag and drop file, or click to browse</p>
</div>
</div>
)}
</div>
{/* History List Card */}
<div className="bg-slate-900/30 border border-slate-850 rounded-2xl p-5 flex flex-col flex-grow min-h-[350px]">
<h3 className="text-sm font-bold text-slate-400 font-mono uppercase tracking-wider mb-4">
Upload History
</h3>
<div className="space-y-3 overflow-y-auto max-h-[450px] flex-grow pr-1">
{history.length === 0 ? (
<div className="text-center py-8 text-xs text-slate-500 italic">
No uploads processed yet.
</div>
) : (
history.map((item) => {
const isSelected = selectedItem?.id === item.id;
const dateFormatted = new Date(item.uploadTime).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
});
return (
<div
key={item.id}
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex justify-between items-start ${
isSelected
? "bg-slate-900 border-teal-500/60 shadow-lg shadow-teal-500/5"
: "bg-slate-900/50 border-slate-800 hover:border-slate-700"
}`}
onClick={() => handleSelectItem(item)}
>
<div className="space-y-1.5 min-w-0 flex-1">
<p className="text-xs font-semibold text-slate-200 truncate pr-2" title={item.filename}>
{item.filename.substring(item.filename.indexOf("-") + 1)}
</p>
<div className="flex items-center space-x-2 text-[10px] text-slate-500 font-mono">
<span>{dateFormatted}</span>
<span>•</span>
<span>{formatBytes(item.size)}</span>
</div>
{item.metadata && (
<div className="flex flex-wrap gap-1.5 mt-2">
{item.metadata.noPO && item.metadata.noPO !== "Not Found" && (
<span className="bg-teal-950/50 text-teal-400 border border-teal-900/60 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
{item.metadata.noPO}
</span>
)}
{item.totalItems > 0 && (
<span className="bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded text-[9px] font-mono">
{item.totalItems} Items
</span>
)}
{item.flaggedItems > 0 && (
<span className="bg-rose-950/30 text-rose-400 border border-rose-900/40 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
{item.flaggedItems} Warning
</span>
)}
</div>
)}
</div>
<button
onClick={(e) => handleDeleteItem(e, item)}
className="text-slate-600 hover:text-rose-400 p-1.5 rounded-lg hover:bg-rose-950/20 transition-colors"
title="Delete document"
>
🗑️
</button>
</div>
);
})
)}
</div>
</div>
</section>
{/* RIGHT COLUMN: Interactive Stepper & Document Viewer (8 Cols) */}
<section className="lg:col-span-8 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)]">
{errorMsg && (
<div className="bg-rose-950/30 border border-rose-800/40 p-4 rounded-xl text-xs text-rose-400 flex items-start space-x-2">
<span className="text-base leading-none">⚠️</span>
<span>{errorMsg}</span>
</div>
)}
{!selectedItem ? (
<div className="flex-grow bg-slate-900/20 border border-slate-900/60 rounded-2xl p-12 flex flex-col justify-center items-center text-center">
<div className="text-5xl mb-4 text-slate-700 animate-pulse">📁</div>
<h3 className="text-md font-bold text-slate-400">No Document Selected</h3>
<p className="text-xs text-slate-500 max-w-sm mt-2">
Upload a new Delivery Order image, or select one from the history panel to inspect its processing stages.
</p>
</div>
) : (
<div className="space-y-6">
{/* Document Overview Metadata Card */}
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl">
<div className="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
<div>
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
<span>📄</span>
<span className="truncate max-w-md">{selectedItem.filename.substring(selectedItem.filename.indexOf("-") + 1)}</span>
</h3>
<p className="text-xs text-slate-400 mt-1">Parsed Overview results from Postgres Database</p>
</div>
{selectedItem.isSample && (
<span className="bg-blue-950/40 text-blue-400 border border-blue-900/40 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider font-mono">
Sample DO
</span>
)}
</div>
{selectedDetails?.result?.metadata || selectedItem.metadata ? (
(() => {
const meta = selectedDetails?.result?.metadata || selectedItem.metadata;
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs font-mono">
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">PO Number</p>
<p className="text-slate-200 font-semibold">{meta.noPO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">SO Number</p>
<p className="text-slate-200 font-semibold">{meta.noSO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">DO Number</p>
<p className="text-slate-200 font-semibold">{meta.noDO || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850 md:col-span-3">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Order Untuk (Store)</p>
<p className="text-slate-200 font-semibold text-sm">{meta.orderUntuk || "Not Found"}</p>
<p className="text-slate-400 mt-1 text-[10px] truncate">{meta.alamat || ""}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Date</p>
<p className="text-slate-200 font-semibold">{meta.tanggal || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Truck Plate</p>
<p className="text-slate-200 font-semibold">{meta.platTruk || "Not Found"}</p>
</div>
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Total SKUs</p>
<p className="text-slate-200 font-semibold">{selectedDetails?.items?.length || 0} SKU Items</p>
</div>
</div>
);
})()
) : (
<p className="text-xs text-slate-500 italic">No metadata available</p>
)}
{/* Parsed Items List */}
{selectedDetails?.items && selectedDetails.items.length > 0 && (
<div className="mt-5 border-t border-slate-800 pt-4">
<h4 className="text-xs font-bold font-mono text-slate-400 uppercase mb-2">Parsed SKU Table Rows</h4>
<div className="overflow-x-auto border border-slate-850 rounded-xl">
<table className="min-w-full text-xs text-left text-slate-300">
<thead className="bg-slate-950/60 font-mono text-[9px] uppercase text-slate-500 border-b border-slate-800">
<tr>
<th className="px-4 py-2">No</th>
<th className="px-4 py-2">SKU Code</th>
<th className="px-4 py-2">Item Description</th>
<th className="px-4 py-2 text-right">Qty</th>
<th className="px-4 py-2 text-right">Price/Total</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/40 bg-slate-950/10">
{selectedDetails.items.map((item: any, idx: number) => {
const isFlagged = selectedDetails.flagged?.[idx];
const remark = selectedDetails.remarks?.[idx];
return (
<tr key={idx} className={isFlagged ? "bg-rose-950/10" : "hover:bg-slate-900/20"}>
<td className="px-4 py-2 text-slate-500">{idx + 1}</td>
<td className={`px-4 py-2 font-mono font-semibold ${isFlagged ? "text-rose-400" : "text-teal-400"}`}>
{item.kodeBarang}
</td>
<td className="px-4 py-2 pr-6">
<div>
<p className="font-semibold text-slate-200">{item.namaBarang}</p>
{remark && <p className="text-[10px] text-rose-400 italic mt-0.5">{remark}</p>}
</div>
</td>
<td className="px-4 py-2 text-right font-mono font-semibold">{item.banyak}</td>
<td className="px-4 py-2 text-right font-mono text-slate-400">{item.jumlah}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* TIMELINE / STEPPER LAYERS CARD */}
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl flex flex-col space-y-6">
<div className="border-b border-slate-800 pb-3">
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
<span>🔬</span>
<span>AI OCR Pipeline Process Layers</span>
</h3>
<p className="text-xs text-slate-500 mt-1">Interactive vertical timeline inspecting how data flows from original image to clean JSON response</p>
</div>
{/* Process Timeline Stepper Container */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* Stepper Steps (4 Cols) */}
<div className="md:col-span-4 flex flex-col space-y-2 border-r border-slate-800/60 pr-4">
{steps.map((step, idx) => {
const isActive = activeStep === idx;
return (
<div
key={idx}
className={`p-3 rounded-xl cursor-pointer transition-all duration-200 border text-left ${
isActive
? "bg-slate-900 border-teal-500 text-slate-200 shadow-md shadow-teal-500/5"
: "bg-slate-950/20 border-transparent text-slate-500 hover:text-slate-300"
}`}
onClick={() => setActiveStep(idx)}
>
<p className="text-xs font-bold font-mono tracking-wide">{step.title}</p>
<p className="text-[10px] font-normal truncate mt-0.5">{step.description}</p>
</div>
);
})}
</div>
{/* Step Detail Content Panel (8 Cols) */}
<div className="md:col-span-8 bg-slate-950/40 border border-slate-850 rounded-xl p-5 min-h-[350px]">
<div className="flex justify-between items-center mb-4 border-b border-slate-800/80 pb-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">
Layer Detail view
</h4>
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full font-mono font-semibold">
Layer {activeStep + 1} of 7
</span>
</div>
<div className="text-xs text-slate-200">
{steps[activeStep].content}
</div>
</div>
</div>
</div>
</div>
)}
</section>
</main>
</div>
);
}