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
This commit is contained in:
parent
3239a8b89b
commit
eb5ebc610f
5 changed files with 340 additions and 70 deletions
|
|
@ -13,6 +13,26 @@ export const getRooms = async (request: FastifyRequest, reply: FastifyReply) =>
|
||||||
return rooms;
|
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) => {
|
export const createRoom = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { FastifyInstance } from 'fastify';
|
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) {
|
export async function roomRoutes(server: FastifyInstance) {
|
||||||
server.addHook('onRequest', async (request) => {
|
server.addHook('onRequest', async (request) => {
|
||||||
|
|
@ -14,5 +14,6 @@ export async function roomRoutes(server: FastifyInstance) {
|
||||||
});
|
});
|
||||||
|
|
||||||
server.get('/', getRooms);
|
server.get('/', getRooms);
|
||||||
|
server.get('/:id', getRoomById);
|
||||||
server.post('/', createRoom);
|
server.post('/', createRoom);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
245
frontend/src/pages/RoomDetailPage.tsx
Normal file
245
frontend/src/pages/RoomDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react';
|
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 api from '../lib/api';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||||
|
|
@ -25,27 +26,18 @@ export default function RoomsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoomTypeAccent = (type: string): 'default' | 'accent' | 'success' | 'warning' => {
|
// Header background colors per room type
|
||||||
const accents: Record<string, 'default' | 'accent' | 'success' | 'warning'> = {
|
const getHeaderStyle = (type: string) => {
|
||||||
VEG: 'success',
|
const styles: Record<string, { bg: string; text: string; border: string }> = {
|
||||||
FLOWER: 'accent',
|
VEG: { bg: 'bg-green-500/10', text: 'text-green-600', border: 'border-green-500/20' },
|
||||||
DRY: 'warning',
|
FLOWER: { bg: 'bg-purple-500/10', text: 'text-purple-600', border: 'border-purple-500/20' },
|
||||||
CURE: 'warning',
|
DRY: { bg: 'bg-amber-500/10', text: 'text-amber-600', border: 'border-amber-500/20' },
|
||||||
MOTHER: 'accent',
|
CURE: { bg: 'bg-orange-500/10', text: 'text-orange-600', border: 'border-orange-500/20' },
|
||||||
TRIM: 'default',
|
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';
|
return styles[type] || { bg: 'bg-tertiary', text: 'text-secondary', border: 'border-subtle' };
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{rooms.map(room => (
|
{rooms.map(room => {
|
||||||
<div
|
const style = getHeaderStyle(room.type);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
key={room.id}
|
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 */}
|
{/* Color-coded Header */}
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className={`px-4 py-3 ${style.bg} ${style.border} border-b flex justify-between items-center`}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-primary text-sm">
|
<h3 className={`font-medium text-sm ${style.text}`}>
|
||||||
{room.name?.replace('[DEMO] ', '')}
|
{room.name?.replace('[DEMO] ', '')}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<span className={getBadgeClass(room.type)}>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs font-semibold ${style.text}`}>
|
||||||
{room.type}
|
{room.type}
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronRight size={14} className="text-tertiary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary: Sensor Data - Large & Prominent */}
|
{/* Sensor Data - Prominent */}
|
||||||
<div className="flex items-center justify-around py-4 border-y border-subtle mb-4">
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-around py-3 mb-3">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1 text-2xl font-semibold text-primary">
|
<div className="flex items-center justify-center gap-1 text-2xl font-semibold text-primary">
|
||||||
<Thermometer size={18} className="text-red-500" />
|
<Thermometer size={18} className="text-red-500" />
|
||||||
|
|
@ -122,20 +121,20 @@ export default function RoomsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary: Batch Count */}
|
{/* Batch Count */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between pt-3 border-t border-subtle">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${room.batches?.length > 0 ? 'bg-success' : 'bg-subtle'}`} />
|
<div className={`w-2 h-2 rounded-full ${room.batches?.length > 0 ? 'bg-success' : 'bg-subtle'}`} />
|
||||||
<span className="text-sm font-medium text-primary">
|
<span className="text-sm font-medium text-primary">
|
||||||
{room.batches?.length || 0} {room.batches?.length === 1 ? 'batch' : 'batches'}
|
{room.batches?.length || 0} {room.batches?.length === 1 ? 'batch' : 'batches'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{room.batches?.length > 0 && (
|
<span className="text-xs text-accent opacity-0 group-hover:opacity-100 transition-opacity">View →</span>
|
||||||
<span className="text-xs text-accent">View →</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import DashboardPage from './pages/DashboardPage';
|
||||||
// Lazy load all other pages to reduce initial bundle
|
// Lazy load all other pages to reduce initial bundle
|
||||||
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
|
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
|
||||||
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
|
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
|
||||||
|
const RoomDetailPage = lazy(() => import('./pages/RoomDetailPage'));
|
||||||
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
|
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
|
||||||
const BatchDetailPage = lazy(() => import('./pages/BatchDetailPage'));
|
const BatchDetailPage = lazy(() => import('./pages/BatchDetailPage'));
|
||||||
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
|
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
|
||||||
|
|
@ -107,6 +108,10 @@ export const router = createBrowserRouter([
|
||||||
path: 'rooms',
|
path: 'rooms',
|
||||||
element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
|
element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rooms/:id',
|
||||||
|
element: <Suspense fallback={<PageLoader />}><RoomDetailPage /></Suspense>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'batches',
|
path: 'batches',
|
||||||
element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
|
element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue