ca-grow-ops-manager/frontend/src/components/heatmap/GrowRoomHeatmap.tsx
fullsizemalt b20edc0c33 fix: Add missing heatmap components and User.name field
🔧 Build Fixes:
- Created FloorToggle component
- Created HealthLegend component
- Added name field to User interface

Components complete for heatmap feature
2025-12-09 14:43:54 -08:00

250 lines
8.1 KiB
TypeScript

import { useState, useEffect } from 'react';
import FloorGrid from './FloorGrid';
import FloorToggle from './FloorToggle';
import HealthLegend from './HealthLegend';
/**
* GrowRoomHeatmap - Visual heat-map representation of plant health
*
* This component displays a multi-level grow room with color-coded beds
* based on plant health scores. Reduces cognitive load by showing problems
* as visual zones instead of text lists.
*
* @param roomId - Unique identifier for the grow room
*
* Integration:
* 1. Replace mockFetchRoomLayout with real API call to /api/rooms/:roomId/layout
* 2. Replace mockFetchRoomHealth with real API call to /api/rooms/:roomId/health
* 3. For real-time updates, add WebSocket connection or polling interval
* 4. Add error handling and loading states
*/
export interface Bed {
bed_id: string;
floor: 'floor_1' | 'floor_2';
row: number;
column: number;
plant_batch_id?: string;
status: 'active' | 'empty' | 'maintenance';
health_score: number; // 0-100
sensors?: {
temp?: number;
humidity?: number;
ec?: number;
par?: number;
};
last_alert?: string;
}
export interface RoomLayout {
room_id: string;
name: string;
floors: Array<'floor_1' | 'floor_2'>;
grid: {
rows: number;
columns: number;
};
}
interface GrowRoomHeatmapProps {
roomId: string;
}
export default function GrowRoomHeatmap({ roomId }: GrowRoomHeatmapProps) {
const [currentFloor, setCurrentFloor] = useState<'floor_1' | 'floor_2'>('floor_1');
const [layout, setLayout] = useState<RoomLayout | null>(null);
const [beds, setBeds] = useState<Bed[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadRoomData();
// TODO: For real-time updates, add polling or WebSocket
// const interval = setInterval(loadRoomData, 30000); // Poll every 30s
// return () => clearInterval(interval);
}, [roomId]);
async function loadRoomData() {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with real API calls
const layoutData = await mockFetchRoomLayout(roomId);
const healthData = await mockFetchRoomHealth(roomId);
setLayout(layoutData);
setBeds(healthData);
} catch (err: any) {
setError(err.message || 'Failed to load room data');
} finally {
setIsLoading(false);
}
}
// Filter beds for current floor
const currentFloorBeds = beds.filter(bed => bed.floor === currentFloor);
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-lg text-slate-600 dark:text-slate-400">
Loading room data...
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-lg text-red-600 dark:text-red-400">
Error: {error}
</div>
</div>
);
}
if (!layout) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-lg text-slate-600 dark:text-slate-400">
No room data available
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
{layout.name}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
Plant Health Heat Map
</p>
</div>
<FloorToggle
floors={layout.floors}
currentFloor={currentFloor}
onFloorChange={setCurrentFloor}
/>
</div>
{/* Legend */}
<HealthLegend />
{/* Grid */}
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<FloorGrid
floor={currentFloor}
rows={layout.grid.rows}
columns={layout.grid.columns}
beds={currentFloorBeds}
/>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Total Beds"
value={currentFloorBeds.length}
color="blue"
/>
<StatCard
label="Active"
value={currentFloorBeds.filter(b => b.status === 'active').length}
color="green"
/>
<StatCard
label="Needs Attention"
value={currentFloorBeds.filter(b => b.health_score < 70).length}
color="yellow"
/>
<StatCard
label="Critical"
value={currentFloorBeds.filter(b => b.health_score < 30).length}
color="red"
/>
</div>
</div>
);
}
// Stats card component
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
const colorClasses = {
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
green: 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300',
red: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300',
};
return (
<div className={`${colorClasses[color as keyof typeof colorClasses]} rounded-lg p-4`}>
<div className="text-2xl font-bold">{value}</div>
<div className="text-sm font-medium mt-1">{label}</div>
</div>
);
}
// ============================================================================
// MOCK API FUNCTIONS
// TODO: Replace these with real API calls
// ============================================================================
async function mockFetchRoomLayout(roomId: string): Promise<RoomLayout> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
return {
room_id: roomId,
name: 'Veg Room A',
floors: ['floor_1', 'floor_2'],
grid: {
rows: 6,
columns: 8,
},
};
}
async function mockFetchRoomHealth(roomId: string): Promise<Bed[]> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
const beds: Bed[] = [];
// Generate mock data for both floors
for (const floor of ['floor_1', 'floor_2'] as const) {
for (let row = 0; row < 6; row++) {
for (let col = 0; col < 8; col++) {
// Randomly make some beds empty
const isEmpty = Math.random() < 0.2;
beds.push({
bed_id: `${floor}_${row}_${col}`,
floor,
row,
column: col,
plant_batch_id: isEmpty ? undefined : `batch_${Math.floor(Math.random() * 10)}`,
status: isEmpty ? 'empty' : 'active',
health_score: isEmpty ? 0 : Math.floor(Math.random() * 100),
sensors: isEmpty ? undefined : {
temp: 72 + Math.random() * 8,
humidity: 55 + Math.random() * 15,
ec: 1.2 + Math.random() * 0.8,
par: 400 + Math.random() * 400,
},
last_alert: isEmpty ? undefined : (Math.random() < 0.3 ? '2 hours ago' : undefined),
});
}
}
}
return beds;
}