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
+
+
+
+
+
+ )}
+
+ );
+}
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: ,
+ },
],
},
]);