diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 0000000..5e01a77 --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,127 @@ +import { ReactNode } from 'react'; +import { ArrowUpDown, ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { EmptyState } from './LinearPrimitives'; // Re-use the AuraUI empty state + +// --- Types --- + +export interface Column { + key: keyof T | string; // 'tag', 'strain.name', etc. + header: string; + cell: (row: T) => ReactNode; // Custom renderer + sortable?: boolean; + className?: string; // e.g. 'text-right', 'w-24' + hideOnMobile?: boolean; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + onRowClick?: (row: T) => void; + isLoading?: boolean; + emptyState?: ReactNode; // Custom empty state or default +} + +// --- Component --- + +export function DataTable({ + data, + columns, + onRowClick, + isLoading, + emptyState, +}: DataTableProps) { + + if (isLoading) { + return ( +
+
+ {/* Skeleton Header */} +
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ); + } + + if (data.length === 0) { + if (emptyState) return <>{emptyState}; + return ( +
+

No records found

+
+ ); + } + + return ( +
+
+ + {/* Header */} + + + {columns.map((col, idx) => ( + + ))} + + + + {/* Body */} + + {data.map((row) => ( + onRowClick && onRowClick(row)} + className={cn( + "group transition-colors", + onRowClick + ? "cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/40" + : "", + "bg-white dark:bg-slate-950" + )} + > + {columns.map((col, idx) => ( + + ))} + + ))} + +
+
+ {col.header} + {col.sortable && } +
+
+ {col.cell(row)} +
+
+ + {/* Optional Footer/Pagination area could go here */} +
+ ); +} diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx index 9ef05c6..3f27c82 100644 --- a/frontend/src/pages/BatchesPage.tsx +++ b/frontend/src/pages/BatchesPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar, Cloud, CloudOff, CheckCircle } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Bug, Search, Cloud, CloudOff } from 'lucide-react'; import { Batch, batchesApi } from '../lib/batchesApi'; import { useToast } from '../context/ToastContext'; import BatchTransitionModal from '../components/BatchTransitionModal'; @@ -9,81 +9,55 @@ import CreateTaskModal from '../components/tasks/CreateTaskModal'; import IPMScheduleModal from '../components/IPMScheduleModal'; import ScoutingModal from '../components/ipm/ScoutingModal'; import { PullToRefresh } from '../components/ui/PullToRefresh'; -import { QuickLogBar } from '../components/touchpoints/QuickLogButtons'; -import { PageHeader, SectionHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives'; +import { PageHeader, EmptyState, ActionButton } from '../components/ui/LinearPrimitives'; +import { DataTable, Column } from '../components/ui/DataTable'; -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 }, -}; +// --- Shared Components (Badges) --- -const STAGE_GROUPS = [ - { id: 'CLONE_IN', label: 'Clones', icon: Sprout, accent: 'accent' as const }, - { id: 'VEGETATIVE', label: 'Vegetative', icon: Leaf, accent: 'success' as const }, - { id: 'FLOWERING', label: 'Flowering', icon: Flower, accent: 'accent' as const }, - { id: 'DRYING', label: 'Drying', icon: Archive, accent: 'warning' as const }, - { id: 'CURING', label: 'Curing', icon: Archive, accent: 'warning' as const }, - { id: 'FINISHED', label: 'Finished', icon: Archive, accent: 'default' as const }, -]; +function StageBadge({ stage }: { stage: string }) { + const config: Record = { + CLONE_IN: { label: 'Clone', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', icon: Sprout }, + VEGETATIVE: { label: 'Veg', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', icon: Leaf }, + FLOWERING: { label: 'Flower', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', icon: Flower }, + HARVEST: { label: 'Harvest', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', icon: Archive }, + DRYING: { label: 'Drying', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Archive }, + CURING: { label: 'Curing', color: 'bg-stone-100 text-stone-700 dark:bg-stone-900/30 dark:text-stone-300', icon: Archive }, + FINISHED: { label: 'Done', color: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400', icon: Archive }, + }; -// Stage Progress Mini Component -function StageProgressMini({ currentStage }: { currentStage: string }) { - const stages = ['CLONE_IN', 'VEGETATIVE', 'FLOWERING', 'HARVEST', 'DRYING', 'CURING']; - const currentIndex = stages.indexOf(currentStage); + const info = config[stage] || { label: stage, color: 'bg-gray-100 text-gray-700' }; + const Icon = info.icon; 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} + + {Icon && } + {info.label} ); } -// METRC Sync Status Badge function MetrcBadge({ synced = true }: { synced?: boolean }) { if (synced) { return ( - - - METRC + + ); } return ( - - - Pending + + ); } export default function BatchesPage() { const { addToast } = useToast(); + const navigate = useNavigate(); const [batches, setBatches] = useState([]); const [loading, setLoading] = useState(true); + + // Action States const [selectedBatch, setSelectedBatch] = useState(null); const [weightLogBatch, setWeightLogBatch] = useState(null); const [createTaskBatch, setCreateTaskBatch] = useState(null); @@ -108,132 +82,138 @@ export default function BatchesPage() { } }; - 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); }; + // --- Table Configuration --- + const columns: Column[] = [ + { + key: 'name', + header: 'Batch', + cell: (batch) => { + const batchCode = batch.name.match(/B\d{3}/)?.[0] || batch.id.slice(0, 6).toUpperCase(); + return ( +
+
+
+ {batchCode} +
+ + {batch.strain} + + +
+ {/* Mobile-only subheading */} +
+ {batch.plantCount} plants + + {batch.stage.replace('_', ' ').toLowerCase()} +
+
+ ); + } + }, + { + key: 'stage', + header: 'Stage', + hideOnMobile: true, + cell: (batch) => + }, + { + key: 'location', + header: 'Location', + hideOnMobile: true, + cell: (batch) => ( + + {batch.room?.name?.replace('[DEMO] ', '') || 'Unassigned'} + + ) + }, + { + key: 'plants', + header: 'Plants', + className: 'text-right', + hideOnMobile: true, + cell: (batch) => {batch.plantCount} + }, + { + 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 ( +
+ Day {days} + + {new Date(batch.startDate).toLocaleDateString()} + +
+ ); + } + }, + { + key: 'actions', + header: '', + className: 'w-[140px]', + cell: (batch) => ( +
e.stopPropagation()}> + setScoutingBatch(batch)} + /> + setIpmBatch(batch)} + /> + setSelectedBatch(batch)} + variant="accent" + /> +
+ ) + } + ]; + return ( -
+
+ } /> -
- {loading ? ( -
- {Array.from({ length: 6 }).map((_, i) => )} -
- ) : groupedBatches.length === 0 ? ( + navigate(`/batches/${batch.id}`)} + emptyState={ + } /> - ) : ( - groupedBatches.map(group => ( -
- - -
- {group.items.map(batch => { - // Extract batch code from name (e.g., "B001" from "[DEMO] Gorilla Glue #4 - B001") - const batchCode = batch.name.match(/B\d{3}/)?.[0] || batch.id.slice(0, 6).toUpperCase(); - - return ( -
- {/* Clickable Header */} - -
-
- {/* Batch Code Badge + Strain Name */} -
- - {batchCode} - - -

- {batch.strain} -

-
- {/* Plant count + Room */} -
- {batch.plantCount} plants - {batch.room && ( - <> - - {batch.room.name?.replace('[DEMO] ', '')} - - )} -
-
- -
- - {/* Progress & Days */} -
- - -
- - - {/* Quick Actions */} -
-
- setScoutingBatch(batch)} - variant="accent" - /> - setIpmBatch(batch)} - variant="destructive" - /> - setSelectedBatch(batch)} - variant="success" - /> -
-
-
- ); - })} -
-
- )) - )} -
+ } + /> {/* Modals */} {selectedBatch && (