feat: Add interactive 3D tab to Room Detail and refactor styling
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-27 16:24:42 -08:00
parent aa8e5d226f
commit 477c076d03
2 changed files with 126 additions and 36 deletions

View file

@ -0,0 +1,74 @@
import { PlantPosition } from './types';
// ...
interface Room3DViewerProps {
room: any;
visMode?: 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
onPlantClick?: (plant: PlantPosition) => void;
}
export function Room3DViewer({ room, visMode = 'STANDARD', onPlantClick }: Room3DViewerProps) {
// Transform API room data to Room3D structure if needed
const room3DData: Room3D = useMemo(() => {
// If it already looks like a Room3D object (has racks/sections), use it.
// Otherwise, construct a mock layout for visualization.
if (room.racks || room.sections) {
return {
...room,
// Ensure dimensions exist
width: room.width || 20,
height: room.height || 30,
x: 0,
y: 0,
sections: room.sections || room.racks || [], // Map racks/sections to standard field
};
}
// Fallback: Create a mock layout based on capacity
return {
id: room.id,
name: room.name,
type: room.type,
width: 20,
height: 30,
x: 0,
y: 0,
sections: [], // Empty for now, or could generatively fill
};
}, [room]);
return (
<div className="w-full h-[600px] bg-gradient-to-br from-slate-900 to-slate-950 rounded-2xl overflow-hidden border border-[var(--color-border-subtle)] relative">
<Canvas
shadows
camera={{ position: [0, 40, 30], fov: 45 }}
className="w-full h-full"
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 20, 10]} intensity={1} castShadow />
<group position={[0, 0, 0]}>
<RoomObject
room={room3DData}
visMode={visMode}
onPlantClick={onPlantClick || (() => { })}
highlightedTags={[]}
dimMode={false}
hierarchy={{
facility: 'Main',
building: 'A',
floor: '1',
}}
/>
</group>
<ContactShadows position={[0, -0.01, 0]} opacity={0.4} scale={40} blur={2} far={10} />
<Environment preset="city" />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2.2} />
</Canvas>
<div className="absolute top-4 right-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 text-xs font-medium text-white/80 pointer-events-none">
Interactive 3D View
</div>
</div>
);
}

View file

@ -1,14 +1,15 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft, Thermometer, Droplets, Wind, Zap, Sun,
ArrowLeft, Thermometer, Droplets, Wind, Zap,
Sprout, Calendar, Edit2, Layers, Clock, CheckCircle2,
Activity, History, ClipboardList, Filter, MoreHorizontal, Plus
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) {
@ -22,7 +23,7 @@ export default function RoomDetailPage() {
const navigate = useNavigate();
const [room, setRoom] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'batches' | 'tasks' | 'environment'>('batches');
const [activeTab, setActiveTab] = useState<'batches' | 'tasks' | 'environment' | '3d'>('batches');
useEffect(() => {
if (id) {
@ -37,7 +38,7 @@ export default function RoomDetailPage() {
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 className="animate-spin w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full" />
</div>
);
}
@ -58,47 +59,47 @@ export default function RoomDetailPage() {
<div className="space-y-4 flex-1">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-xs font-bold text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] uppercase tracking-widest transition-colors"
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-4xl font-bold tracking-tight text-[var(--color-text-primary)] ">
<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-[10px] font-bold uppercase tracking-wide border shadow-sm",
"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-500"
: "bg-[var(--color-primary)]/10 border-emerald-500/20 text-[var(--color-primary)]"
? "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} className="text-[var(--color-text-tertiary)]" />
<Layers size={14} />
<span>{room.sqft?.toLocaleString()} SQFT</span>
</div>
<div className="flex items-center gap-2">
<Sprout size={14} className="text-[var(--color-text-tertiary)]" />
<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)]">System Nominal</span>
<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-slate-100 dark:hover:bg-slate-800 transition-all text-[var(--color-text-tertiary)]">
<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-xl shadow-emerald-500/20 transition-all uppercase tracking-widest">
<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>
@ -112,6 +113,7 @@ export default function RoomDetailPage() {
value={room.targetTemp || 75.5}
unit="°F"
color="text-[var(--color-error)]"
fill="bg-[var(--color-error)]"
data={sensorData.temp}
/>
<MetricCard
@ -120,6 +122,7 @@ export default function RoomDetailPage() {
value={room.targetHumidity || 55}
unit="%"
color="text-[var(--color-accent)]"
fill="bg-[var(--color-accent)]"
data={sensorData.humidity}
/>
<MetricCard
@ -128,6 +131,7 @@ export default function RoomDetailPage() {
value={1.15}
unit="kPa"
color="text-[var(--color-primary)]"
fill="bg-[var(--color-primary)]"
data={sensorData.vpd}
/>
<MetricCard
@ -136,13 +140,14 @@ export default function RoomDetailPage() {
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 items-center gap-8 border-b border-[var(--color-border-subtle)]">
<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}
@ -150,6 +155,12 @@ export default function RoomDetailPage() {
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}
@ -166,48 +177,52 @@ export default function RoomDetailPage() {
</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-white bg-[var(--color-bg-elevated)] border border-[var(--color-border-subtle)] rounded-2xl hover:border-emerald-500/50 transition-all flex items-center justify-between">
<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-110 transition-transform">
<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-0.5">{batch.strain} {batch.plantCount} plants</p>
<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-[10px] font-medium border border-emerald-500/20 mb-2 inline-block">
<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-[11px] text-[var(--color-text-tertiary)] font-bold uppercase tracking-tighter">
<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-emerald-500 hover:text-[var(--color-primary)] transition-all">
<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-xs font-medium">Transfer New Batch</span>
<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-slate-100 dark:divide-slate-800/50">
<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-slate-50 dark:hover:bg-slate-900/50 transition-colors cursor-pointer group">
<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",
@ -217,8 +232,8 @@ export default function RoomDetailPage() {
<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-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">Assigned: {task.assignee}</span>
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">Due: {task.due}</span>
<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>
@ -236,21 +251,22 @@ export default function RoomDetailPage() {
);
}
function MetricCard({ icon: Icon, label, value, unit, color, data }: {
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">
<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-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.15em]">{label}</span>
<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>
@ -258,11 +274,11 @@ function MetricCard({ icon: Icon, label, value, unit, color, data }: {
</div>
</div>
<div className="pt-2">
<SparklineLine data={data} color={color === 'text-[var(--color-error)]' ? '#f43f5e' : color === 'text-[var(--color-accent)]' ? '#3b82f6' : '#10b981'} />
<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-slate-100 dark:bg-slate-800 overflow-hidden">
<div className={cn("h-full w-1/3 opacity-50", color.replace('text', 'bg'))} />
<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>
);
@ -300,16 +316,16 @@ function TabButton({ active, icon: Icon, label, count, onClick }: { active: bool
<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-[var(--color-primary)]" : "text-[var(--color-text-tertiary)] hover:text-slate-800 dark:hover:text-slate-200"
"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-[9px]",
active ? "bg-[var(--color-primary)] text-white" : "bg-slate-100 dark:bg-slate-800 text-[var(--color-text-tertiary)]"
"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>