- 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
322 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|