feat(ui): Implement AuraUI DataTable for Batches
- 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:
parent
e1ea974b18
commit
56948c20ec
2 changed files with 262 additions and 155 deletions
127
frontend/src/components/ui/DataTable.tsx
Normal file
127
frontend/src/components/ui/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar, Cloud, CloudOff, CheckCircle } from 'lucide-react';
|
import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Bug, Search, Cloud, CloudOff } from 'lucide-react';
|
||||||
import { Batch, batchesApi } from '../lib/batchesApi';
|
import { Batch, batchesApi } from '../lib/batchesApi';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import BatchTransitionModal from '../components/BatchTransitionModal';
|
import BatchTransitionModal from '../components/BatchTransitionModal';
|
||||||
|
|
@ -9,81 +9,55 @@ import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
||||||
import IPMScheduleModal from '../components/IPMScheduleModal';
|
import IPMScheduleModal from '../components/IPMScheduleModal';
|
||||||
import ScoutingModal from '../components/ipm/ScoutingModal';
|
import ScoutingModal from '../components/ipm/ScoutingModal';
|
||||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||||
import { QuickLogBar } from '../components/touchpoints/QuickLogButtons';
|
import { PageHeader, EmptyState, ActionButton } from '../components/ui/LinearPrimitives';
|
||||||
import { PageHeader, SectionHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
|
import { DataTable, Column } from '../components/ui/DataTable';
|
||||||
|
|
||||||
const STAGE_CONFIG = {
|
// --- Shared Components (Badges) ---
|
||||||
CLONE_IN: { label: 'Clone', icon: Sprout, color: 'bg-blue-500', order: 0 },
|
|
||||||
VEGETATIVE: { label: 'Veg', icon: Leaf, color: 'bg-green-500', order: 1 },
|
function StageBadge({ stage }: { stage: string }) {
|
||||||
FLOWERING: { label: 'Flower', icon: Flower, color: 'bg-purple-500', order: 2 },
|
const config: Record<string, { label: string, color: string, icon?: any }> = {
|
||||||
HARVEST: { label: 'Harvest', icon: Archive, color: 'bg-amber-500', order: 3 },
|
CLONE_IN: { label: 'Clone', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', icon: Sprout },
|
||||||
DRYING: { label: 'Dry', icon: Archive, color: 'bg-orange-500', order: 4 },
|
VEGETATIVE: { label: 'Veg', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', icon: Leaf },
|
||||||
CURING: { label: 'Cure', icon: Archive, color: 'bg-stone-500', order: 5 },
|
FLOWERING: { label: 'Flower', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', icon: Flower },
|
||||||
FINISHED: { label: 'Done', icon: Archive, color: 'bg-neutral-400', order: 6 },
|
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 = [
|
const info = config[stage] || { label: stage, color: 'bg-gray-100 text-gray-700' };
|
||||||
{ id: 'CLONE_IN', label: 'Clones', icon: Sprout, accent: 'accent' as const },
|
const Icon = info.icon;
|
||||||
{ 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-0.5">
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${info.color}`}>
|
||||||
{stages.map((stage, i) => (
|
{Icon && <Icon size={12} />}
|
||||||
<div
|
{info.label}
|
||||||
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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// METRC Sync Status Badge
|
|
||||||
function MetrcBadge({ synced = true }: { synced?: boolean }) {
|
function MetrcBadge({ synced = true }: { synced?: boolean }) {
|
||||||
if (synced) {
|
if (synced) {
|
||||||
return (
|
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">
|
<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={9} />
|
<Cloud size={10} />
|
||||||
METRC
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<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={9} />
|
<CloudOff size={10} />
|
||||||
Pending
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchesPage() {
|
export default function BatchesPage() {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [batches, setBatches] = useState<Batch[]>([]);
|
const [batches, setBatches] = useState<Batch[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Action States
|
||||||
const [selectedBatch, setSelectedBatch] = useState<Batch | null>(null);
|
const [selectedBatch, setSelectedBatch] = useState<Batch | null>(null);
|
||||||
const [weightLogBatch, setWeightLogBatch] = useState<Batch | null>(null);
|
const [weightLogBatch, setWeightLogBatch] = useState<Batch | null>(null);
|
||||||
const [createTaskBatch, setCreateTaskBatch] = 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 () => {
|
const handlePullRefresh = async () => {
|
||||||
await fetchBatches(true);
|
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 (
|
return (
|
||||||
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
<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
|
<PageHeader
|
||||||
title="Batches"
|
title="Batches"
|
||||||
subtitle={loading ? 'Loading...' : `${batches.length} active batches`}
|
subtitle="Production Overview"
|
||||||
actions={
|
actions={
|
||||||
<button className="btn btn-primary">
|
<button className="btn btn-primary shadow-lg shadow-indigo-500/20">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
<span className="hidden md:inline">New Batch</span>
|
<span className="hidden md:inline">New Batch</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<DataTable
|
||||||
{loading ? (
|
data={batches}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
columns={columns}
|
||||||
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
|
isLoading={loading}
|
||||||
</div>
|
onRowClick={(batch) => navigate(`/batches/${batch.id}`)}
|
||||||
) : groupedBatches.length === 0 ? (
|
emptyState={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Sprout}
|
icon={Sprout}
|
||||||
title="No active batches"
|
title="No active batches"
|
||||||
description="Get started by creating your first batch."
|
description="Get started by creating your first batch in the system."
|
||||||
action={
|
action={
|
||||||
<button className="btn btn-primary">
|
<button className="btn btn-primary mt-4">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Start Batch
|
Start Batch
|
||||||
</button>
|
</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 */}
|
{/* Modals */}
|
||||||
{selectedBatch && (
|
{selectedBatch && (
|
||||||
<BatchTransitionModal
|
<BatchTransitionModal
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue