342 lines
17 KiB
TypeScript
342 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft, Thermometer, Droplets, Wind, Zap,
|
|
Sprout, Calendar, Edit2, Layers, Clock, CheckCircle2,
|
|
Activity, ClipboardList, Plus, Box
|
|
} from 'lucide-react';
|
|
import api from '../lib/api';
|
|
import { Card } from '../components/ui/card';
|
|
import { cn } from '../lib/utils';
|
|
import { motion } from 'framer-motion';
|
|
import { Room3DViewer } from '../components/facility3d/Room3DViewer';
|
|
|
|
// Mock sensor data generator 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 RoomDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [room, setRoom] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'batches' | 'tasks' | 'environment' | '3d'>('batches');
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
setLoading(true);
|
|
api.get(`/rooms/${id}`)
|
|
.then(res => setRoom(res.data))
|
|
.catch(() => navigate('/rooms'))
|
|
.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-[var(--color-primary)] border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!room) return null;
|
|
|
|
const sensorData = {
|
|
temp: generateMockData(20, 72, 78),
|
|
humidity: generateMockData(20, 45, 55),
|
|
vpd: generateMockData(20, 0.9, 1.3),
|
|
co2: generateMockData(20, 1000, 1400),
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8 pb-20 max-w-[1600px] mx-auto">
|
|
{/* Header / Hero */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 bg-[var(--color-bg-tertiary)]/50 p-6 rounded-2xl border border-[var(--color-border-subtle)]">
|
|
<div className="space-y-4 flex-1">
|
|
<button
|
|
onClick={() => navigate('/dashboard')}
|
|
className="flex items-center gap-2 text-xs font-semibold text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors"
|
|
>
|
|
<ArrowLeft size={14} /> Back to Overview
|
|
</button>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
|
{room.name?.replace('[DEMO] ', '')}
|
|
</h1>
|
|
<span className={cn(
|
|
"px-3 py-1 rounded-full text-xs font-semibold border shadow-sm",
|
|
room.type === 'FLOWER'
|
|
? "bg-purple-500/10 border-purple-500/20 text-purple-600 dark:text-purple-400"
|
|
: "bg-[var(--color-primary)]/10 border-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
|
)}>
|
|
{room.type} Phase
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-6 mt-4 text-xs font-medium text-[var(--color-text-tertiary)]">
|
|
<div className="flex items-center gap-2">
|
|
<Layers size={14} />
|
|
<span>{room.sqft?.toLocaleString()} SQFT</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Sprout size={14} />
|
|
<span>{room.capacity || 0} Plants max</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)] animate-pulse" />
|
|
<span className="text-[var(--color-primary)] font-medium">System Nominal</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button className="p-2.5 rounded-xl border border-[var(--color-border-subtle)] hover:bg-[var(--color-bg-tertiary)] transition-all text-[var(--color-text-secondary)]">
|
|
<Edit2 size={18} />
|
|
</button>
|
|
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white px-6 py-2.5 rounded-xl font-bold text-sm shadow-md transition-all">
|
|
<Plus size={18} /> Log Action
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Environmental Dashboard */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard
|
|
icon={Thermometer}
|
|
label="Temperature"
|
|
value={room.targetTemp || 75.5}
|
|
unit="°F"
|
|
color="text-[var(--color-error)]"
|
|
fill="bg-[var(--color-error)]"
|
|
data={sensorData.temp}
|
|
/>
|
|
<MetricCard
|
|
icon={Droplets}
|
|
label="Rel. Humidity"
|
|
value={room.targetHumidity || 55}
|
|
unit="%"
|
|
color="text-[var(--color-accent)]"
|
|
fill="bg-[var(--color-accent)]"
|
|
data={sensorData.humidity}
|
|
/>
|
|
<MetricCard
|
|
icon={Wind}
|
|
label="VPD Target"
|
|
value={1.15}
|
|
unit="kPa"
|
|
color="text-[var(--color-primary)]"
|
|
fill="bg-[var(--color-primary)]"
|
|
data={sensorData.vpd}
|
|
/>
|
|
<MetricCard
|
|
icon={Zap}
|
|
label="CO2 Level"
|
|
value={1200}
|
|
unit="ppm"
|
|
color="text-[var(--color-warning)]"
|
|
fill="bg-[var(--color-warning)]"
|
|
data={sensorData.co2}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content Tabs */}
|
|
<div className="space-y-6">
|
|
<div className="flex overflow-x-auto items-center gap-8 border-b border-[var(--color-border-subtle)] pb-1">
|
|
<TabButton
|
|
active={activeTab === 'batches'}
|
|
icon={Sprout}
|
|
label="Active Batches"
|
|
count={room.batches?.length || 0}
|
|
onClick={() => setActiveTab('batches')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === '3d'}
|
|
icon={Box}
|
|
label="3D View"
|
|
onClick={() => setActiveTab('3d')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === 'tasks'}
|
|
icon={ClipboardList}
|
|
label="Room Tasks"
|
|
count={4}
|
|
onClick={() => setActiveTab('tasks')}
|
|
/>
|
|
<TabButton
|
|
active={activeTab === 'environment'}
|
|
icon={Activity}
|
|
label="Environment History"
|
|
onClick={() => setActiveTab('environment')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-h-[400px]">
|
|
{activeTab === '3d' && (
|
|
<Room3DViewer room={room} />
|
|
)}
|
|
|
|
{activeTab === 'batches' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{room.batches?.map((batch: any) => (
|
|
<Link key={batch.id} to={`/batches/${batch.id}`}>
|
|
<div className="group p-5 bg-[var(--color-bg-elevated)] border border-[var(--color-border-subtle)] rounded-2xl hover:border-[var(--color-primary)] transition-all flex items-center justify-between shadow-sm">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-[var(--color-bg-tertiary)] flex items-center justify-center border border-[var(--color-border-subtle)] group-hover:scale-105 transition-transform">
|
|
<Sprout size={20} className="text-[var(--color-primary)]" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-base font-bold text-[var(--color-text-primary)]">{batch.name}</h4>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] font-medium mt-1">{batch.strain} • {batch.plantCount} plants</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="px-2 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs font-bold border border-[var(--color-primary)]/20 mb-2 inline-block">
|
|
{batch.stage}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)] font-medium">
|
|
<Clock size={12} /> Day {Math.floor((Date.now() - new Date(batch.startDate).getTime()) / 86400000)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
<button className="flex flex-col items-center justify-center p-8 rounded-2xl border-2 border-dashed border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-all bg-[var(--color-bg-primary)]/50">
|
|
<Plus size={24} className="mb-2" />
|
|
<span className="text-sm font-medium">Transfer New Batch</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'tasks' && (
|
|
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
|
<div className="divide-y divide-[var(--color-border-subtle)]">
|
|
{[
|
|
{ title: 'De-fan Fan Leaves', assignee: 'John D.', priority: 'HIGH', due: 'Today' },
|
|
{ title: 'Irrigation Maintenance', assignee: 'Sarah K.', priority: 'MED', due: 'Tomorrow' },
|
|
{ title: 'IPM Foliar Spray', assignee: 'John D.', priority: 'CRITICAL', due: '2h ago' },
|
|
{ title: 'Verify Light Intensity', assignee: 'None', priority: 'LOW', due: 'Friday' },
|
|
].map((task, i) => (
|
|
<div key={i} className="p-4 flex items-center justify-between hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer group">
|
|
<div className="flex items-center gap-4">
|
|
<div className={cn(
|
|
"w-2 h-2 rounded-full",
|
|
task.priority === 'CRITICAL' ? "bg-[var(--color-error)]" :
|
|
task.priority === 'HIGH' ? "bg-[var(--color-warning)]" : "bg-slate-300"
|
|
)} />
|
|
<div>
|
|
<p className="text-sm font-bold text-[var(--color-text-primary)]">{task.title}</p>
|
|
<div className="flex items-center gap-3 mt-1 underline-offset-4 decoration-slate-300">
|
|
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">Assigned: {task.assignee}</span>
|
|
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">Due: {task.due}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-primary)] hover:text-white transition-all">
|
|
<CheckCircle2 size={18} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ icon: Icon, label, value, unit, color, fill, data }: {
|
|
icon: any;
|
|
label: string;
|
|
value: string | number;
|
|
unit: string;
|
|
color: string;
|
|
fill: string; // added back to control sparkline background opacity
|
|
data: { value: number }[];
|
|
}) {
|
|
return (
|
|
<Card className="p-5 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] relative group overflow-hidden shadow-sm">
|
|
<div className="flex items-start justify-between relative z-10">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={14} className={color} />
|
|
<span className="text-xs font-bold text-[var(--color-text-tertiary)] tracking-wide">{label}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-2xl font-bold text-[var(--color-text-primary)]">{value}</span>
|
|
<span className="text-xs font-bold text-[var(--color-text-tertiary)]">{unit}</span>
|
|
</div>
|
|
</div>
|
|
<div className="pt-2">
|
|
<SparklineLine data={data} color={color.includes('error') ? '#f43f5e' : color.includes('accent') ? '#3b82f6' : '#10b981'} />
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-[var(--color-bg-tertiary)] overflow-hidden">
|
|
<div className={cn("h-full w-1/3 opacity-70", fill.replace('text-', 'bg-'))} />
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SparklineLine({ data, color }: { data: { value: number }[], color: string }) {
|
|
const values = data.map(d => d.value);
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const range = max - min || 1;
|
|
const width = 80;
|
|
const height = 30;
|
|
const points = data.map((d, i) => {
|
|
const x = (i / (data.length - 1)) * width;
|
|
const y = height - ((d.value - min) / range) * height;
|
|
return `${x},${y}`;
|
|
}).join(' ');
|
|
|
|
return (
|
|
<svg width={width} height={height} className="overflow-visible">
|
|
<path
|
|
d={`M ${points}`}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TabButton({ active, icon: Icon, label, count, onClick }: { active: boolean, icon: any, label: string, count?: number, onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-center gap-2 px-1 py-4 text-xs font-bold transition-all relative shrink-0",
|
|
active ? "text-[var(--color-primary)]" : "text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
|
)}
|
|
>
|
|
<Icon size={16} />
|
|
{label}
|
|
{count !== undefined && (
|
|
<span className={cn(
|
|
"ml-1 px-1.5 py-0.5 rounded-full text-[10px]",
|
|
active ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
|
)}>
|
|
{count}
|
|
</span>
|
|
)}
|
|
{active && (
|
|
<motion.div
|
|
layoutId="room-tab"
|
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-primary)]"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|