ca-grow-ops-manager/frontend/src/pages/SuppliesPage.tsx
fullsizemalt 71e58dd4c7
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
feat: Linear-inspired UI redesign with Space Grotesk headlines
- 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
2025-12-12 14:29:47 -08:00

401 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}