- BatchDetailPage: lifecycle timeline, KPI strip, tabbed interface - TasksPage: operational command board with filtering sidebar - AuditLogPage: System Ledger with clinical styling - BatchesPage: Production Inventory with KPI cards - Updated color tokens and typography across components
416 lines
24 KiB
TypeScript
416 lines
24 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft, Calendar, Droplets, Thermometer, Wind, Zap,
|
|
Bug, Camera, Activity, TrendingUp, Clock, CheckCircle2,
|
|
AlertTriangle, Sprout, Sun, History, ClipboardList,
|
|
ShieldCheck, BarChart3, MoreHorizontal, Download, Plus,
|
|
Trash2, ExternalLink
|
|
} from 'lucide-react';
|
|
import { batchesApi, Batch } from '../lib/batchesApi';
|
|
import { Card } from '../components/ui/card';
|
|
import { cn } from '../lib/utils';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
|
|
// Mock sensor data for Sparklines
|
|
function generateMockData(count: number, min: number, max: number) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
value: min + Math.random() * (max - min),
|
|
}));
|
|
}
|
|
|
|
export default function BatchDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [batch, setBatch] = useState<Batch | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'overview' | 'environment' | 'tasks' | 'compliance'>('overview');
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
setLoading(true);
|
|
batchesApi.getById(id)
|
|
.then(setBatch)
|
|
.catch((err) => {
|
|
console.error('Failed to load batch:', err);
|
|
navigate('/batches');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}
|
|
}, [id, navigate]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div className="animate-spin w-8 h-8 border-4 border-emerald-500 border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!batch) return null;
|
|
|
|
const startDate = new Date(batch.startDate);
|
|
const daysInCycle = Math.floor((Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
return (
|
|
<div className="space-y-8 pb-20 max-w-[1600px] mx-auto">
|
|
{/* Context Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-2xl border border-slate-200 dark:border-slate-800">
|
|
<div className="space-y-4 flex-1">
|
|
<button
|
|
onClick={() => navigate('/batches')}
|
|
className="flex items-center gap-2 text-xs font-bold text-slate-500 hover:text-emerald-500 uppercase tracking-widest transition-colors"
|
|
>
|
|
<ArrowLeft size={14} /> Back to Inventory
|
|
</button>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white uppercase italic">
|
|
{batch.name}
|
|
</h1>
|
|
<span className={cn(
|
|
"px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.2em] border shadow-sm",
|
|
batch.stage === 'FLOWERING'
|
|
? "bg-purple-500/10 border-purple-500/20 text-purple-500"
|
|
: "bg-emerald-500/10 border-emerald-500/20 text-emerald-500"
|
|
)}>
|
|
{batch.stage}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-6 mt-4 text-xs font-bold uppercase tracking-widest text-slate-500">
|
|
<div className="flex items-center gap-2">
|
|
<Sprout size={14} className="text-emerald-500" />
|
|
<span>{batch.strain}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Layers size={14} className="text-slate-400" />
|
|
<span>{batch.plantCount} Plants</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Calendar size={14} className="text-slate-400" />
|
|
<span>Cycle Day {daysInCycle}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck size={14} className="text-blue-500" />
|
|
<span className="text-blue-500">METRC SYNCED</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button className="p-2.5 rounded-xl border border-slate-200 dark:border-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all text-slate-500">
|
|
<Download size={18} />
|
|
</button>
|
|
<button className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2.5 rounded-xl font-bold text-sm shadow-xl shadow-emerald-500/20 transition-all uppercase tracking-widest">
|
|
<Plus size={18} /> Batch Event
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Strip */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<KPICard label="Current Health" value="98%" trend="+2%" status="success" icon={Activity} />
|
|
<KPICard label="Expected Yield" value="4.2kg" trend="On Target" status="neutral" icon={BarChart3} />
|
|
<KPICard label="Room Temp Avg" value="76.4°F" trend="Optimal" status="success" icon={Thermometer} />
|
|
<KPICard label="VPD Focus" value="1.25" trend="Stable" status="success" icon={Wind} />
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
|
{/* Vertical Timeline - Left Column */}
|
|
<div className="xl:col-span-3 space-y-6">
|
|
<div className="flex items-center justify-between px-2">
|
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-slate-500">Batch Lifecycle</h3>
|
|
<History size={14} className="text-slate-400" />
|
|
</div>
|
|
|
|
<div className="relative pl-6 space-y-8 before:absolute before:left-2.5 before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-100 dark:before:bg-slate-800">
|
|
<TimelineEvent
|
|
status="completed"
|
|
title="Harvested"
|
|
date="5 days remaining (Est.)"
|
|
desc="Target moisture: 10-12%"
|
|
icon={CheckCircle2}
|
|
/>
|
|
<TimelineEvent
|
|
status="current"
|
|
title="Flowering"
|
|
date="Week 8 of 9"
|
|
desc="Pistils darkening (40%)"
|
|
icon={Activity}
|
|
/>
|
|
<TimelineEvent
|
|
status="completed"
|
|
title="Veg Phase"
|
|
date="Completed Oct 15"
|
|
desc="28 days total"
|
|
icon={CheckCircle2}
|
|
/>
|
|
<TimelineEvent
|
|
status="completed"
|
|
title="Cloned"
|
|
date="Completed Sept 18"
|
|
desc="Rooted successfully"
|
|
icon={CheckCircle2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabbed Content - Right Column */}
|
|
<div className="xl:col-span-9 space-y-6">
|
|
<div className="flex items-center gap-8 border-b border-slate-200 dark:border-slate-800">
|
|
<TabButton
|
|
active={activeTab === 'overview'}
|
|
label="Batch Overview"
|
|
icon={LayoutDashboard}
|
|
onClick={() => setActiveTab('overview')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === 'environment'}
|
|
label="Environmental History"
|
|
icon={Activity}
|
|
onClick={() => setActiveTab('environment')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === 'tasks'}
|
|
label="Task Log"
|
|
icon={ClipboardList}
|
|
count={12}
|
|
onClick={() => setActiveTab('tasks')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === 'compliance'}
|
|
label="Compliance"
|
|
icon={ShieldCheck}
|
|
onClick={() => setActiveTab('compliance')}
|
|
/>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={activeTab}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{activeTab === 'overview' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card className="p-6 dark:bg-[#13171F] border-slate-200 dark:border-slate-800 space-y-6">
|
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500">Quick Actions</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<ActionButton label="Log Feeding" icon={Droplets} />
|
|
<ActionButton label="IPM Action" icon={Bug} />
|
|
<ActionButton label="Waste Log" icon={Trash2} />
|
|
<ActionButton label="Room Transfer" icon={Layers} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6 dark:bg-[#13171F] border-slate-200 dark:border-slate-800 space-y-6">
|
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500">Integration Hub</h4>
|
|
<div className="space-y-4">
|
|
<IntegrationItem name="METRC" status="Connected" time="Sync 2m ago" />
|
|
<IntegrationItem name="DLI Monitor" status="Active" time="840 umol/m2" />
|
|
<IntegrationItem name="Sensors" status="Online" time="8 Nodes active" />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="md:col-span-2 p-6 dark:bg-[#13171F] border-slate-200 dark:border-slate-800">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 text-left">Recent Activity</h4>
|
|
<button className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest hover:underline">View All Log</button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{batch.touchPoints?.map(tp => (
|
|
<div key={tp.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800/50">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-8 h-8 rounded-lg bg-white dark:bg-slate-800 flex items-center justify-center border border-slate-200 dark:border-slate-800 shadow-sm">
|
|
<Activity size={14} className="text-emerald-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-slate-900 dark:text-slate-100">{tp.type}</p>
|
|
<p className="text-[10px] text-slate-500 font-medium">{tp.notes || 'Routine check complete'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-[10px] font-bold text-slate-900 dark:text-slate-100 uppercase tracking-widest">{tp.user?.name}</p>
|
|
<p className="text-[10px] text-slate-500">{new Date(tp.createdAt).toLocaleDateString()}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'environment' && (
|
|
<Card className="p-8 dark:bg-[#13171F] border-slate-200 dark:border-slate-800">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500">Sensor Rollups</h4>
|
|
<div className="flex items-center gap-2">
|
|
<button className="px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-900 text-[10px] font-bold uppercase tracking-widest">24h</button>
|
|
<button className="px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest">7d</button>
|
|
</div>
|
|
</div>
|
|
<div className="h-[400px] w-full bg-slate-50/50 dark:bg-slate-900/50 rounded-2xl border border-slate-100 dark:border-slate-800 flex items-center justify-center text-slate-400">
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<TrendingUp size={32} />
|
|
<span className="text-xs font-bold tracking-widest uppercase">Interactive Analytics Terminal</span>
|
|
<span className="text-[10px] opacity-50">Rendering environmental time-series...</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'compliance' && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="p-5 rounded-2xl bg-blue-500/10 border border-blue-500/20">
|
|
<ShieldCheck className="text-blue-500 mb-2" size={24} />
|
|
<h5 className="text-sm font-bold text-blue-500">Compliance OK</h5>
|
|
<p className="text-[11px] text-blue-400 font-medium mt-1">No outstanding events detected.</p>
|
|
</div>
|
|
<div className="p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20">
|
|
<CheckCircle2 className="text-emerald-500 mb-2" size={24} />
|
|
<h5 className="text-sm font-bold text-emerald-500">Verified</h5>
|
|
<p className="text-[11px] text-emerald-400 font-medium mt-1">All labor hours signed off.</p>
|
|
</div>
|
|
<div className="p-5 rounded-2xl bg-amber-500/10 border border-amber-500/20">
|
|
<Download className="text-amber-500 mb-2" size={24} />
|
|
<h5 className="text-sm font-bold text-amber-500">Manifest Ready</h5>
|
|
<p className="text-[11px] text-amber-400 font-medium mt-1">Export batch certificate of analysis.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="p-6 dark:bg-[#13171F] border-slate-200 dark:border-slate-800">
|
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-6">Traceability History</h4>
|
|
<div className="space-y-0.5">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors">
|
|
<div className="flex items-center gap-4">
|
|
<Clock size={14} className="text-slate-400" />
|
|
<span className="text-xs font-bold font-mono text-slate-500">#E-00452-Z</span>
|
|
<span className="text-xs font-medium">Batch Transfer: Veg-1 to Flow-A</span>
|
|
</div>
|
|
<ExternalLink size={14} className="text-slate-400" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KPICard({ label, value, trend, status, icon: Icon }: any) {
|
|
return (
|
|
<Card className="p-5 dark:bg-[#13171F] border-slate-200 dark:border-slate-800 relative group overflow-hidden">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Icon size={14} className="text-slate-500" />
|
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">{label}</span>
|
|
</div>
|
|
<div className="text-2xl font-black italic tracking-tighter text-slate-900 dark:text-white">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
<div className={cn(
|
|
"px-1.5 py-0.5 rounded text-[9px] font-bold uppercase",
|
|
status === 'success' ? "bg-emerald-500/10 text-emerald-500" : "bg-slate-100 dark:bg-slate-800 text-slate-500"
|
|
)}>
|
|
{trend}
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-slate-100 dark:bg-slate-800" />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function TimelineEvent({ status, title, date, desc, icon: Icon }: any) {
|
|
return (
|
|
<div className="relative group">
|
|
<div className={cn(
|
|
"absolute -left-[1.35rem] w-3 h-3 rounded-full border-2 transition-all group-hover:scale-125 z-10",
|
|
status === 'completed' ? "bg-emerald-500 border-emerald-600 shadow-sm" :
|
|
status === 'current' ? "bg-white dark:bg-[#0B0E14] border-emerald-500 animate-pulse" :
|
|
"bg-slate-200 dark:bg-slate-800 border-slate-300 dark:border-slate-700"
|
|
)} />
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={12} className={status === 'completed' ? "text-emerald-500" : "text-slate-400"} />
|
|
<h5 className={cn(
|
|
"text-[11px] font-black uppercase tracking-widest",
|
|
status === 'completed' ? "text-slate-900 dark:text-white" : "text-slate-400"
|
|
)}>{title}</h5>
|
|
</div>
|
|
<p className="text-[10px] font-bold text-emerald-500 tracking-tighter uppercase">{date}</p>
|
|
<p className="text-[10px] text-slate-500 font-medium leading-relaxed">{desc}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TabButton({ active, label, icon: Icon, count, onClick }: any) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-center gap-2 px-1 py-4 text-xs font-bold uppercase tracking-[0.15em] transition-all relative",
|
|
active ? "text-emerald-500" : "text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
|
)}
|
|
>
|
|
<Icon size={16} />
|
|
{label}
|
|
{count !== undefined && (
|
|
<span className={cn(
|
|
"ml-1 px-1.5 py-0.5 rounded-full text-[9px]",
|
|
active ? "bg-emerald-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500"
|
|
)}>
|
|
{count}
|
|
</span>
|
|
)}
|
|
{active && (
|
|
<motion.div
|
|
layoutId="batch-tab"
|
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ActionButton({ label, icon: Icon }: any) {
|
|
return (
|
|
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800/50 hover:border-emerald-500/50 hover:bg-emerald-500/10 transition-all group">
|
|
<Icon size={20} className="text-slate-400 group-hover:text-emerald-500 mb-2 transition-colors" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500 group-hover:text-emerald-500">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function IntegrationItem({ name, status, time }: any) {
|
|
return (
|
|
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800/50">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
|
<div>
|
|
<p className="text-[11px] font-bold text-slate-900 dark:text-slate-100">{name}</p>
|
|
<p className="text-[9px] text-slate-500 font-medium uppercase tracking-tighter">{time}</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest">{status}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Layers({ size, className }: any) { return <Sun size={size} className={className} />; }
|
|
function LayoutDashboard({ size, className }: any) { return <Activity size={size} className={className} />; }
|
|
|