- Complete UI refactor with charcoal/bone color palette - Add Space Grotesk font for headlines, Inter for body - Update all 24+ pages with new design system - Add LinearPrimitives reusable components - Improve dark mode support throughout - Add subtle micro-animations and transitions
401 lines
20 KiB
TypeScript
401 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
Plus, Search, ShoppingCart, Package, Filter,
|
||
AlertCircle, ExternalLink, X, Loader2
|
||
} from 'lucide-react';
|
||
import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi';
|
||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||
import { useToast } from '../context/ToastContext';
|
||
|
||
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);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleQuantityAdjust = async (id: string, adjustment: number) => {
|
||
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) {
|
||
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', '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'];
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
||
<PageHeader
|
||
title={view === 'shopping' ? 'Shopping List' : 'Storage & Inventory'}
|
||
subtitle={view === 'shopping'
|
||
? `${filteredItems.length} items need attention`
|
||
: 'Manage facility supplies'
|
||
}
|
||
actions={
|
||
<button
|
||
onClick={() => setIsAddModalOpen(true)}
|
||
className="btn btn-primary"
|
||
>
|
||
<Plus size={16} />
|
||
<span className="hidden sm:inline">Add Item</span>
|
||
</button>
|
||
}
|
||
/>
|
||
|
||
{/* View Toggle */}
|
||
<div className="card p-1 inline-flex gap-1">
|
||
<button
|
||
onClick={() => setView('all')}
|
||
className={`
|
||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
|
||
${view === 'all' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
|
||
`}
|
||
>
|
||
Inventory
|
||
</button>
|
||
<button
|
||
onClick={() => setView('shopping')}
|
||
className={`
|
||
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast relative
|
||
${view === 'shopping' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
|
||
`}
|
||
>
|
||
Shopping List
|
||
{shoppingListCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-white text-[10px] flex items-center justify-center rounded-full">
|
||
{shoppingListCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||
<input
|
||
type="text"
|
||
placeholder="Search supplies..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="input w-full pl-9"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||
<button
|
||
onClick={() => setCategoryFilter('ALL')}
|
||
className={`badge flex-shrink-0 ${categoryFilter === 'ALL' ? 'badge-accent' : ''}`}
|
||
>
|
||
All
|
||
</button>
|
||
{categories.map(cat => (
|
||
<button
|
||
key={cat}
|
||
onClick={() => setCategoryFilter(cat)}
|
||
className={`badge flex-shrink-0 capitalize ${categoryFilter === cat ? 'badge-accent' : ''}`}
|
||
>
|
||
{cat.toLowerCase()}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Items */}
|
||
{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>
|
||
) : filteredItems.length === 0 ? (
|
||
<EmptyState
|
||
icon={Package}
|
||
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'No supplies yet'}
|
||
description={search || categoryFilter !== 'ALL'
|
||
? 'Try adjusting your search or filters.'
|
||
: 'Add your first item to track inventory.'}
|
||
action={
|
||
search || categoryFilter !== 'ALL' ? (
|
||
<button
|
||
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
|
||
className="text-accent hover:underline text-sm"
|
||
>
|
||
Clear filters
|
||
</button>
|
||
) : (
|
||
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
|
||
<Plus size={16} />
|
||
Add First Item
|
||
</button>
|
||
)
|
||
}
|
||
/>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{filteredItems.map(item => (
|
||
<div
|
||
key={item.id}
|
||
className={`
|
||
card p-4 flex flex-col
|
||
${item.quantity <= item.minThreshold ? 'ring-1 ring-destructive/30' : ''}
|
||
`}
|
||
>
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-primary text-sm flex items-center gap-2">
|
||
{item.name}
|
||
{item.quantity <= item.minThreshold && (
|
||
<AlertCircle size={14} className="text-destructive flex-shrink-0" />
|
||
)}
|
||
</h3>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
<span className="badge text-[10px] capitalize">{item.category.toLowerCase()}</span>
|
||
{item.location && <span className="badge text-[10px]">📍 {item.location}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-xl font-semibold text-primary tabular-nums">{item.quantity}</div>
|
||
<div className="text-xs text-tertiary">{item.unit}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-auto pt-3 border-t border-subtle flex items-center gap-2">
|
||
<div className="flex items-center gap-0.5 bg-tertiary rounded-md p-0.5">
|
||
<button
|
||
onClick={() => handleQuantityAdjust(item.id, -1)}
|
||
disabled={item.quantity <= 0}
|
||
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors disabled:opacity-50"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() => handleQuantityAdjust(item.id, 1)}
|
||
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
{item.quantity <= item.minThreshold && (
|
||
<div className="flex-1 flex gap-1">
|
||
{item.productUrl && (
|
||
<a
|
||
href={item.productUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="btn btn-ghost text-xs h-7 flex-1 text-accent hover:bg-accent-muted"
|
||
>
|
||
<ExternalLink size={12} />
|
||
Buy
|
||
</a>
|
||
)}
|
||
<button
|
||
onClick={() => handleMarkOrdered(item.id)}
|
||
className={`
|
||
btn text-xs h-7 flex-1
|
||
${item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||
? 'btn-secondary'
|
||
: 'btn-ghost text-destructive hover:bg-destructive-muted'
|
||
}
|
||
`}
|
||
>
|
||
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
|
||
? 'Ordered'
|
||
: 'Order'
|
||
}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Mobile FAB */}
|
||
<button
|
||
onClick={() => setIsAddModalOpen(true)}
|
||
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-accent text-white rounded-full shadow-lg flex items-center justify-center z-40 hover:bg-accent/90 active:scale-95 transition-all"
|
||
>
|
||
<Plus size={24} />
|
||
</button>
|
||
|
||
{/* Add Modal */}
|
||
{isAddModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 animate-fade-in">
|
||
<div className="card w-full max-w-md max-h-[90vh] overflow-y-auto animate-scale-in">
|
||
<div className="p-4 border-b border-subtle flex justify-between items-center sticky top-0 bg-primary z-10">
|
||
<h2 className="text-lg font-semibold text-primary">Add New Item</h2>
|
||
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-tertiary rounded-md">
|
||
<X size={16} className="text-tertiary" />
|
||
</button>
|
||
</div>
|
||
<form onSubmit={handleCreate} className="p-4 space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Item Name</label>
|
||
<input
|
||
required
|
||
className="input w-full"
|
||
value={newItem.name}
|
||
onChange={e => setNewItem({ ...newItem, name: e.target.value })}
|
||
placeholder="e.g. 5 Gallon Pots"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Category</label>
|
||
<select
|
||
className="input w-full"
|
||
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>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Unit</label>
|
||
<input
|
||
className="input w-full"
|
||
value={newItem.unit}
|
||
onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
|
||
placeholder="box, each..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Current Qty</label>
|
||
<input
|
||
type="number"
|
||
className="input w-full"
|
||
value={newItem.quantity}
|
||
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Min Threshold</label>
|
||
<input
|
||
type="number"
|
||
className="input w-full"
|
||
value={newItem.minThreshold}
|
||
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Vendor</label>
|
||
<input
|
||
className="input w-full"
|
||
value={newItem.vendor}
|
||
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
|
||
placeholder="e.g. Amazon"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Product URL</label>
|
||
<input
|
||
className="input w-full"
|
||
value={newItem.productUrl}
|
||
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Location</label>
|
||
<input
|
||
className="input w-full"
|
||
value={newItem.location}
|
||
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
|
||
placeholder="e.g. Storage Room A"
|
||
/>
|
||
</div>
|
||
|
||
<button type="submit" className="btn btn-primary w-full h-12 mt-4">
|
||
Create Item
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|