- 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
500 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|