ca-grow-ops-manager/frontend/src/pages/BatchesPage.tsx
fullsizemalt eca58ecbc2 feat: Wire action buttons and add CreateBatchModal
- Wire 'Add Zone' button on RoomsPage to open wizard
- Create CreateBatchModal for batch creation form
- Wire 'New Batch' button on BatchesPage to open modal
- Modal includes strain, plant count, source, stage, room, METRC tags
2026-01-12 23:54:32 -08:00

322 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Bug, Search, Cloud, CloudOff, BarChart3, ShieldCheck, Activity } from 'lucide-react';
import { Batch, batchesApi } from '../lib/batchesApi';
import { useToast } from '../context/ToastContext';
import BatchTransitionModal from '../components/BatchTransitionModal';
import WeightLogModal from '../components/WeightLogModal';
import CreateTaskModal from '../components/tasks/CreateTaskModal';
import CreateBatchModal from '../components/CreateBatchModal';
import IPMScheduleModal from '../components/IPMScheduleModal';
import ScoutingModal from '../components/ipm/ScoutingModal';
import { DataTable, Column } from '../components/ui/DataTable';
import { Card } from '../components/ui/card';
import { cn } from '../lib/utils';
// --- Stage Badge with Industrial Styling ---
function StageBadge({ stage }: { stage: string }) {
const config: Record<string, { label: string, color: string, icon?: any }> = {
CLONE_IN: { label: 'Clone', color: 'bg-[var(--color-accent)]/10 text-[var(--color-accent)] border-blue-500/20', icon: Sprout },
VEGETATIVE: { label: 'Veg', color: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] border-emerald-500/20', icon: Leaf },
FLOWERING: { label: 'Flower', color: 'bg-purple-500/10 text-purple-500 border-purple-500/20', icon: Flower },
HARVEST: { label: 'Harvest', color: 'bg-[var(--color-warning)]/10 text-[var(--color-warning)] border-amber-500/20', icon: Archive },
DRYING: { label: 'Drying', color: 'bg-orange-500/10 text-orange-500 border-orange-500/20', icon: Archive },
CURING: { label: 'Curing', color: 'bg-stone-500/10 text-stone-500 border-stone-500/20', icon: Archive },
FINISHED: { label: 'Done', color: 'bg-slate-500/10 text-[var(--color-text-tertiary)] border-slate-500/20', icon: Archive },
};
const info = config[stage] || { label: stage, color: 'bg-gray-100 text-gray-500' };
const Icon = info.icon;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[9px] font-bold uppercase tracking-widest", info.color)}>
{Icon && <Icon size={10} />}
{info.label}
</span>
);
}
function MetrcBadge({ synced = true }: { synced?: boolean }) {
if (synced) {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold bg-[var(--color-primary)]/5 text-[var(--color-primary)] border border-emerald-500/10 uppercase tracking-widest" title="Synced with METRC">
<Cloud size={8} /> Sync
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold bg-[var(--color-warning)]/5 text-[var(--color-warning)] border border-amber-500/10 uppercase tracking-widest" title="Pending Sync">
<CloudOff size={8} /> Pend
</span>
);
}
export default function BatchesPage() {
const { addToast } = useToast();
const navigate = useNavigate();
const [batches, setBatches] = useState<Batch[]>([]);
const [loading, setLoading] = useState(true);
// Action States
const [selectedBatch, setSelectedBatch] = useState<Batch | null>(null);
const [weightLogBatch, setWeightLogBatch] = useState<Batch | null>(null);
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
useEffect(() => {
fetchBatches();
}, []);
const fetchBatches = async (showToast = false) => {
setLoading(true);
try {
const data = await batchesApi.getAll();
setBatches(data);
if (showToast) addToast('Batches refreshed', 'info');
} catch (e) {
console.error(e);
addToast('Failed to load batches', 'error');
} finally {
setLoading(false);
}
};
const handlePullRefresh = async () => {
await fetchBatches(true);
};
// --- Table Configuration ---
const columns: Column<Batch>[] = [
{
key: 'name',
header: 'Batch',
cell: (batch) => {
const batchCode = batch.name.match(/B\d{3}/)?.[0] || batch.id.slice(0, 6).toUpperCase();
return (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<div className="min-w-[40px] px-1.5 py-0.5 text-center text-[10px] font-mono font-bold rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-[var(--color-text-tertiary)]">
{batchCode}
</div>
<span className="font-medium text-slate-900 dark:text-slate-200">
{batch.strain}
</span>
<MetrcBadge />
</div>
{/* Mobile-only subheading */}
<div className="md:hidden text-xs text-[var(--color-text-tertiary)] mt-1 flex gap-2">
<span>{batch.plantCount} plants</span>
<span></span>
<span className="capitalize">{batch.stage.replace('_', ' ').toLowerCase()}</span>
</div>
</div>
);
}
},
{
key: 'stage',
header: 'Stage',
hideOnMobile: true,
cell: (batch) => <StageBadge stage={batch.stage} />
},
{
key: 'location',
header: 'Location',
hideOnMobile: true,
cell: (batch) => (
<span className="text-sm text-slate-600 dark:text-[var(--color-text-tertiary)]">
{batch.room?.name?.replace('[DEMO] ', '') || 'Unassigned'}
</span>
)
},
{
key: 'plants',
header: 'Plants',
className: 'text-right',
hideOnMobile: true,
cell: (batch) => <span className="text-sm font-mono text-slate-600 dark:text-[var(--color-text-tertiary)]">{batch.plantCount}</span>
},
{
key: 'startDate',
header: 'Age',
className: 'text-right w-32',
hideOnMobile: true,
cell: (batch) => {
const days = Math.floor((Date.now() - new Date(batch.startDate).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="flex flex-col items-end">
<span className="font-medium text-[var(--color-text-secondary)]">Day {days}</span>
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{new Date(batch.startDate).toLocaleDateString()}
</span>
</div>
);
}
},
{
key: 'actions',
header: '',
className: 'w-[140px]',
cell: (batch) => (
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setScoutingBatch(batch)}
className="p-1.5 rounded border border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] hover:border-emerald-500/50 transition-all"
title="Scout"
>
<Search size={14} />
</button>
<button
onClick={() => setIpmBatch(batch)}
className="p-1.5 rounded border border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:text-[var(--color-warning)] hover:border-amber-500/50 transition-all"
title="IPM"
>
<Bug size={14} />
</button>
<button
onClick={() => setSelectedBatch(batch)}
className="p-1.5 rounded border border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:text-[var(--color-accent)] hover:border-blue-500/50 transition-all"
title="Transition"
>
<MoveRight size={14} />
</button>
</div>
)
}
];
// Calculate summary stats
const vegCount = batches.filter(b => b.stage === 'VEGETATIVE').length;
const flowerCount = batches.filter(b => b.stage === 'FLOWERING').length;
const totalPlants = batches.reduce((acc, b) => acc + b.plantCount, 0);
return (
<div className="space-y-8 pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight text-[var(--color-text-primary)]">
Production Inventory
</h1>
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)] text-xs font-medium">
<ShieldCheck size={14} className="text-[var(--color-primary)]" />
<span>{batches.length} Active Batches METRC Integrated</span>
</div>
</div>
<button
onClick={() => setIsCreateOpen(true)}
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
>
<Plus size={18} />
New Batch
</button>
</div>
{/* KPI Strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] flex items-center justify-between">
<div className="space-y-1">
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">Total Batches</p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{batches.length}</p>
</div>
<div className="p-2.5 rounded-lg border bg-[var(--color-accent)]/5 text-[var(--color-accent)] border-blue-500/10">
<Activity size={16} />
</div>
</Card>
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] flex items-center justify-between">
<div className="space-y-1">
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">In Flower</p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{flowerCount}</p>
</div>
<div className="p-2.5 rounded-lg border bg-purple-500/5 text-purple-500 border-purple-500/10">
<Flower size={16} />
</div>
</Card>
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] flex items-center justify-between">
<div className="space-y-1">
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">In Veg</p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{vegCount}</p>
</div>
<div className="p-2.5 rounded-lg border bg-[var(--color-primary)]/5 text-[var(--color-primary)] border-emerald-500/10">
<Leaf size={16} />
</div>
</Card>
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] flex items-center justify-between">
<div className="space-y-1">
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">Total Plants</p>
<p className="text-xl font-bold text-[var(--color-text-primary)]">{totalPlants.toLocaleString()}</p>
</div>
<div className="p-2.5 rounded-lg border bg-[var(--color-error)]/5 text-[var(--color-error)] border-rose-500/10">
<BarChart3 size={16} />
</div>
</Card>
</div>
{/* Data Table */}
<DataTable
data={batches}
columns={columns}
isLoading={loading}
onRowClick={(batch) => navigate(`/batches/${batch.id}`)}
/>
{/* Modals */}
{selectedBatch && (
<BatchTransitionModal
batch={selectedBatch}
onClose={() => setSelectedBatch(null)}
onSuccess={() => {
fetchBatches();
setSelectedBatch(null);
}}
/>
)}
{weightLogBatch && (
<WeightLogModal
batch={weightLogBatch}
onClose={() => setWeightLogBatch(null)}
onSuccess={() => fetchBatches()}
/>
)}
{createTaskBatch && (
<CreateTaskModal
isOpen={true}
initialBatchId={createTaskBatch.id}
onClose={() => setCreateTaskBatch(null)}
onSuccess={() => {
setCreateTaskBatch(null);
addToast('Task created!', 'success');
}}
/>
)}
{ipmBatch && (
<IPMScheduleModal
batch={ipmBatch}
onClose={() => setIpmBatch(null)}
/>
)}
{scoutingBatch && (
<ScoutingModal
batch={scoutingBatch}
onClose={() => setScoutingBatch(null)}
onSuccess={() => {
fetchBatches();
addToast('Scouting report logged', 'success');
}}
/>
)}
<CreateBatchModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
fetchBatches();
setIsCreateOpen(false);
}}
/>
</div>
);
}