feat: Shopping List UI + Roadmap Update
📦 Shopping List Feature (Phase 3A) - Full SupplyItem model with Vendor & ProductUrl - Shopping List / Inventory toggle - Add Item Modal (Category, Thresholds, Vendor info) - 'Order Now' external link logic - 'Mark Ordered' tracking - Quantity +/- adjustments 📅 Roadmap Update: - Defined Unified Master Calendar (Phase 4) - Added granular toggles (Emp Schedules, Taxes, Compliance, Plant Cycle, etc.) 🚀 Status: - Backend deployed (Schema synced) - Frontend deploying...
This commit is contained in:
parent
22574359ba
commit
f95b626724
7 changed files with 566 additions and 33 deletions
19
ROADMAP.md
19
ROADMAP.md
|
|
@ -224,20 +224,27 @@
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Task templates (recurring tasks)
|
- **Unified Master Calendar**:
|
||||||
- Task scheduling (calendar view)
|
- Toggles for:
|
||||||
- Task assignment (by user/role)
|
- Employee Schedules (Rooster)
|
||||||
- Task completion tracking
|
- Vacation Requests
|
||||||
|
- Shift/Hours Logs
|
||||||
|
- Vendor Deliveries/Meetings
|
||||||
|
- Plant Cycle (Veg/Flower/Harvest dates)
|
||||||
|
- Tax Schedule (Deadlines)
|
||||||
|
- Compliance Matters (Inspections, renewals)
|
||||||
|
- Task templating & assignment
|
||||||
- Automatic stock deduction
|
- Automatic stock deduction
|
||||||
- Batch lifecycle tasks
|
- Batch lifecycle integration
|
||||||
- Notifications and reminders
|
- Notifications and reminders
|
||||||
|
|
||||||
**Database**:
|
**Database**:
|
||||||
|
|
||||||
|
- CalendarEvent model
|
||||||
|
- EventType enum (SCHEDULE, VACATION, VENDOR, PLANT, TAX, COMPLIANCE)
|
||||||
- Task model
|
- Task model
|
||||||
- TaskTemplate model
|
- TaskTemplate model
|
||||||
- TaskAssignment model
|
- TaskAssignment model
|
||||||
- TaskStatus enum
|
|
||||||
|
|
||||||
**Spec**: `specs/tasks-and-scheduling.md`
|
**Spec**: `specs/tasks-and-scheduling.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,8 @@ model SupplyItem {
|
||||||
minThreshold Int @default(0)
|
minThreshold Int @default(0)
|
||||||
unit String // "each", "box", "roll", "gallon", etc.
|
unit String // "each", "box", "roll", "gallon", etc.
|
||||||
location String? // "Storage Room", "Bathroom", etc.
|
location String? // "Storage Room", "Bathroom", etc.
|
||||||
|
vendor String? // "Amazon", "Local", etc.
|
||||||
|
productUrl String? // Link to reorder
|
||||||
lastOrdered DateTime?
|
lastOrdered DateTime?
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,15 @@ export async function createSupplyItem(
|
||||||
minThreshold?: number;
|
minThreshold?: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
vendor?: string;
|
||||||
|
productUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { name, category, quantity, minThreshold, unit, location, notes } = request.body;
|
const { name, category, quantity, minThreshold, unit, location, vendor, productUrl, notes } = request.body;
|
||||||
|
|
||||||
const item = await prisma.supplyItem.create({
|
const item = await prisma.supplyItem.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -85,6 +87,8 @@ export async function createSupplyItem(
|
||||||
minThreshold: minThreshold || 0,
|
minThreshold: minThreshold || 0,
|
||||||
unit,
|
unit,
|
||||||
location,
|
location,
|
||||||
|
vendor,
|
||||||
|
productUrl,
|
||||||
notes,
|
notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -106,6 +110,8 @@ export async function updateSupplyItem(
|
||||||
minThreshold?: number;
|
minThreshold?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
vendor?: string;
|
||||||
|
productUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,24 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
Package
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Dashboard', path: '/', icon: LayoutDashboard },
|
{ label: 'Dashboard', path: '/', icon: LayoutDashboard },
|
||||||
{ label: 'Walkthrough', path: '/walkthrough', icon: CheckSquare },
|
{ label: 'Walkthrough', path: '/walkthrough', icon: CheckSquare },
|
||||||
{ label: 'Rooms', path: '/rooms', icon: Home },
|
{ label: 'Rooms', path: '/rooms', icon: Home },
|
||||||
{ label: 'Batches', path: '/batches', icon: Sprout },
|
{ label: 'Batches', path: '/batches', icon: Sprout },
|
||||||
|
{ label: 'Inventory', path: '/supplies', icon: Package },
|
||||||
{ label: 'Time', path: '/timeclock', icon: Clock },
|
{ label: 'Time', path: '/timeclock', icon: Clock },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -138,13 +143,18 @@ export default function Layout() {
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* User Menu (Desktop) */}
|
||||||
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
|
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-3 mb-4 px-2">
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
|
className="w-full flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors"
|
||||||
|
>
|
||||||
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-sm font-bold text-white ring-2 ring-emerald-500/20">
|
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-sm font-bold text-white ring-2 ring-emerald-500/20">
|
||||||
{user?.email[0].toUpperCase()}
|
{user?.email[0].toUpperCase()}
|
||||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-800" />
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-800" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 text-left overflow-hidden">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||||
{user?.name || user?.email}
|
{user?.name || user?.email}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -152,16 +162,23 @@ export default function Layout() {
|
||||||
{user?.role}
|
{user?.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${userMenuOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-slate-700 rounded-lg shadow-lg border border-slate-200 dark:border-slate-600 py-1 animate-scale-in">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="group w-full flex items-center justify-center gap-2 py-2 px-4 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 text-red-700 dark:text-red-400 text-sm font-medium rounded-lg transition-all"
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
aria-label="Sign out"
|
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
<LogOut className="w-4 h-4" />
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|
|
||||||
82
frontend/src/lib/suppliesApi.ts
Normal file
82
frontend/src/lib/suppliesApi.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export type SupplyCategory =
|
||||||
|
| 'FILTER'
|
||||||
|
| 'CLEANING'
|
||||||
|
| 'PPE'
|
||||||
|
| 'OFFICE'
|
||||||
|
| 'BATHROOM'
|
||||||
|
| 'KITCHEN'
|
||||||
|
| 'MAINTENANCE'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
export interface SupplyItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: SupplyCategory;
|
||||||
|
quantity: number;
|
||||||
|
minThreshold: number;
|
||||||
|
unit: string;
|
||||||
|
location?: string;
|
||||||
|
vendor?: string;
|
||||||
|
productUrl?: string;
|
||||||
|
lastOrdered?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSupplyItemData {
|
||||||
|
name: string;
|
||||||
|
category: SupplyCategory;
|
||||||
|
quantity: number;
|
||||||
|
minThreshold: number;
|
||||||
|
unit: string;
|
||||||
|
location?: string;
|
||||||
|
vendor?: string;
|
||||||
|
productUrl?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSupplyItemData = Partial<CreateSupplyItemData>;
|
||||||
|
|
||||||
|
export const suppliesApi = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await api.get<SupplyItem[]>('/supplies');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getShoppingList: async () => {
|
||||||
|
const response = await api.get<SupplyItem[]>('/supplies/shopping-list');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getOne: async (id: string) => {
|
||||||
|
const response = await api.get<SupplyItem>(`/supplies/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateSupplyItemData) => {
|
||||||
|
const response = await api.post<SupplyItem>('/supplies', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateSupplyItemData) => {
|
||||||
|
const response = await api.patch<SupplyItem>(`/supplies/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
await api.delete(`/supplies/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markOrdered: async (id: string) => {
|
||||||
|
const response = await api.post<SupplyItem>(`/supplies/${id}/order`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
adjustQuantity: async (id: string, adjustment: number) => {
|
||||||
|
const response = await api.post<SupplyItem>(`/supplies/${id}/adjust`, { adjustment });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
414
frontend/src/pages/SuppliesPage.tsx
Normal file
414
frontend/src/pages/SuppliesPage.tsx
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShoppingCart,
|
||||||
|
Package,
|
||||||
|
Filter,
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
ExternalLink
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi';
|
||||||
|
|
||||||
|
export default function SuppliesPage() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
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) => {
|
||||||
|
// 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) {
|
||||||
|
// Revert on 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: ''
|
||||||
|
});
|
||||||
|
loadItems();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkOrdered = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const updated = await suppliesApi.markOrdered(id);
|
||||||
|
setItems(current => current.map(item => item.id === id ? updated : item));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
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-6xl mx-auto space-y-6 pb-20">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
|
{view === 'shopping' ? '🛒 Shopping List' : '📦 Storage & Inventory'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm">
|
||||||
|
{view === 'shopping'
|
||||||
|
? `${filteredItems.length} items need attention`
|
||||||
|
: 'Manage facility supplies and track usage'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('all')}
|
||||||
|
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-sm font-medium transition-colors ${view === 'all'
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Inventory
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('shopping')}
|
||||||
|
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-sm font-medium transition-colors relative ${view === 'shopping'
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Shopping List
|
||||||
|
{shoppingListCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs flex items-center justify-center rounded-full">
|
||||||
|
{shoppingListCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
|
className="hidden md:flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-lg text-sm font-medium hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 bg-white dark:bg-slate-800 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search supplies, vendors..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 rounded-lg bg-slate-50 dark:bg-slate-900 border-none focus:ring-2 focus:ring-emerald-500 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 md:pb-0 no-scrollbar">
|
||||||
|
<button
|
||||||
|
onClick={() => setCategoryFilter('ALL')}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap ${categoryFilter === 'ALL'
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setCategoryFilter(cat)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap ${categoryFilter === cat
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item List */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`bg-white dark:bg-slate-800 p-5 rounded-xl border ${item.quantity <= item.minThreshold
|
||||||
|
? 'border-red-300 dark:border-red-900/50 ring-1 ring-red-100 dark:ring-red-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
} shadow-sm transition-all hover:shadow-md`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||||
|
{item.name}
|
||||||
|
{item.quantity <= item.minThreshold && (
|
||||||
|
<AlertCircle size={16} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 flex flex-wrap gap-2">
|
||||||
|
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded capitalize">
|
||||||
|
{item.category.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
{item.location && (
|
||||||
|
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded flex items-center gap-1">
|
||||||
|
📍 {item.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.vendor && (
|
||||||
|
<span className="bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded flex items-center gap-1">
|
||||||
|
🏪 {item.vendor}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||||
|
{item.quantity}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{item.unit} (Min: {item.minThreshold})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-slate-100 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-900 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuantityAdjust(item.id, -1)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm disabled:opacity-50"
|
||||||
|
disabled={item.quantity <= 0}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuantityAdjust(item.id, 1)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 shadow-sm"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.quantity <= item.minThreshold && (
|
||||||
|
item.productUrl ? (
|
||||||
|
<a
|
||||||
|
href={item.productUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 h-10 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 text-sm font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
Order ({item.vendor})
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkOrdered(item.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 h-10 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString() ? (
|
||||||
|
<>
|
||||||
|
<Check size={16} />
|
||||||
|
Ordered
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShoppingCart size={16} />
|
||||||
|
Add to list
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
|
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-emerald-600 text-white rounded-full shadow-lg flex items-center justify-center z-40 hover:bg-emerald-700 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add Item Modal */}
|
||||||
|
{isAddModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-800 z-10">
|
||||||
|
<h2 className="text-xl font-bold dark:text-white">Add New Item</h2>
|
||||||
|
<button onClick={() => setIsAddModalOpen(false)} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Item Name</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
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-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
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-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Unit</label>
|
||||||
|
<input
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
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-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Current Qty</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
value={newItem.quantity}
|
||||||
|
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Min Threshold</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
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-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Vendor (Optional)</label>
|
||||||
|
<input
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
value={newItem.vendor}
|
||||||
|
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
|
||||||
|
placeholder="e.g. Amazon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Product URL</label>
|
||||||
|
<input
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
value={newItem.productUrl}
|
||||||
|
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Location</label>
|
||||||
|
<input
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-50 dark:bg-slate-900 border dark:border-slate-700 dark:text-white"
|
||||||
|
value={newItem.location}
|
||||||
|
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
|
||||||
|
placeholder="e.g. Storage Room A"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg mt-4"
|
||||||
|
>
|
||||||
|
Create Item
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import DailyWalkthroughPage from './pages/DailyWalkthroughPage';
|
||||||
import RoomsPage from './pages/RoomsPage';
|
import RoomsPage from './pages/RoomsPage';
|
||||||
import BatchesPage from './pages/BatchesPage';
|
import BatchesPage from './pages/BatchesPage';
|
||||||
import TimeclockPage from './pages/TimeclockPage';
|
import TimeclockPage from './pages/TimeclockPage';
|
||||||
|
import SuppliesPage from './pages/SuppliesPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -36,6 +37,10 @@ export const router = createBrowserRouter([
|
||||||
path: 'timeclock',
|
path: 'timeclock',
|
||||||
element: <TimeclockPage />,
|
element: <TimeclockPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'supplies',
|
||||||
|
element: <SuppliesPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue