feat(ui): Implement AuraUI DataTable for Batches
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Created centralized DataTable component with sorting and mobile-responsive columns
- Refactored BatchesPage to use DataTable instead of card grid
- Improved information density and scannability for operational data
- Updated StageBadge styling to match new system
This commit is contained in:
fullsizemalt 2025-12-19 18:46:42 -08:00
parent e1ea974b18
commit 56948c20ec
2 changed files with 262 additions and 155 deletions

View file

@ -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<T> {
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<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
isLoading?: boolean;
emptyState?: ReactNode; // Custom empty state or default
}
// --- Component ---
export function DataTable<T extends { id: string | number }>({
data,
columns,
onRowClick,
isLoading,
emptyState,
}: DataTableProps<T>) {
if (isLoading) {
return (
<div className="w-full border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden bg-white dark:bg-slate-950">
<div className="border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 px-4 py-3 h-10 flex items-center gap-4">
{/* Skeleton Header */}
<div className="h-4 w-24 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-slate-200 dark:bg-slate-800 rounded animate-pulse" />
</div>
<div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-4 border-b border-slate-100 dark:border-slate-800/50 flex items-center gap-4">
<div className="h-4 w-1/4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse" />
<div className="h-4 w-1/4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse" />
<div className="h-4 w-1/4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
if (data.length === 0) {
if (emptyState) return <>{emptyState}</>;
return (
<div className="border border-slate-200 dark:border-slate-800 rounded-lg bg-slate-50/50 dark:bg-slate-950/50 p-12 flex flex-col items-center justify-center text-center">
<p className="text-slate-500 dark:text-slate-400">No records found</p>
</div>
);
}
return (
<div className="w-full border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden bg-white dark:bg-slate-950 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
{/* Header */}
<thead className="bg-slate-50 dark:bg-slate-900/50 text-slate-500 dark:text-slate-400 font-medium border-b border-slate-200 dark:border-slate-800">
<tr>
{columns.map((col, idx) => (
<th
key={String(col.key) + idx}
className={cn(
"px-4 py-3 whitespace-nowrap transition-colors",
col.sortable && "cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-700 dark:hover:text-slate-300",
col.hideOnMobile && "hidden md:table-cell",
col.className
)}
>
<div className="flex items-center gap-1">
{col.header}
{col.sortable && <ArrowUpDown className="h-3 w-3 opacity-50" />}
</div>
</th>
))}
</tr>
</thead>
{/* Body */}
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
{data.map((row) => (
<tr
key={row.id}
onClick={() => 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) => (
<td
key={String(col.key) + idx}
className={cn(
"px-4 py-3 align-middle text-slate-700 dark:text-slate-300",
col.hideOnMobile && "hidden md:table-cell",
col.className
)}
>
{col.cell(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Optional Footer/Pagination area could go here */}
</div>
);
}

View file

@ -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) ---
function StageBadge({ stage }: { stage: string }) {
const config: Record<string, { label: string, color: string, icon?: any }> = {
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 },
};
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 },
];
// 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 (
<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 className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${info.color}`}>
{Icon && <Icon size={12} />}
{info.label}
</span>
);
}
// METRC Sync Status Badge
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-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400" title="Synced with METRC">
<Cloud size={9} />
METRC
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400 opacity-80" title="Synced with METRC">
<Cloud size={10} />
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Not synced with METRC">
<CloudOff size={9} />
Pending
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400 opacity-80" title="Pending Sync">
<CloudOff size={10} />
</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);
@ -108,133 +82,139 @@ 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<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-slate-400">
{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-slate-500 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-slate-400">
{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-slate-400">{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-slate-700 dark:text-slate-300">Day {days}</span>
<span className="text-[10px] text-slate-400">
{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()}>
<ActionButton
icon={Search}
label="Scout"
onClick={() => setScoutingBatch(batch)}
/>
<ActionButton
icon={Bug}
label="IPM"
onClick={() => setIpmBatch(batch)}
/>
<ActionButton
icon={MoveRight}
label="Transition"
onClick={() => setSelectedBatch(batch)}
variant="accent"
/>
</div>
)
}
];
return (
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
<div className="space-y-6 pb-20 animate-in">
<div className="space-y-6 pb-20 animate-in fade-in duration-500">
<PageHeader
title="Batches"
subtitle={loading ? 'Loading...' : `${batches.length} active batches`}
subtitle="Production Overview"
actions={
<button className="btn btn-primary">
<button className="btn btn-primary shadow-lg shadow-indigo-500/20">
<Plus size={16} />
<span className="hidden md:inline">New Batch</span>
</button>
}
/>
<div className="space-y-6">
{loading ? (
<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 ? (
<DataTable
data={batches}
columns={columns}
isLoading={loading}
onRowClick={(batch) => navigate(`/batches/${batch.id}`)}
emptyState={
<EmptyState
icon={Sprout}
title="No active batches"
description="Get started by creating your first batch."
description="Get started by creating your first batch in the system."
action={
<button className="btn btn-primary">
<button className="btn btn-primary mt-4">
<Plus size={16} />
Start Batch
</button>
}
/>
) : (
groupedBatches.map(group => (
<div key={group.id} className="space-y-3">
<SectionHeader
icon={group.icon}
title={group.label}
count={group.items.length}
accent={group.accent}
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{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 (
<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">
{/* Batch Code Badge + Strain Name */}
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-accent-muted text-accent">
{batchCode}
</span>
<MetrcBadge synced={true} />
<h4 className="font-medium text-primary text-sm truncate">
{batch.strain}
</h4>
</div>
{/* Plant count + Room */}
<div className="flex items-center gap-2 text-xs text-tertiary">
<span>{batch.plantCount} plants</span>
{batch.room && (
<>
<span></span>
<span>{batch.room.name?.replace('[DEMO] ', '')}</span>
</>
)}
</div>
</div>
<ChevronRight size={14} className="text-tertiary flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* 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-end">
<div className="flex gap-0.5">
<ActionButton
icon={Search}
label="Scout"
onClick={() => setScoutingBatch(batch)}
variant="accent"
/>
<ActionButton
icon={Bug}
label="IPM"
onClick={() => setIpmBatch(batch)}
variant="destructive"
/>
<ActionButton
icon={MoveRight}
label="Transition"
onClick={() => setSelectedBatch(batch)}
variant="success"
/>
</div>
</div>
</div>
);
})}
</div>
</div>
))
)}
</div>
{/* Modals */}
{selectedBatch && (
<BatchTransitionModal