🔧 Build Fixes:
- Created FloorToggle component
- Created HealthLegend component
- Added name field to User interface
Components complete for heatmap feature
250 lines
8.1 KiB
TypeScript
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;
|
|
}
|