ca-grow-ops-manager/frontend/src/pages/RoomDetailPage.tsx
fullsizemalt 477c076d03
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
feat: Add interactive 3D tab to Room Detail and refactor styling
2025-12-27 16:24:42 -08:00

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