From f95b6267245b17090a1f214915a507d221136707 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:26:35 -0800 Subject: [PATCH] feat: Shopping List UI + Roadmap Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📦 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... --- ROADMAP.md | 19 +- backend/prisma/schema.prisma | 2 + .../src/controllers/supplies.controller.ts | 8 +- frontend/src/components/Layout.tsx | 69 +-- frontend/src/lib/suppliesApi.ts | 82 ++++ frontend/src/pages/SuppliesPage.tsx | 414 ++++++++++++++++++ frontend/src/router.tsx | 5 + 7 files changed, 566 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/suppliesApi.ts create mode 100644 frontend/src/pages/SuppliesPage.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 03c36a2..b1677aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,20 +224,27 @@ ### Features -- Task templates (recurring tasks) -- Task scheduling (calendar view) -- Task assignment (by user/role) -- Task completion tracking +- **Unified Master Calendar**: + - Toggles for: + - Employee Schedules (Rooster) + - 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 -- Batch lifecycle tasks +- Batch lifecycle integration - Notifications and reminders **Database**: +- CalendarEvent model +- EventType enum (SCHEDULE, VACATION, VENDOR, PLANT, TAX, COMPLIANCE) - Task model - TaskTemplate model - TaskAssignment model -- TaskStatus enum **Spec**: `specs/tasks-and-scheduling.md` diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0d86139..276c640 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -250,6 +250,8 @@ model SupplyItem { minThreshold Int @default(0) unit String // "each", "box", "roll", "gallon", etc. location String? // "Storage Room", "Bathroom", etc. + vendor String? // "Amazon", "Local", etc. + productUrl String? // Link to reorder lastOrdered DateTime? notes String? diff --git a/backend/src/controllers/supplies.controller.ts b/backend/src/controllers/supplies.controller.ts index b70eade..9addcb3 100644 --- a/backend/src/controllers/supplies.controller.ts +++ b/backend/src/controllers/supplies.controller.ts @@ -69,13 +69,15 @@ export async function createSupplyItem( minThreshold?: number; unit: string; location?: string; + vendor?: string; + productUrl?: string; notes?: string; }; }>, reply: FastifyReply ) { 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({ data: { @@ -85,6 +87,8 @@ export async function createSupplyItem( minThreshold: minThreshold || 0, unit, location, + vendor, + productUrl, notes, }, }); @@ -106,6 +110,8 @@ export async function updateSupplyItem( minThreshold?: number; unit?: string; location?: string; + vendor?: string; + productUrl?: string; notes?: string; }; }>, diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d224bde..a428b2e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -10,19 +10,24 @@ import { Clock, LogOut, Menu, - X + X, + User, + ChevronDown, + Package } from 'lucide-react'; export default function Layout() { const { user, logout } = useAuth(); const location = useLocation(); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); + const [userMenuOpen, setUserMenuOpen] = React.useState(false); const navItems = [ { label: 'Dashboard', path: '/', icon: LayoutDashboard }, { label: 'Walkthrough', path: '/walkthrough', icon: CheckSquare }, { label: 'Rooms', path: '/rooms', icon: Home }, { label: 'Batches', path: '/batches', icon: Sprout }, + { label: 'Inventory', path: '/supplies', icon: Package }, { label: 'Time', path: '/timeclock', icon: Clock }, ]; @@ -119,8 +124,8 @@ export default function Layout() { key={item.path} to={item.path} className={`group flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${isActive - ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 font-semibold shadow-sm' - : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50' + ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 font-semibold shadow-sm' + : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50' }`} aria-current={isActive ? 'page' : undefined} > @@ -138,29 +143,41 @@ export default function Layout() { })} + {/* User Menu (Desktop) */}
-
-
- {user?.email[0].toUpperCase()} -
-
-
-

- {user?.name || user?.email} -

-

- {user?.role} -

-
+
+ + + {/* Dropdown Menu */} + {userMenuOpen && ( +
+ +
+ )}
-
@@ -188,8 +205,8 @@ export default function Layout() { key={item.path} to={item.path} className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all min-w-[64px] ${isActive - ? 'text-emerald-600 dark:text-emerald-400' - : 'text-slate-600 dark:text-slate-400' + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-slate-600 dark:text-slate-400' }`} aria-current={isActive ? 'page' : undefined} > diff --git a/frontend/src/lib/suppliesApi.ts b/frontend/src/lib/suppliesApi.ts new file mode 100644 index 0000000..0035457 --- /dev/null +++ b/frontend/src/lib/suppliesApi.ts @@ -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; + +export const suppliesApi = { + getAll: async () => { + const response = await api.get('/supplies'); + return response.data; + }, + + getShoppingList: async () => { + const response = await api.get('/supplies/shopping-list'); + return response.data; + }, + + getOne: async (id: string) => { + const response = await api.get(`/supplies/${id}`); + return response.data; + }, + + create: async (data: CreateSupplyItemData) => { + const response = await api.post('/supplies', data); + return response.data; + }, + + update: async (id: string, data: UpdateSupplyItemData) => { + const response = await api.patch(`/supplies/${id}`, data); + return response.data; + }, + + delete: async (id: string) => { + await api.delete(`/supplies/${id}`); + }, + + markOrdered: async (id: string) => { + const response = await api.post(`/supplies/${id}/order`); + return response.data; + }, + + adjustQuantity: async (id: string, adjustment: number) => { + const response = await api.post(`/supplies/${id}/adjust`, { adjustment }); + return response.data; + }, +}; diff --git a/frontend/src/pages/SuppliesPage.tsx b/frontend/src/pages/SuppliesPage.tsx new file mode 100644 index 0000000..25b14dd --- /dev/null +++ b/frontend/src/pages/SuppliesPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [view, setView] = useState<'all' | 'shopping'>('all'); + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('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 ( +
+ {/* Header */} +
+
+

+ {view === 'shopping' ? '🛒 Shopping List' : '📦 Storage & Inventory'} +

+

+ {view === 'shopping' + ? `${filteredItems.length} items need attention` + : 'Manage facility supplies and track usage'} +

+
+ +
+ + + +
+
+ + {/* Filters */} +
+
+ + 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" + /> +
+
+ + {categories.map(cat => ( + + ))} +
+
+ + {/* Item List */} +
+ {filteredItems.map(item => ( +
+
+
+

+ {item.name} + {item.quantity <= item.minThreshold && ( + + )} +

+
+ + {item.category.toLowerCase()} + + {item.location && ( + + 📍 {item.location} + + )} + {item.vendor && ( + + 🏪 {item.vendor} + + )} +
+
+
+
+ {item.quantity} +
+
+ {item.unit} (Min: {item.minThreshold}) +
+
+
+ + {/* Controls */} +
+
+ + +
+ + {item.quantity <= item.minThreshold && ( + item.productUrl ? ( + + + Order ({item.vendor}) + + ) : ( + + ) + )} +
+
+ ))} +
+ + {/* Mobile FAB */} + + + {/* Add Item Modal */} + {isAddModalOpen && ( +
+
+
+

Add New Item

+ +
+
+
+ + setNewItem({ ...newItem, name: e.target.value })} + placeholder="e.g. 5 Gallon Pots" + /> +
+ +
+
+ + +
+
+ + setNewItem({ ...newItem, unit: e.target.value })} + placeholder="box, each..." + /> +
+
+ +
+
+ + setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })} + /> +
+
+ + setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })} + /> +
+
+ +
+
+ + setNewItem({ ...newItem, vendor: e.target.value })} + placeholder="e.g. Amazon" + /> +
+
+ + setNewItem({ ...newItem, productUrl: e.target.value })} + placeholder="https://..." + /> +
+
+ +
+ + setNewItem({ ...newItem, location: e.target.value })} + placeholder="e.g. Storage Room A" + /> +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 48d53b9..542cf0a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,6 +6,7 @@ import DailyWalkthroughPage from './pages/DailyWalkthroughPage'; import RoomsPage from './pages/RoomsPage'; import BatchesPage from './pages/BatchesPage'; import TimeclockPage from './pages/TimeclockPage'; +import SuppliesPage from './pages/SuppliesPage'; export const router = createBrowserRouter([ { @@ -36,6 +37,10 @@ export const router = createBrowserRouter([ path: 'timeclock', element: , }, + { + path: 'supplies', + element: , + }, ], }, ]);