From eb5ebc610f4828dee2882ffebdd7d28aa6c89816 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:33:07 -0800 Subject: [PATCH] feat: room cards with color-coded headers + room detail page - 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 --- backend/src/controllers/rooms.controller.ts | 20 ++ backend/src/routes/rooms.routes.ts | 3 +- frontend/src/pages/RoomDetailPage.tsx | 245 ++++++++++++++++++++ frontend/src/pages/RoomsPage.tsx | 137 ++++++----- frontend/src/router.tsx | 5 + 5 files changed, 340 insertions(+), 70 deletions(-) create mode 100644 frontend/src/pages/RoomDetailPage.tsx diff --git a/backend/src/controllers/rooms.controller.ts b/backend/src/controllers/rooms.controller.ts index ca7cf61..79adf6f 100644 --- a/backend/src/controllers/rooms.controller.ts +++ b/backend/src/controllers/rooms.controller.ts @@ -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, diff --git a/backend/src/routes/rooms.routes.ts b/backend/src/routes/rooms.routes.ts index 5afe069..e85069e 100644 --- a/backend/src/routes/rooms.routes.ts +++ b/backend/src/routes/rooms.routes.ts @@ -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); } diff --git a/frontend/src/pages/RoomDetailPage.tsx b/frontend/src/pages/RoomDetailPage.tsx new file mode 100644 index 0000000..f1d639b --- /dev/null +++ b/frontend/src/pages/RoomDetailPage.tsx @@ -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 ( + + + + ); +} + +export default function RoomDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [room, setRoom] = useState(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 ( +
+
+
+ ); + } + + if (!room) { + return ( +
+

Room not found

+ ← Back to rooms +
+ ); + } + + // Header color based on type + const typeColors: Record = { + 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 ( +
+ {/* Header with gradient */} +
+
+ +
+
+

{room.name?.replace('[DEMO] ', '')}

+ {room.type} +
+
+ {room.sqft?.toLocaleString()} sqft + + {room.capacity || '—'} plant capacity +
+
+ +
+
+ + {/* Current Conditions */} +
+

Current Conditions

+
+ + + + +
+
+ + {/* Active Batches */} +
+
+

Active Batches

+ {room.batches?.length || 0} batches +
+ {room.batches?.length > 0 ? ( +
+ {room.batches.map((batch: any) => ( + +
+
+ +
+
+

{batch.name}

+

{batch.strain} • {batch.plantCount} plants

+
+
+
+ {batch.stage} + + + Day {Math.floor((Date.now() - new Date(batch.startDate).getTime()) / 86400000)} + +
+ + ))} +
+ ) : ( +
+ +

No active batches in this room

+
+ )} +
+ + {/* Room Settings */} +
+

Target Settings

+
+
+ Target Temp +

{room.targetTemp || '—'}°F

+
+
+ Target Humidity +

{room.targetHumidity || '—'}%

+
+
+ Light Schedule +

{room.lightSchedule || '18/6'}

+
+
+ Floor +

{room.floor || 'Main'}

+
+
+
+
+ ); +} + +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 ( +
+
+
+ + {label} +
+ +
+
+ {value} + {unit} +
+
+ ); +} diff --git a/frontend/src/pages/RoomsPage.tsx b/frontend/src/pages/RoomsPage.tsx index 5eca520..28a5738 100644 --- a/frontend/src/pages/RoomsPage.tsx +++ b/frontend/src/pages/RoomsPage.tsx @@ -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 = { - 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 = { + 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,59 +75,66 @@ export default function RoomsPage() { /> ) : (
- {rooms.map(room => ( -
- {/* Header: Name + Type Badge */} -
-
-

- {room.name?.replace('[DEMO] ', '')} -

- {room.sqft?.toLocaleString()} sqft • {room.capacity || '—'} cap -
- - {room.type} - -
- - {/* Primary: Sensor Data - Large & Prominent */} -
-
-
- - {room.targetTemp || '—'} - °F + {rooms.map(room => { + const style = getHeaderStyle(room.type); + return ( + + {/* Color-coded Header */} +
+
+

+ {room.name?.replace('[DEMO] ', '')} +

+ {room.sqft?.toLocaleString()} sqft • {room.capacity || '—'} cap
- Temp -
-
-
-
- - {room.targetHumidity || '—'} - % +
+ + {room.type} + +
- Humidity
-
- {/* Secondary: Batch Count */} -
-
-
0 ? 'bg-success' : 'bg-subtle'}`} /> - - {room.batches?.length || 0} {room.batches?.length === 1 ? 'batch' : 'batches'} - + {/* Sensor Data - Prominent */} +
+
+
+
+ + {room.targetTemp || '—'} + °F +
+ Temp +
+
+
+
+ + {room.targetHumidity || '—'} + % +
+ Humidity +
+
+ + {/* Batch Count */} +
+
+
0 ? 'bg-success' : 'bg-subtle'}`} /> + + {room.batches?.length || 0} {room.batches?.length === 1 ? 'batch' : 'batches'} + +
+ View → +
- {room.batches?.length > 0 && ( - View → - )} -
-
- ))} + + ); + })}
)}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c0a5f4a..fc893e9 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: }>, }, + { + path: 'rooms/:id', + element: }>, + }, { path: 'batches', element: }>,