ca-grow-ops-manager/frontend/src/pages/BatchDetailPage.tsx
fullsizemalt dff54a60ce
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
refactor(ui): Control Room aesthetic for core pages
- 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
2025-12-19 20:34:19 -08:00

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} />; }