ca-grow-ops-manager/frontend/src/pages/SuppliesPage.tsx
fullsizemalt 4b37e9fa84
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
style: Clean up typography across all pages
- Remove italic styling from headers throughout app
- Reduce excessive font-black to font-bold
- Clean up tracking-widest to tracking-wider
- Normalize button styling across pages
2025-12-27 13:09:04 -08:00

500 lines
27 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Plus, Search, ShoppingCart, Package, Filter,
AlertCircle, ExternalLink, X, Loader2, Minus, MoreVertical
} from 'lucide-react';
import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi';
import { PageHeader, EmptyState, StatusBadge } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { useToast } from '../context/ToastContext';
import { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
export default function SuppliesPage() {
const { addToast } = useToast();
const [items, setItems] = useState<SupplyItem[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<'all' | 'shopping'>('all');
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<SupplyCategory | 'ALL'>('ALL');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [newItem, setNewItem] = useState({
name: '',
category: 'OTHER' as SupplyCategory,
quantity: 0,
minThreshold: 1,
unit: 'each',
location: '',
vendor: '',
productUrl: '',
notes: ''
});
useEffect(() => {
loadItems();
}, []);
const loadItems = async () => {
setLoading(true);
try {
const data = await suppliesApi.getAll();
setItems(data);
} catch (error) {
console.error('Failed to load supplies', error);
addToast('Failed to load inventory', 'error');
} finally {
setLoading(false);
}
};
const handleQuantityAdjust = async (id: string, adjustment: number) => {
// Optimistic update
setItems(current => current.map(item => {
if (item.id === id) {
return { ...item, quantity: Math.max(0, item.quantity + adjustment) };
}
return item;
}));
try {
await suppliesApi.adjustQuantity(id, adjustment);
} catch (error) {
addToast('Failed to sync quantity', 'error');
loadItems();
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await suppliesApi.create(newItem);
setIsAddModalOpen(false);
setNewItem({
name: '', category: 'OTHER', quantity: 0, minThreshold: 1,
unit: 'each', location: '', vendor: '', productUrl: '', notes: ''
});
addToast('Item created', 'success');
loadItems();
} catch (error) {
addToast('Failed to create item', 'error');
}
};
const handleMarkOrdered = async (id: string) => {
try {
const updated = await suppliesApi.markOrdered(id);
setItems(current => current.map(item => item.id === id ? updated : item));
addToast('Marked as ordered', 'success');
} catch (error) {
addToast('Failed to update order status', 'error');
}
};
const shoppingListCount = items.filter(i => i.quantity <= i.minThreshold).length;
const filteredItems = items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(search.toLowerCase()) ||
(item.location && item.location.toLowerCase().includes(search.toLowerCase())) ||
(item.vendor && item.vendor.toLowerCase().includes(search.toLowerCase()));
const matchesCategory = categoryFilter === 'ALL' || item.category === categoryFilter;
const matchesView = view === 'all' || (view === 'shopping' && item.quantity <= item.minThreshold);
return matchesSearch && matchesCategory && matchesView;
});
const categories: SupplyCategory[] = ['FILTER', 'CLEANING', 'PPE', 'OFFICE', 'BATHROOM', 'KITCHEN', 'MAINTENANCE', 'OTHER'];
const columns: Column<SupplyItem>[] = [
{
key: 'name',
header: 'Item Name',
cell: (item) => (
<div className="flex items-center gap-2">
<span className={cn("font-bold text-sm", item.quantity <= item.minThreshold ? "text-[var(--color-error)]" : "text-[var(--color-text-primary)]")}>
{item.name}
</span>
{item.quantity <= item.minThreshold && (
<AlertCircle size={14} className="text-[var(--color-error)] animate-pulse" />
)}
</div>
)
},
{
key: 'category',
header: 'Category',
cell: (item) => (
<StatusBadge
status="default"
label={item.category.toLowerCase()}
className="bg-slate-100 dark:bg-slate-800 border-none px-2"
/>
),
hideOnMobile: true
},
{
key: 'location',
header: 'Location',
cell: (item) => <span className="text-xs font-mono text-[var(--color-text-tertiary)]">{item.location || '—'}</span>,
hideOnMobile: true
},
{
key: 'quantity',
header: 'Quantity',
className: 'w-48',
cell: (item) => (
<div className="flex items-center gap-3">
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 border border-[var(--color-border-default)]/50">
<button
onClick={(e) => { e.stopPropagation(); handleQuantityAdjust(item.id, -1); }}
disabled={item.quantity <= 0}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm transition-all disabled:opacity-30"
>
<Minus size={14} />
</button>
<div className="w-10 text-center font-bold text-sm tabular-nums">
{item.quantity}
</div>
<button
onClick={(e) => { e.stopPropagation(); handleQuantityAdjust(item.id, 1); }}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm transition-all"
>
<Plus size={14} />
</button>
</div>
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">{item.unit}</span>
</div>
)
},
{
key: 'minThreshold',
header: 'Min',
cell: (item) => (
<div className="text-xs font-bold text-[var(--color-text-tertiary)]">
<span className="md:hidden">Min: </span>{item.minThreshold}
</div>
),
hideOnMobile: true
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (item) => (
<div className="flex justify-end gap-2">
{item.productUrl && (
<a
href={item.productUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded-lg transition-colors"
title="Buy Now"
>
<ExternalLink size={16} />
</a>
)}
{item.quantity <= item.minThreshold && (
<button
onClick={(e) => { e.stopPropagation(); handleMarkOrdered(item.id); }}
className={cn(
"text-[10px] font-medium px-3 py-1.5 rounded-lg border transition-all",
item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? "bg-emerald-50 text-[var(--color-primary)] border-emerald-100"
: "bg-rose-50 text-rose-600 border-rose-100 hover:bg-rose-100"
)}
>
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? 'Ordered'
: 'Order'
}
</button>
)}
</div>
)
}
];
return (
<div className="max-w-[1600px] mx-auto space-y-8 pb-24 animate-in">
<PageHeader
title={view === 'shopping' ? 'Supply Shortages' : 'Facility Inventory'}
subtitle={view === 'shopping'
? `Action required for ${filteredItems.length} low-stock items`
: 'Real-time tracking of facility and maintenance supplies'
}
actions={
<button
onClick={() => setIsAddModalOpen(true)}
className="group relative bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-xl font-bold text-sm tracking-tight transition-all active:scale-95 shadow-xl shadow-indigo-600/10 flex items-center gap-2"
>
<Plus size={18} strokeWidth={2.5} />
<span>Add Supply Record</span>
</button>
}
/>
{/* Dashboard Ribbon */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card p-5 bg-gradient-to-br from-indigo-500/5 to-transparent flex items-center justify-between">
<div>
<p className="text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Stock Health</p>
<h4 className="text-2xl font-bold">{items.length} Unique SKUs</h4>
</div>
<Package className="text-indigo-500/20" size={40} />
</div>
<button
onClick={() => setView('shopping')}
className={cn(
"card p-5 transition-all text-left group",
shoppingListCount > 0
? "bg-[var(--color-error)]/5 hover:bg-[var(--color-error)]/10 border-rose-200/50"
: "bg-[var(--color-primary)]/5 hover:bg-[var(--color-primary)]/10 border-emerald-200/50"
)}
>
<div className="flex justify-between items-start">
<div>
<p className={cn(
"text-[10px] font-medium mb-1",
shoppingListCount > 0 ? "text-[var(--color-error)]" : "text-[var(--color-primary)]"
)}>
Shortages Detected
</p>
<h4 className="text-2xl font-bold">{shoppingListCount} Items</h4>
</div>
<AlertCircle className={cn(
"group-hover:scale-110 transition-transform",
shoppingListCount > 0 ? "text-[var(--color-error)]/40" : "text-[var(--color-primary)]/40"
)} size={40} />
</div>
</button>
<div className="card p-5 bg-slate-100/50 dark:bg-slate-900/50 flex flex-col justify-center">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]" size={14} />
<input
type="text"
placeholder="Scan or search inventory..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white dark:bg-slate-950 border border-[var(--color-border-subtle)] rounded-lg pl-9 pr-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
</div>
</div>
</div>
{/* Controls & Table */}
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4 bg-white dark:bg-[#050505] p-2 rounded-2xl border border-[var(--color-border-subtle)] shadow-sm">
<div className="flex p-1 bg-[var(--color-bg-tertiary)] rounded-xl">
<button
onClick={() => setView('all')}
className={cn(
"px-6 py-2 rounded-lg text-xs font-medium transition-all",
view === 'all'
? "bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300"
)}
>
Full Inventory
</button>
<button
onClick={() => setView('shopping')}
className={cn(
"px-6 py-2 rounded-lg text-xs font-medium transition-all relative",
view === 'shopping'
? "bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300"
)}
>
Shopping List
</button>
</div>
<div className="flex gap-1 overflow-x-auto pb-1 no-scrollbar pr-2">
<button
onClick={() => setCategoryFilter('ALL')}
className={cn(
"px-4 py-2 rounded-lg text-[10px] font-medium transition-all border",
categoryFilter === 'ALL'
? "bg-indigo-500 text-white border-indigo-500"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] border-[var(--color-border-subtle)]"
)}
>
All Categories
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
className={cn(
"px-4 py-2 rounded-lg text-[10px] font-medium transition-all border whitespace-nowrap",
categoryFilter === cat
? "bg-indigo-500 text-white border-indigo-500"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] border-[var(--color-border-subtle)]"
)}
>
{cat.toLowerCase()}
</button>
))}
</div>
</div>
<DataTable
data={filteredItems}
columns={columns}
isLoading={loading}
emptyState={
<EmptyState
icon={Package}
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'Inventory Empty'}
description={search || categoryFilter !== 'ALL'
? 'Try adjusting your search query or category filters.'
: 'Your facility inventory is currently empty. Add your first supply record to start tracking.'}
action={
(search || categoryFilter !== 'ALL') ? (
<button
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
className="font-bold text-indigo-500 hover:text-indigo-600 uppercase text-xs tracking-widest"
>
Reset Filters
</button>
) : (
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
<Plus size={16} />
Add First Item
</button>
)
}
/>
}
/>
</div>
{/* Add Modal */}
<AnimatePresence>
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsAddModalOpen(false)}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-lg bg-white dark:bg-[#0C0C0C] border border-[var(--color-border-subtle)] rounded-3xl shadow-2xl overflow-hidden"
>
<div className="p-6 border-b border-[var(--color-border-subtle)] flex justify-between items-center">
<h2 className="text-xl font-bold tracking-tight">Add Supply Record</h2>
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-colors">
<X size={20} className="text-[var(--color-text-tertiary)]" />
</button>
</div>
<form onSubmit={handleCreate} className="p-8 space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Item Name</label>
<input
required
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.name}
onChange={e => setNewItem({ ...newItem, name: e.target.value })}
placeholder="e.g. 5 Gallon Nursery Pots"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Category</label>
<select
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.category}
onChange={e => setNewItem({ ...newItem, category: e.target.value as any })}
>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Unit</label>
<input
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.unit}
onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
placeholder="box, each, gal..."
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Initial Stock</label>
<input
type="number"
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.quantity}
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Alert Threshold</label>
<input
type="number"
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.minThreshold}
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Storage Location</label>
<input
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.location}
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
placeholder="e.g. Warehouse Rack B4"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Vendor</label>
<input
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.vendor}
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
placeholder="Amazon, Home Depot..."
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">URL</label>
<input
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.productUrl}
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
placeholder="https://..."
/>
</div>
</div>
</div>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl h-14 font-bold tracking-tight shadow-xl shadow-indigo-600/10 transition-all active:scale-[0.98] flex items-center justify-center gap-2"
>
<Package size={18} />
<span>Create Inventory Record</span>
</button>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}