feat: room cards with color-coded headers + room detail page
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Room cards now have colored header backgrounds per type (VEG=green, FLOWER=purple, DRY=amber, CURE=orange, etc.)
- Cards are clickable, linking to /rooms/:id
- New RoomDetailPage with gradient header, sensor metrics with sparklines, active batches list
- Backend: GET /rooms/:id endpoint returns room with batches
This commit is contained in:
fullsizemalt 2025-12-12 19:33:07 -08:00
parent 3239a8b89b
commit eb5ebc610f
5 changed files with 340 additions and 70 deletions

View file

@ -13,6 +13,26 @@ export const getRooms = async (request: FastifyRequest, reply: FastifyReply) =>
return rooms;
};
export const getRoomById = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const room = await request.server.prisma.room.findUnique({
where: { id },
include: {
batches: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' }
}
}
});
if (!room) {
return reply.status(404).send({ message: 'Room not found' });
}
return room;
};
export const createRoom = async (request: FastifyRequest, reply: FastifyReply) => {
const {
name,

View file

@ -1,5 +1,5 @@
import { FastifyInstance } from 'fastify';
import { getRooms, createRoom } from '../controllers/rooms.controller';
import { getRooms, getRoomById, createRoom } from '../controllers/rooms.controller';
export async function roomRoutes(server: FastifyInstance) {
server.addHook('onRequest', async (request) => {
@ -14,5 +14,6 @@ export async function roomRoutes(server: FastifyInstance) {
});
server.get('/', getRooms);
server.get('/:id', getRoomById);
server.post('/', createRoom);
}

View file

@ -0,0 +1,245 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft, Thermometer, Droplets, Wind, Zap, Sun,
Sprout, Calendar, Edit2, Layers
} from 'lucide-react';
import api from '../lib/api';
// Mock sensor data generator
function generateMockData(days: number, min: number, max: number) {
return Array.from({ length: days }, (_, i) => ({
day: i + 1,
value: min + Math.random() * (max - min),
}));
}
// SVG Sparkline
function Sparkline({ data, color = '#3B82F6', height = 32 }: {
data: { value: number }[];
color?: string;
height?: number;
}) {
if (data.length === 0) return null;
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 points = data.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((d.value - min) / range) * (height - 4);
return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height}>
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default function RoomDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [room, setRoom] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [sensorData] = useState(() => ({
temperature: generateMockData(14, 68, 78),
humidity: generateMockData(14, 45, 65),
vpd: generateMockData(14, 0.8, 1.4),
co2: generateMockData(14, 800, 1200),
}));
useEffect(() => {
if (id) {
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-6 h-6 border-2 border-accent border-t-transparent rounded-full" />
</div>
);
}
if (!room) {
return (
<div className="text-center py-12">
<p className="text-secondary">Room not found</p>
<Link to="/rooms" className="text-accent text-sm mt-2 inline-block"> Back to rooms</Link>
</div>
);
}
// Header color based on type
const typeColors: Record<string, string> = {
VEG: 'from-green-500/20 to-green-500/5',
FLOWER: 'from-purple-500/20 to-purple-500/5',
DRY: 'from-amber-500/20 to-amber-500/5',
CURE: 'from-orange-500/20 to-orange-500/5',
MOTHER: 'from-pink-500/20 to-pink-500/5',
CLONE: 'from-teal-500/20 to-teal-500/5',
};
const gradient = typeColors[room.type] || 'from-slate-500/20 to-slate-500/5';
return (
<div className="max-w-5xl mx-auto pb-20 space-y-6 animate-in">
{/* Header with gradient */}
<div className={`-mx-4 -mt-4 px-6 py-6 bg-gradient-to-b ${gradient}`}>
<div className="flex items-start gap-4">
<button onClick={() => navigate('/rooms')} className="p-2 -ml-2 hover:bg-white/20 rounded-md transition-colors">
<ArrowLeft size={18} className="text-secondary" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-semibold text-primary">{room.name?.replace('[DEMO] ', '')}</h1>
<span className="badge">{room.type}</span>
</div>
<div className="flex items-center gap-4 text-sm text-secondary">
<span className="flex items-center gap-1"><Layers size={12} /> {room.sqft?.toLocaleString()} sqft</span>
<span></span>
<span>{room.capacity || '—'} plant capacity</span>
</div>
</div>
<button className="btn btn-secondary h-9">
<Edit2 size={14} /> Edit
</button>
</div>
</div>
{/* Current Conditions */}
<div className="card p-4">
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-4">Current Conditions</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricTile
icon={Thermometer}
label="Temperature"
value={room.targetTemp || '—'}
unit="°F"
color="#EF4444"
data={sensorData.temperature}
/>
<MetricTile
icon={Droplets}
label="Humidity"
value={room.targetHumidity || '—'}
unit="%"
color="#3B82F6"
data={sensorData.humidity}
/>
<MetricTile
icon={Wind}
label="VPD"
value={sensorData.vpd[sensorData.vpd.length - 1].value.toFixed(2)}
unit="kPa"
color="#8B5CF6"
data={sensorData.vpd}
/>
<MetricTile
icon={Zap}
label="CO₂"
value={Math.round(sensorData.co2[sensorData.co2.length - 1].value)}
unit="ppm"
color="#10B981"
data={sensorData.co2}
/>
</div>
</div>
{/* Active Batches */}
<div className="card">
<div className="p-4 border-b border-subtle flex items-center justify-between">
<h3 className="text-sm font-medium text-primary">Active Batches</h3>
<span className="text-xs text-tertiary">{room.batches?.length || 0} batches</span>
</div>
{room.batches?.length > 0 ? (
<div className="divide-y divide-subtle">
{room.batches.map((batch: any) => (
<Link
key={batch.id}
to={`/batches/${batch.id}`}
className="flex items-center justify-between p-4 hover:bg-tertiary transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-accent-muted flex items-center justify-center">
<Sprout size={14} className="text-accent" />
</div>
<div>
<p className="text-sm font-medium text-primary">{batch.name}</p>
<p className="text-xs text-tertiary">{batch.strain} {batch.plantCount} plants</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="badge">{batch.stage}</span>
<span className="text-xs text-tertiary flex items-center gap-1">
<Calendar size={10} />
Day {Math.floor((Date.now() - new Date(batch.startDate).getTime()) / 86400000)}
</span>
</div>
</Link>
))}
</div>
) : (
<div className="p-8 text-center">
<Sprout size={32} className="mx-auto text-quaternary mb-2" />
<p className="text-sm text-tertiary">No active batches in this room</p>
</div>
)}
</div>
{/* Room Settings */}
<div className="card p-4">
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-4">Target Settings</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-tertiary">Target Temp</span>
<p className="font-medium text-primary">{room.targetTemp || '—'}°F</p>
</div>
<div>
<span className="text-tertiary">Target Humidity</span>
<p className="font-medium text-primary">{room.targetHumidity || '—'}%</p>
</div>
<div>
<span className="text-tertiary">Light Schedule</span>
<p className="font-medium text-primary">{room.lightSchedule || '18/6'}</p>
</div>
<div>
<span className="text-tertiary">Floor</span>
<p className="font-medium text-primary">{room.floor || 'Main'}</p>
</div>
</div>
</div>
</div>
);
}
function MetricTile({ icon: Icon, label, value, unit, color, data }: {
icon: typeof Thermometer;
label: string;
value: string | number;
unit: string;
color: string;
data: { value: number }[];
}) {
return (
<div className="bg-tertiary rounded-md p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon size={14} style={{ color }} />
<span className="text-xs text-tertiary">{label}</span>
</div>
<Sparkline data={data} color={color} />
</div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-semibold text-primary">{value}</span>
<span className="text-xs text-tertiary">{unit}</span>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Home, Plus, Thermometer, Droplets } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Home, Plus, Thermometer, Droplets, ChevronRight } from 'lucide-react';
import api from '../lib/api';
import { usePermissions } from '../hooks/usePermissions';
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
@ -25,27 +26,18 @@ export default function RoomsPage() {
}
};
const getRoomTypeAccent = (type: string): 'default' | 'accent' | 'success' | 'warning' => {
const accents: Record<string, 'default' | 'accent' | 'success' | 'warning'> = {
VEG: 'success',
FLOWER: 'accent',
DRY: 'warning',
CURE: 'warning',
MOTHER: 'accent',
TRIM: 'default',
// Header background colors per room type
const getHeaderStyle = (type: string) => {
const styles: Record<string, { bg: string; text: string; border: string }> = {
VEG: { bg: 'bg-green-500/10', text: 'text-green-600', border: 'border-green-500/20' },
FLOWER: { bg: 'bg-purple-500/10', text: 'text-purple-600', border: 'border-purple-500/20' },
DRY: { bg: 'bg-amber-500/10', text: 'text-amber-600', border: 'border-amber-500/20' },
CURE: { bg: 'bg-orange-500/10', text: 'text-orange-600', border: 'border-orange-500/20' },
MOTHER: { bg: 'bg-pink-500/10', text: 'text-pink-600', border: 'border-pink-500/20' },
TRIM: { bg: 'bg-slate-500/10', text: 'text-slate-600', border: 'border-slate-500/20' },
CLONE: { bg: 'bg-teal-500/10', text: 'text-teal-600', border: 'border-teal-500/20' },
};
return accents[type] || 'default';
};
const getBadgeClass = (type: string) => {
const accent = getRoomTypeAccent(type);
const classes = {
default: 'badge',
accent: 'badge-accent',
success: 'badge-success',
warning: 'badge-warning',
};
return classes[accent];
return styles[type] || { bg: 'bg-tertiary', text: 'text-secondary', border: 'border-subtle' };
};
return (
@ -83,26 +75,33 @@ export default function RoomsPage() {
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{rooms.map(room => (
<div
{rooms.map(room => {
const style = getHeaderStyle(room.type);
return (
<Link
key={room.id}
className="card card-interactive p-4 group"
to={`/rooms/${room.id}`}
className="card overflow-hidden group hover:shadow-md transition-shadow"
>
{/* Header: Name + Type Badge */}
<div className="flex justify-between items-start mb-4">
{/* Color-coded Header */}
<div className={`px-4 py-3 ${style.bg} ${style.border} border-b flex justify-between items-center`}>
<div>
<h3 className="font-medium text-primary text-sm">
<h3 className={`font-medium text-sm ${style.text}`}>
{room.name?.replace('[DEMO] ', '')}
</h3>
<span className="text-xs text-tertiary">{room.sqft?.toLocaleString()} sqft {room.capacity || '—'} cap</span>
<span className="text-[10px] text-tertiary">{room.sqft?.toLocaleString()} sqft {room.capacity || '—'} cap</span>
</div>
<span className={getBadgeClass(room.type)}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold ${style.text}`}>
{room.type}
</span>
<ChevronRight size={14} className="text-tertiary opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
{/* Primary: Sensor Data - Large & Prominent */}
<div className="flex items-center justify-around py-4 border-y border-subtle mb-4">
{/* Sensor Data - Prominent */}
<div className="p-4">
<div className="flex items-center justify-around py-3 mb-3">
<div className="text-center">
<div className="flex items-center justify-center gap-1 text-2xl font-semibold text-primary">
<Thermometer size={18} className="text-red-500" />
@ -122,20 +121,20 @@ export default function RoomsPage() {
</div>
</div>
{/* Secondary: Batch Count */}
<div className="flex items-center justify-between">
{/* Batch Count */}
<div className="flex items-center justify-between pt-3 border-t border-subtle">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${room.batches?.length > 0 ? 'bg-success' : 'bg-subtle'}`} />
<span className="text-sm font-medium text-primary">
{room.batches?.length || 0} {room.batches?.length === 1 ? 'batch' : 'batches'}
</span>
</div>
{room.batches?.length > 0 && (
<span className="text-xs text-accent">View </span>
)}
<span className="text-xs text-accent opacity-0 group-hover:opacity-100 transition-opacity">View </span>
</div>
</div>
))}
</Link>
);
})}
</div>
)}
</div>

View file

@ -11,6 +11,7 @@ import DashboardPage from './pages/DashboardPage';
// Lazy load all other pages to reduce initial bundle
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
const RoomDetailPage = lazy(() => import('./pages/RoomDetailPage'));
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
const BatchDetailPage = lazy(() => import('./pages/BatchDetailPage'));
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
@ -107,6 +108,10 @@ export const router = createBrowserRouter([
path: 'rooms',
element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
},
{
path: 'rooms/:id',
element: <Suspense fallback={<PageLoader />}><RoomDetailPage /></Suspense>,
},
{
path: 'batches',
element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,