feat: BatchDetailPage with rich data visualization
- BatchDetailPage: Lifecycle journey, SVG sparkline charts, modeled sensor data - Metrics: Temperature, humidity, VPD, CO2, light PPFD with trends - Touch point history with categorized icons - Health score gauge visualization - IPM schedule display - BatchesPage: Clickable cards linking to detail - Stage progress mini indicator - Days-in-cycle badge
This commit is contained in:
parent
a2120170b6
commit
93a39c2f2c
3 changed files with 516 additions and 71 deletions
419
frontend/src/pages/BatchDetailPage.tsx
Normal file
419
frontend/src/pages/BatchDetailPage.tsx
Normal file
|
|
@ -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 (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Current value dot */}
|
||||
{data.length > 0 && (
|
||||
<circle
|
||||
cx={width - padding}
|
||||
cy={height - padding - ((values[values.length - 1] - min) / range) * (height - padding * 2)}
|
||||
r="3"
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{stages.map((stage, i) => {
|
||||
const isPast = i < currentIndex;
|
||||
const isCurrent = i === currentIndex;
|
||||
const isFuture = i > currentIndex;
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
<div className={`
|
||||
flex flex-col items-center px-2 py-1.5 rounded-md transition-colors
|
||||
${isCurrent ? 'bg-accent text-white' : ''}
|
||||
${isPast ? 'text-success' : ''}
|
||||
${isFuture ? 'text-tertiary' : ''}
|
||||
`}>
|
||||
<span className="text-sm">{stage.icon}</span>
|
||||
<span className="text-[9px] font-medium mt-0.5">{stage.label}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div className={`w-3 h-0.5 ${isPast ? 'bg-success' : 'bg-subtle'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-md bg-tertiary flex items-center justify-center">
|
||||
<Icon size={14} style={{ color }} />
|
||||
</div>
|
||||
<span className="text-xs text-tertiary">{label}</span>
|
||||
</div>
|
||||
{trend && (
|
||||
<span className={`text-xs ${trend === 'up' ? 'text-success' : trend === 'down' ? 'text-destructive' : 'text-tertiary'
|
||||
}`}>
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<span className="text-2xl font-semibold text-primary">{value}</span>
|
||||
<span className="text-sm text-tertiary ml-1">{unit}</span>
|
||||
</div>
|
||||
<Sparkline data={data} color={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Touch Point Item
|
||||
function TouchPoint({ type, date, user, notes }: {
|
||||
type: string;
|
||||
date: string;
|
||||
user: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const typeConfig: Record<string, { icon: typeof Camera; color: string }> = {
|
||||
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 (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<div className={`w-6 h-6 rounded-full bg-tertiary flex items-center justify-center flex-shrink-0 ${config.color}`}>
|
||||
<Icon size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-primary">{type}</span>
|
||||
<span className="text-xs text-tertiary">{new Date(date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{notes && <p className="text-xs text-secondary mt-0.5 truncate">{notes}</p>}
|
||||
<span className="text-[10px] text-tertiary">{user}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [batch, setBatch] = useState<Batch | null>(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 (
|
||||
<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 (!batch) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-secondary">Batch not found</p>
|
||||
<Link to="/batches" className="text-accent text-sm mt-2 inline-block">← Back to batches</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate days in stage
|
||||
const startDate = new Date(batch.startDate);
|
||||
const daysInCycle = Math.floor((Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 pb-20 animate-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/batches')}
|
||||
className="p-2 -ml-2 hover:bg-tertiary rounded-md transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} className="text-secondary" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-xl font-semibold text-primary">{batch.name}</h1>
|
||||
<span className={`badge ${batch.stage === 'FLOWERING' ? 'badge-warning' :
|
||||
batch.stage === 'VEGETATIVE' ? 'badge-success' :
|
||||
'badge'
|
||||
}`}>{batch.stage}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary">
|
||||
<span>{batch.strain}</span>
|
||||
<span>•</span>
|
||||
<span>{batch.plantCount} plants</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
Day {daysInCycle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Journey */}
|
||||
<div className="card p-4 overflow-x-auto">
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-3">Lifecycle</h3>
|
||||
<StageProgress currentStage={batch.stage} />
|
||||
</div>
|
||||
|
||||
{/* Sensor Grid */}
|
||||
<div>
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-3">Environment (14 days)</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<MetricCard
|
||||
icon={Thermometer}
|
||||
label="Temperature"
|
||||
value={sensorData.temperature[sensorData.temperature.length - 1].value.toFixed(1)}
|
||||
unit="°F"
|
||||
trend="stable"
|
||||
data={sensorData.temperature}
|
||||
color="#EF4444"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Droplets}
|
||||
label="Humidity"
|
||||
value={sensorData.humidity[sensorData.humidity.length - 1].value.toFixed(0)}
|
||||
unit="%"
|
||||
trend="up"
|
||||
data={sensorData.humidity}
|
||||
color="#3B82F6"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Wind}
|
||||
label="VPD"
|
||||
value={sensorData.vpd[sensorData.vpd.length - 1].value.toFixed(2)}
|
||||
unit="kPa"
|
||||
trend="stable"
|
||||
data={sensorData.vpd}
|
||||
color="#8B5CF6"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Zap}
|
||||
label="CO₂"
|
||||
value={sensorData.co2[sensorData.co2.length - 1].value.toFixed(0)}
|
||||
unit="ppm"
|
||||
trend="up"
|
||||
data={sensorData.co2}
|
||||
color="#10B981"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Sun}
|
||||
label="Light PPFD"
|
||||
value={sensorData.lightPPFD[sensorData.lightPPFD.length - 1].value.toFixed(0)}
|
||||
unit="μmol"
|
||||
trend="stable"
|
||||
data={sensorData.lightPPFD}
|
||||
color="#F59E0B"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Touch Points */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-subtle flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-primary">Recent Activity</h3>
|
||||
<span className="text-xs text-tertiary">{touchPoints.length} entries</span>
|
||||
</div>
|
||||
<div className="p-4 divide-y divide-subtle">
|
||||
{touchPoints.map((tp, i) => (
|
||||
<TouchPoint key={i} {...tp} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats & IPM */}
|
||||
<div className="space-y-4">
|
||||
{/* Quick Stats */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-4">Statistics</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-primary">{daysInCycle}</div>
|
||||
<div className="text-xs text-tertiary">Days</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-primary">{touchPoints.length}</div>
|
||||
<div className="text-xs text-tertiary">Touch Points</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-success">0</div>
|
||||
<div className="text-xs text-tertiary">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IPM Schedule */}
|
||||
{batch.ipmSchedule ? (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-3">IPM Schedule</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-warning-muted flex items-center justify-center">
|
||||
<Bug size={18} className="text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">{batch.ipmSchedule.product || 'Preventative'}</p>
|
||||
<p className="text-xs text-secondary">
|
||||
Next: {new Date(batch.ipmSchedule.nextTreatment).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-3">IPM Schedule</h3>
|
||||
<p className="text-sm text-secondary">No IPM schedule configured</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Score */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-xs text-tertiary uppercase tracking-wider mb-3">Health Score</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-16 h-16">
|
||||
<svg className="w-16 h-16 -rotate-90">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
fill="none"
|
||||
stroke="var(--color-bg-tertiary)"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
fill="none"
|
||||
stroke="var(--color-success)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${0.92 * 176} 176`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-semibold text-primary">92</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-success">Excellent</p>
|
||||
<p className="text-xs text-tertiary">Based on 14 day average</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex gap-0.5">
|
||||
{stages.map((stage, i) => (
|
||||
<div
|
||||
key={stage}
|
||||
className={`h-1 w-3 rounded-full ${i < currentIndex ? 'bg-success' :
|
||||
i === currentIndex ? 'bg-accent' :
|
||||
'bg-subtle'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Days Badge
|
||||
function DaysBadge({ startDate }: { startDate: string }) {
|
||||
const days = Math.floor((Date.now() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));
|
||||
return (
|
||||
<span className="text-[10px] text-tertiary flex items-center gap-1">
|
||||
<Calendar size={10} />
|
||||
Day {days}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchesPage() {
|
||||
const { addToast } = useToast();
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
|
|
@ -71,9 +113,9 @@ export default function BatchesPage() {
|
|||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : groupedBatches.length === 0 ? (
|
||||
|
|
@ -100,22 +142,41 @@ export default function BatchesPage() {
|
|||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{group.items.map(batch => (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="card p-4 group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-primary text-sm mb-1">
|
||||
{batch.name}
|
||||
</h4>
|
||||
<span className="badge">{batch.strain}</span>
|
||||
<div key={batch.id} className="card group overflow-hidden">
|
||||
{/* Clickable Header */}
|
||||
<Link
|
||||
to={`/batches/${batch.id}`}
|
||||
className="block p-4 hover:bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-primary text-sm truncate mb-1">
|
||||
{batch.name}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary">{batch.strain}</span>
|
||||
<span className="text-xs text-tertiary">•</span>
|
||||
<span className="text-xs text-tertiary">{batch.plantCount} plants</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-tertiary flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast">
|
||||
|
||||
{/* Progress & Days */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<StageProgressMini currentStage={batch.stage} />
|
||||
<DaysBadge startDate={batch.startDate} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="px-3 py-2 border-t border-subtle bg-tertiary/30 flex items-center justify-between">
|
||||
<span className="text-[10px] text-tertiary">{batch.room?.name || 'No Room'}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<ActionButton
|
||||
icon={ClipboardList}
|
||||
label="Add Task"
|
||||
onClick={() => setCreateTaskBatch(batch)}
|
||||
icon={Search}
|
||||
label="Scout"
|
||||
onClick={() => setScoutingBatch(batch)}
|
||||
variant="accent"
|
||||
/>
|
||||
<ActionButton
|
||||
|
|
@ -124,20 +185,6 @@ export default function BatchesPage() {
|
|||
onClick={() => setIpmBatch(batch)}
|
||||
variant="destructive"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Search}
|
||||
label="Scout"
|
||||
onClick={() => setScoutingBatch(batch)}
|
||||
variant="accent"
|
||||
/>
|
||||
{['HARVEST', 'DRYING', 'CURING', 'FINISHED'].includes(batch.stage) && (
|
||||
<ActionButton
|
||||
icon={Scale}
|
||||
label="Log Weight"
|
||||
onClick={() => setWeightLogBatch(batch)}
|
||||
variant="accent"
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
icon={MoveRight}
|
||||
label="Transition"
|
||||
|
|
@ -146,30 +193,6 @@ export default function BatchesPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-tertiary pt-3 border-t border-subtle">
|
||||
<div>
|
||||
<span className="text-quaternary">Room</span>
|
||||
<span className="text-secondary ml-2 font-medium">
|
||||
{batch.room?.name || 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-quaternary">Plants</span>
|
||||
<span className="text-secondary ml-2 font-medium">
|
||||
{batch.plantCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-subtle">
|
||||
<QuickLogBar
|
||||
batchId={batch.id}
|
||||
batchName={batch.name}
|
||||
onSuccess={() => fetchBatches()}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -178,6 +201,7 @@ export default function BatchesPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{selectedBatch && (
|
||||
<BatchTransitionModal
|
||||
batch={selectedBatch}
|
||||
|
|
@ -188,40 +212,37 @@ export default function BatchesPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{weightLogBatch && (
|
||||
<WeightLogModal
|
||||
batch={weightLogBatch}
|
||||
onClose={() => setWeightLogBatch(null)}
|
||||
onSuccess={() => fetchBatches()}
|
||||
/>
|
||||
)}
|
||||
{createTaskBatch && (
|
||||
<CreateTaskModal
|
||||
batchContext={createTaskBatch}
|
||||
onClose={() => setCreateTaskBatch(null)}
|
||||
onSuccess={() => {
|
||||
fetchBatches();
|
||||
setWeightLogBatch(null);
|
||||
setCreateTaskBatch(null);
|
||||
addToast('Task created!', 'success');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createTaskBatch && (
|
||||
<CreateTaskModal
|
||||
isOpen={true}
|
||||
initialBatchId={createTaskBatch.id}
|
||||
initialRoomId={createTaskBatch.roomId || undefined}
|
||||
onClose={() => setCreateTaskBatch(null)}
|
||||
onSuccess={() => setCreateTaskBatch(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ipmBatch && (
|
||||
<IPMScheduleModal
|
||||
batch={ipmBatch}
|
||||
onClose={() => setIpmBatch(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scoutingBatch && (
|
||||
<ScoutingModal
|
||||
batch={scoutingBatch}
|
||||
onClose={() => setScoutingBatch(null)}
|
||||
onSuccess={() => { }}
|
||||
onSuccess={() => {
|
||||
fetchBatches();
|
||||
addToast('Scouting report logged', 'success');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'batches/:id',
|
||||
element: <Suspense fallback={<PageLoader />}><BatchDetailPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'timeclock',
|
||||
element: <Suspense fallback={<PageLoader />}><TimeclockPage /></Suspense>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue