diff --git a/frontend/src/pages/BatchDetailPage.tsx b/frontend/src/pages/BatchDetailPage.tsx new file mode 100644 index 0000000..46b0921 --- /dev/null +++ b/frontend/src/pages/BatchDetailPage.tsx @@ -0,0 +1,419 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { + ArrowLeft, Calendar, Droplets, Thermometer, Wind, Zap, + Bug, Camera, Activity, TrendingUp, Clock, CheckCircle, + AlertTriangle, Leaf, Sun, Moon +} from 'lucide-react'; +import { batchesApi, Batch } from '../lib/batchesApi'; + +// Mock sensor data generator +function generateMockData(days: number, min: number, max: number, variance: number = 5) { + return Array.from({ length: days }, (_, i) => ({ + day: i + 1, + value: min + Math.random() * (max - min) + (Math.random() - 0.5) * variance, + timestamp: new Date(Date.now() - (days - i) * 24 * 60 * 60 * 1000).toISOString() + })); +} + +// SVG Sparkline Component +function Sparkline({ data, color = '#3B82F6', height = 40 }: { + 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 = 120; + const padding = 2; + + const points = data.map((d, i) => { + const x = padding + (i / (data.length - 1)) * (width - padding * 2); + const y = height - padding - ((d.value - min) / range) * (height - padding * 2); + return `${x},${y}`; + }).join(' '); + + return ( + + + {/* Current value dot */} + {data.length > 0 && ( + + )} + + ); +} + +// Stage Progress Component +function StageProgress({ currentStage }: { currentStage: string }) { + const stages = [ + { id: 'CLONE_IN', label: 'Clone', icon: '🌱', days: 7 }, + { id: 'VEGETATIVE', label: 'Veg', icon: '🌿', days: 28 }, + { id: 'FLOWERING', label: 'Flower', icon: '🌸', days: 63 }, + { id: 'HARVEST', label: 'Harvest', icon: '✂️', days: 1 }, + { id: 'DRYING', label: 'Dry', icon: '🍂', days: 14 }, + { id: 'CURING', label: 'Cure', icon: '🫙', days: 30 }, + ]; + + const currentIndex = stages.findIndex(s => s.id === currentStage); + + return ( +
+ {stages.map((stage, i) => { + const isPast = i < currentIndex; + const isCurrent = i === currentIndex; + const isFuture = i > currentIndex; + + return ( +
+
+ {stage.icon} + {stage.label} +
+ {i < stages.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +// Metric Card with Sparkline +function MetricCard({ icon: Icon, label, value, unit, trend, data, color }: { + icon: typeof Thermometer; + label: string; + value: string; + unit: string; + trend?: 'up' | 'down' | 'stable'; + data: { value: number }[]; + color: string; +}) { + return ( +
+
+
+
+ +
+ {label} +
+ {trend && ( + + {trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} + + )} +
+
+
+ {value} + {unit} +
+ +
+
+ ); +} + +// Touch Point Item +function TouchPoint({ type, date, user, notes }: { + type: string; + date: string; + user: string; + notes?: string; +}) { + const typeConfig: Record = { + WATER: { icon: Droplets, color: 'text-blue-500' }, + FEED: { icon: Leaf, color: 'text-green-500' }, + INSPECT: { icon: Bug, color: 'text-amber-500' }, + PHOTO: { icon: Camera, color: 'text-purple-500' }, + DEFOLIATE: { icon: Leaf, color: 'text-orange-500' }, + TRANSPLANT: { icon: Activity, color: 'text-teal-500' }, + }; + + const config = typeConfig[type] || { icon: Activity, color: 'text-secondary' }; + const Icon = config.icon; + + return ( +
+
+ +
+
+
+ {type} + {new Date(date).toLocaleDateString()} +
+ {notes &&

{notes}

} + {user} +
+
+ ); +} + +export default function BatchDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [batch, setBatch] = useState(null); + const [loading, setLoading] = useState(true); + + // Mock sensor data + const [sensorData] = useState(() => ({ + temperature: generateMockData(14, 72, 78), + humidity: generateMockData(14, 50, 65), + vpd: generateMockData(14, 0.8, 1.2), + co2: generateMockData(14, 800, 1200), + lightPPFD: generateMockData(14, 600, 900), + })); + + // Mock touch points + const [touchPoints] = useState(() => [ + { type: 'INSPECT', date: new Date().toISOString(), user: 'Alex', notes: 'Looking healthy' }, + { type: 'WATER', date: new Date(Date.now() - 86400000).toISOString(), user: 'Jordan' }, + { type: 'FEED', date: new Date(Date.now() - 172800000).toISOString(), user: 'Alex', notes: 'Week 3 flower nutrients' }, + { type: 'PHOTO', date: new Date(Date.now() - 259200000).toISOString(), user: 'Sam', notes: 'Progress photo' }, + { type: 'INSPECT', date: new Date(Date.now() - 345600000).toISOString(), user: 'Jordan', notes: 'No issues' }, + ]); + + useEffect(() => { + if (id) { + batchesApi.getById(id) + .then(setBatch) + .catch(() => navigate('/batches')) + .finally(() => setLoading(false)); + } + }, [id, navigate]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!batch) { + return ( +
+

Batch not found

+ ← Back to batches +
+ ); + } + + // Calculate days in stage + const startDate = new Date(batch.startDate); + const daysInCycle = Math.floor((Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + return ( +
+ {/* Header */} +
+ +
+
+

{batch.name}

+ {batch.stage} +
+
+ {batch.strain} + + {batch.plantCount} plants + + + + Day {daysInCycle} + +
+
+
+ + {/* Stage Journey */} +
+

Lifecycle

+ +
+ + {/* Sensor Grid */} +
+

Environment (14 days)

+
+ + + + + +
+
+ + {/* Two Column Layout */} +
+ {/* Touch Points */} +
+
+

Recent Activity

+ {touchPoints.length} entries +
+
+ {touchPoints.map((tp, i) => ( + + ))} +
+
+ + {/* Stats & IPM */} +
+ {/* Quick Stats */} +
+

Statistics

+
+
+
{daysInCycle}
+
Days
+
+
+
{touchPoints.length}
+
Touch Points
+
+
+
0
+
Issues
+
+
+
+ + {/* IPM Schedule */} + {batch.ipmSchedule ? ( +
+

IPM Schedule

+
+
+ +
+
+

{batch.ipmSchedule.product || 'Preventative'}

+

+ Next: {new Date(batch.ipmSchedule.nextTreatment).toLocaleDateString()} +

+
+
+
+ ) : ( +
+

IPM Schedule

+

No IPM schedule configured

+
+ )} + + {/* Health Score */} +
+

Health Score

+
+
+ + + + +
+ 92 +
+
+
+

Excellent

+

Based on 14 day average

+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx index 7c4addf..461ca09 100644 --- a/frontend/src/pages/BatchesPage.tsx +++ b/frontend/src/pages/BatchesPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; -import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar } from 'lucide-react'; import { Batch, batchesApi } from '../lib/batchesApi'; import { useToast } from '../context/ToastContext'; import BatchTransitionModal from '../components/BatchTransitionModal'; @@ -11,6 +12,16 @@ import { PullToRefresh } from '../components/ui/PullToRefresh'; import { QuickLogBar } from '../components/touchpoints/QuickLogButtons'; import { PageHeader, SectionHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives'; +const STAGE_CONFIG = { + CLONE_IN: { label: 'Clone', icon: Sprout, color: 'bg-blue-500', order: 0 }, + VEGETATIVE: { label: 'Veg', icon: Leaf, color: 'bg-green-500', order: 1 }, + FLOWERING: { label: 'Flower', icon: Flower, color: 'bg-purple-500', order: 2 }, + HARVEST: { label: 'Harvest', icon: Archive, color: 'bg-amber-500', order: 3 }, + DRYING: { label: 'Dry', icon: Archive, color: 'bg-orange-500', order: 4 }, + CURING: { label: 'Cure', icon: Archive, color: 'bg-stone-500', order: 5 }, + FINISHED: { label: 'Done', icon: Archive, color: 'bg-neutral-400', order: 6 }, +}; + const STAGE_GROUPS = [ { id: 'CLONE_IN', label: 'Clones', icon: Sprout, accent: 'accent' as const }, { id: 'VEGETATIVE', label: 'Vegetative', icon: Leaf, accent: 'success' as const }, @@ -20,6 +31,37 @@ const STAGE_GROUPS = [ { id: 'FINISHED', label: 'Finished', icon: Archive, accent: 'default' as const }, ]; +// Stage Progress Mini Component +function StageProgressMini({ currentStage }: { currentStage: string }) { + const stages = ['CLONE_IN', 'VEGETATIVE', 'FLOWERING', 'HARVEST', 'DRYING', 'CURING']; + const currentIndex = stages.indexOf(currentStage); + + return ( +
+ {stages.map((stage, i) => ( +
+ ))} +
+ ); +} + +// Days Badge +function DaysBadge({ startDate }: { startDate: string }) { + const days = Math.floor((Date.now() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)); + return ( + + + Day {days} + + ); +} + export default function BatchesPage() { const { addToast } = useToast(); const [batches, setBatches] = useState([]); @@ -71,9 +113,9 @@ export default function BatchesPage() { } /> -
+
{loading ? ( -
+
{Array.from({ length: 6 }).map((_, i) => )}
) : groupedBatches.length === 0 ? ( @@ -100,22 +142,41 @@ export default function BatchesPage() {
{group.items.map(batch => ( -
-
-
-

- {batch.name} -

- {batch.strain} +
+ {/* Clickable Header */} + +
+
+

+ {batch.name} +

+
+ {batch.strain} + + {batch.plantCount} plants +
+
+
-
+ + {/* Progress & Days */} +
+ + +
+ + + {/* Quick Actions */} +
+ {batch.room?.name || 'No Room'} +
setCreateTaskBatch(batch)} + icon={Search} + label="Scout" + onClick={() => setScoutingBatch(batch)} variant="accent" /> setIpmBatch(batch)} variant="destructive" /> - setScoutingBatch(batch)} - variant="accent" - /> - {['HARVEST', 'DRYING', 'CURING', 'FINISHED'].includes(batch.stage) && ( - setWeightLogBatch(batch)} - variant="accent" - /> - )}
- -
-
- Room - - {batch.room?.name || 'Unassigned'} - -
-
- Plants - - {batch.plantCount} - -
-
- -
- fetchBatches()} - compact - /> -
))}
@@ -178,6 +201,7 @@ export default function BatchesPage() { )}
+ {/* Modals */} {selectedBatch && ( )} - {weightLogBatch && ( setWeightLogBatch(null)} + onSuccess={() => fetchBatches()} + /> + )} + {createTaskBatch && ( + setCreateTaskBatch(null)} onSuccess={() => { - fetchBatches(); - setWeightLogBatch(null); + setCreateTaskBatch(null); + addToast('Task created!', 'success'); }} /> )} - - {createTaskBatch && ( - setCreateTaskBatch(null)} - onSuccess={() => setCreateTaskBatch(null)} - /> - )} - {ipmBatch && ( setIpmBatch(null)} /> )} - {scoutingBatch && ( setScoutingBatch(null)} - onSuccess={() => { }} + onSuccess={() => { + fetchBatches(); + addToast('Scouting report logged', 'success'); + }} /> )}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 542f1f2..c0a5f4a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -12,6 +12,7 @@ import DashboardPage from './pages/DashboardPage'; const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage')); const RoomsPage = lazy(() => import('./pages/RoomsPage')); const BatchesPage = lazy(() => import('./pages/BatchesPage')); +const BatchDetailPage = lazy(() => import('./pages/BatchDetailPage')); const TimeclockPage = lazy(() => import('./pages/TimeclockPage')); const SuppliesPage = lazy(() => import('./pages/SuppliesPage')); const TasksPage = lazy(() => import('./pages/TasksPage')); @@ -110,6 +111,10 @@ export const router = createBrowserRouter([ path: 'batches', element: }>, }, + { + path: 'batches/:id', + element: }>, + }, { path: 'timeclock', element: }>,