Phase 8: Visitor Management - Visitor/VisitorLog/AccessZone models - Check-in/out with badge generation - Zone occupancy tracking - Kiosk and management pages Phase 9: Messaging & Communication - Announcements with priority levels - Acknowledgement tracking - Shift notes for team handoffs - AnnouncementBanner component Phase 10: Compliance & Audit Trail - Immutable AuditLog model - Document versioning and approval workflow - Acknowledgement tracking for SOPs - CSV export for audit logs Phase 11: Accessibility & i18n - WCAG 2.1 AA compliance utilities - react-i18next with EN/ES translations - User preferences context (theme, font size, etc) - High contrast and reduced motion support Phase 12: Hardware Integration - QR code generation for batches/plants/visitors - Printable label system - Visitor badge printing Phase 13: Advanced Features - Environmental monitoring (sensors, readings, alerts) - Financial tracking (transactions, P&L reports) - AI/ML insights (yield predictions, anomaly detection)
249 lines
14 KiB
TypeScript
249 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, RefreshCw } 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 IPMScheduleModal from '../components/IPMScheduleModal';
|
|
import ScoutingModal from '../components/ipm/ScoutingModal';
|
|
import { SkeletonCard } from '../components/ui/Skeleton';
|
|
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
|
import { QuickLogBar } from '../components/touchpoints/QuickLogButtons';
|
|
|
|
const STAGE_GROUPS = [
|
|
{ id: 'CLONE_IN', label: 'Clones', icon: Sprout, color: 'text-blue-500 bg-blue-50 border-blue-100' },
|
|
{ id: 'VEGETATIVE', label: 'Veg', icon: Leaf, color: 'text-emerald-500 bg-emerald-50 border-emerald-100' },
|
|
{ id: 'FLOWERING', label: 'Flower', icon: Flower, color: 'text-purple-500 bg-purple-50 border-purple-100' },
|
|
{ id: 'DRYING', label: 'Drying', icon: Archive, color: 'text-amber-500 bg-amber-50 border-amber-100' },
|
|
{ id: 'CURING', label: 'Curing', icon: Archive, color: 'text-orange-500 bg-orange-50 border-orange-100' },
|
|
{ id: 'FINISHED', label: 'Finished', icon: Archive, color: 'text-slate-500 bg-slate-50 border-slate-100' },
|
|
];
|
|
|
|
export default function BatchesPage() {
|
|
const { addToast } = useToast();
|
|
const [batches, setBatches] = useState<Batch[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Group batches by stage
|
|
const groupedBatches = STAGE_GROUPS.map(group => ({
|
|
...group,
|
|
items: batches.filter(b => b.stage === group.id)
|
|
})).filter(group => group.items.length > 0);
|
|
|
|
const handlePullRefresh = async () => {
|
|
await fetchBatches(true);
|
|
};
|
|
|
|
return (
|
|
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
|
<div className="space-y-6 pb-20">
|
|
<header className="flex justify-between items-center sticky top-0 bg-slate-50 dark:bg-slate-900 z-10 py-2">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Batches</h2>
|
|
<p className="text-sm text-slate-500">{loading ? 'Loading...' : `${batches.length} active batches`}</p>
|
|
</div>
|
|
<button className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors shadow-lg">
|
|
<Plus size={20} />
|
|
<span className="hidden md:inline">Start Batch</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="space-y-8">
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
|
|
</div>
|
|
) : groupedBatches.length === 0 ? (
|
|
<div className="text-center py-20 bg-white dark:bg-slate-800 rounded-xl border border-dashed border-slate-300 dark:border-slate-700">
|
|
<div className="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4">
|
|
<Sprout size={32} className="text-slate-400" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">No active batches</h3>
|
|
<p className="text-slate-500 dark:text-slate-400 mt-2">Get started by creating your first batch.</p>
|
|
</div>
|
|
) : (
|
|
groupedBatches.map(group => (
|
|
<div key={group.id} className="space-y-3">
|
|
<div className="flex items-center gap-2 px-1">
|
|
<div className={`p-2 rounded-lg ${group.color.split(' ')[1]}`}>
|
|
<group.icon size={18} className={group.color.split(' ')[0]} />
|
|
</div>
|
|
<h3 className="font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide text-sm">
|
|
{group.label}
|
|
</h3>
|
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
|
|
{group.items.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{group.items.map(batch => (
|
|
<div
|
|
key={batch.id}
|
|
className="bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-100 dark:border-slate-700 shadow-sm hover:shadow-md transition-shadow relative group"
|
|
>
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div>
|
|
<h4 className="font-bold text-lg text-slate-900 dark:text-white leading-tight mb-1">
|
|
{batch.name}
|
|
</h4>
|
|
<div className="text-xs font-medium px-2 py-1 rounded bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 inline-block">
|
|
{batch.strain}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => setCreateTaskBatch(batch)}
|
|
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-lg transition-colors"
|
|
title="Add Task"
|
|
>
|
|
<ClipboardList size={20} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setIpmBatch(batch)}
|
|
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
|
title="IPM Schedule"
|
|
>
|
|
<Bug size={20} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setScoutingBatch(batch)}
|
|
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
|
title="Scout Batch"
|
|
>
|
|
<Search size={20} />
|
|
</button>
|
|
|
|
{/* Show Scale button for Harvest+ stages */}
|
|
{['HARVEST', 'DRYING', 'CURING', 'FINISHED'].includes(batch.stage) && (
|
|
<button
|
|
onClick={() => setWeightLogBatch(batch)}
|
|
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
|
title="Log Weight"
|
|
>
|
|
<Scale size={20} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setSelectedBatch(batch)}
|
|
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg transition-colors"
|
|
title="Transition Stage"
|
|
>
|
|
<MoveRight size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400 pt-3 border-t border-slate-50 dark:border-slate-700">
|
|
<div className="flex flex-col">
|
|
<span className="text-xs uppercase tracking-wider text-slate-400">Room</span>
|
|
<span className="font-medium text-slate-700 dark:text-slate-300">
|
|
{batch.room?.name || 'Unassigned'}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-xs uppercase tracking-wider text-slate-400">Plants</span>
|
|
<span className="font-medium text-slate-700 dark:text-slate-300">
|
|
{batch.plantCount}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Log Bar */}
|
|
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
|
<QuickLogBar
|
|
batchId={batch.id}
|
|
batchName={batch.name}
|
|
onSuccess={() => fetchBatches()}
|
|
compact
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{selectedBatch && (
|
|
<BatchTransitionModal
|
|
batch={selectedBatch}
|
|
onClose={() => setSelectedBatch(null)}
|
|
onSuccess={() => {
|
|
fetchBatches();
|
|
setSelectedBatch(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{weightLogBatch && (
|
|
<WeightLogModal
|
|
batch={weightLogBatch}
|
|
onClose={() => setWeightLogBatch(null)}
|
|
onSuccess={() => {
|
|
fetchBatches();
|
|
setWeightLogBatch(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{createTaskBatch && (
|
|
<CreateTaskModal
|
|
isOpen={true}
|
|
initialBatchId={createTaskBatch.id}
|
|
initialRoomId={createTaskBatch.roomId || undefined}
|
|
onClose={() => setCreateTaskBatch(null)}
|
|
onSuccess={() => {
|
|
// Optional: show toast
|
|
setCreateTaskBatch(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{ipmBatch && (
|
|
<IPMScheduleModal
|
|
batch={ipmBatch}
|
|
onClose={() => setIpmBatch(null)}
|
|
/>
|
|
)}
|
|
|
|
{scoutingBatch && (
|
|
<ScoutingModal
|
|
batch={scoutingBatch}
|
|
onClose={() => setScoutingBatch(null)}
|
|
onSuccess={() => {
|
|
// Optional: show toast
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</PullToRefresh>
|
|
);
|
|
}
|