From 9ec4a32c17fcbbbd32efee024494f4de123d2971 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 12:19:08 -0500 Subject: [PATCH 01/27] Centralize env var gathering for backend --- python-backend/localmart_backend/config.py | 8 ++++++++ python-backend/localmart_backend/main.py | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 python-backend/localmart_backend/config.py diff --git a/python-backend/localmart_backend/config.py b/python-backend/localmart_backend/config.py new file mode 100644 index 0000000..cba1912 --- /dev/null +++ b/python-backend/localmart_backend/config.py @@ -0,0 +1,8 @@ +import os + +class Config: + UBER_CLIENT_ID = os.getenv('LOCALMART_UBER_DIRECT_CLIENT_ID') + UBER_CLIENT_SECRET = os.getenv('LOCALMART_UBER_DIRECT_CLIENT_SECRET') + UBER_CUSTOMER_ID = os.getenv('LOCALMART_UBER_DIRECT_CUSTOMER_ID') + STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY') + STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') \ No newline at end of file diff --git a/python-backend/localmart_backend/main.py b/python-backend/localmart_backend/main.py index 7568a0e..7c20664 100644 --- a/python-backend/localmart_backend/main.py +++ b/python-backend/localmart_backend/main.py @@ -15,6 +15,7 @@ from .uber_direct import UberDirectClient from .pocketbase_service import PocketBaseService +from .config import Config # Initialize logging logging.basicConfig(level=logging.INFO) @@ -26,9 +27,9 @@ pb_service = PocketBaseService(POCKETBASE_URL) # Get Uber Direct credentials from environment -UBER_CUSTOMER_ID = os.getenv('LOCALMART_UBER_DIRECT_CUSTOMER_ID') -UBER_CLIENT_ID = os.getenv('LOCALMART_UBER_DIRECT_CLIENT_ID') -UBER_CLIENT_SECRET = os.getenv('LOCALMART_UBER_DIRECT_CLIENT_SECRET') +UBER_CUSTOMER_ID = Config.UBER_CUSTOMER_ID +UBER_CLIENT_ID = Config.UBER_CLIENT_ID +UBER_CLIENT_SECRET = Config.UBER_CLIENT_SECRET # Initialize Uber Direct client uber_client = UberDirectClient(UBER_CUSTOMER_ID, UBER_CLIENT_ID, UBER_CLIENT_SECRET) @@ -37,10 +38,10 @@ active_deliveries: Set[str] = set() # Initialize Stripe -stripe.api_key = os.getenv('STRIPE_SECRET_KEY') +stripe.api_key = Config.STRIPE_SECRET_KEY # Initialize Stripe webhook secret -STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') +STRIPE_WEBHOOK_SECRET = Config.STRIPE_WEBHOOK_SECRET @contextmanager def user_auth_context(token: str): From 7a55167dc9c1be59836f7bc6426ac80e1cd21d74 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 17:26:20 -0500 Subject: [PATCH 02/27] Allow admins to get orders/users --- .../1739303000_updated_users_add_roles.js | 30 +++++++++++++++++++ .../1739303001_updated_users_rules.js | 20 +++++++++++++ .../1739303002_updated_orders_rules.js | 30 +++++++++++++++++++ db/pb_migrations/1739826655_updated_orders.js | 22 ++++++++++++++ db/pb_migrations/1739830958_updated_users.js | 22 ++++++++++++++ python-backend/localmart_backend/main.py | 13 ++++++-- 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 db/pb_migrations/1739303000_updated_users_add_roles.js create mode 100644 db/pb_migrations/1739303001_updated_users_rules.js create mode 100644 db/pb_migrations/1739303002_updated_orders_rules.js create mode 100644 db/pb_migrations/1739826655_updated_orders.js create mode 100644 db/pb_migrations/1739830958_updated_users.js diff --git a/db/pb_migrations/1739303000_updated_users_add_roles.js b/db/pb_migrations/1739303000_updated_users_add_roles.js new file mode 100644 index 0000000..cc86b31 --- /dev/null +++ b/db/pb_migrations/1739303000_updated_users_add_roles.js @@ -0,0 +1,30 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); + + // add roles field + collection.fields.addAt(0, new Field({ + "system": false, + "id": "roles", + "name": "roles", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "maxSelect": 3, + "values": [ + "admin", + "delivery", + "vendor" + ] + })); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); + + // remove roles field + collection.fields.removeById("roles"); + + return app.save(collection); +}); \ No newline at end of file diff --git a/db/pb_migrations/1739303001_updated_users_rules.js b/db/pb_migrations/1739303001_updated_users_rules.js new file mode 100644 index 0000000..f41f795 --- /dev/null +++ b/db/pb_migrations/1739303001_updated_users_rules.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); + + // Update view rule to allow admin users to view all user data + collection.viewRule = '@request.auth.roles ?= "admin" || @request.auth.id = id'; + + // Update list rule to allow admin users to list all users + collection.listRule = '@request.auth.roles ?= "admin" || @request.auth.id = id'; + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); + + // Revert to default rules + collection.viewRule = '@request.auth.id = id'; + collection.listRule = '@request.auth.id = id'; + + return app.save(collection); +}); \ No newline at end of file diff --git a/db/pb_migrations/1739303002_updated_orders_rules.js b/db/pb_migrations/1739303002_updated_orders_rules.js new file mode 100644 index 0000000..4698cfd --- /dev/null +++ b/db/pb_migrations/1739303002_updated_orders_rules.js @@ -0,0 +1,30 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("orders"); + + // Update view rule to allow admin users to view all orders and their expanded data + collection.viewRule = '@request.auth.roles ?= "admin" || user.id = @request.auth.id'; + + // Update list rule to allow admin users to list all orders + collection.listRule = '@request.auth.roles ?= "admin" || user.id = @request.auth.id'; + + // Update expand rule to allow admin users to expand all relations + collection.options = { + ...collection.options, + expandRule: '@request.auth.roles ?= "admin" || user.id = @request.auth.id' + }; + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("orders"); + + // Revert to default rules + collection.viewRule = 'user.id = @request.auth.id'; + collection.listRule = 'user.id = @request.auth.id'; + collection.options = { + ...collection.options, + expandRule: 'user.id = @request.auth.id' + }; + + return app.save(collection); +}); \ No newline at end of file diff --git a/db/pb_migrations/1739826655_updated_orders.js b/db/pb_migrations/1739826655_updated_orders.js new file mode 100644 index 0000000..bf83abd --- /dev/null +++ b/db/pb_migrations/1739826655_updated_orders.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3527180448") + + // update collection data + unmarshal({ + "listRule": "@request.auth.roles ?~ \"admin\" || user.id = @request.auth.id", + "viewRule": "@request.auth.roles ?~ \"admin\" || user.id = @request.auth.id" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3527180448") + + // update collection data + unmarshal({ + "listRule": "@request.auth.roles ?= \"admin\" || user.id = @request.auth.id", + "viewRule": "@request.auth.roles ?= \"admin\" || user.id = @request.auth.id" + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739830958_updated_users.js b/db/pb_migrations/1739830958_updated_users.js new file mode 100644 index 0000000..c72c8cd --- /dev/null +++ b/db/pb_migrations/1739830958_updated_users.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // update collection data + unmarshal({ + "listRule": "@request.auth.roles ?~ \"admin\" || @request.auth.id = id", + "viewRule": "@request.auth.roles ?~ \"admin\" || @request.auth.id = id" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_") + + // update collection data + unmarshal({ + "listRule": "@request.auth.roles ?= \"admin\" || @request.auth.id = id", + "viewRule": "@request.auth.roles ?= \"admin\" || @request.auth.id = id" + }, collection) + + return app.save(collection) +}) diff --git a/python-backend/localmart_backend/main.py b/python-backend/localmart_backend/main.py index 7c20664..f36ffe2 100644 --- a/python-backend/localmart_backend/main.py +++ b/python-backend/localmart_backend/main.py @@ -311,12 +311,16 @@ async def get_user_orders(request: Request): decoded_token = decode_jwt(token) user_id = decoded_token['id'] + # if user is admin, then filter by user, otherwise don't + user = pb_service.get_user_from_token(token) + is_admin = 'admin' in (getattr(user, 'roles', []) or []) + with user_auth_context(token): # Get user's orders orders = pb_service.get_list( 'orders', query_params={ - "filter": f'user = "{user_id}"', + "filter": f'user = "{user_id}"' if not is_admin else '', "sort": "-created", "expand": "order_items(order).store_item,order_items(order).store_item.store,user" } @@ -358,8 +362,13 @@ async def get_user_orders(request: Request): 'price': item.price_at_time }) - # Get user info from expanded user record user = order.expand.get('user', {}) + + if not user: + print(f"Order: {order}") + print(f"Order expand: {order.expand}") + + # Get user info from expanded user record customer_name = f"{user.first_name} {user.last_name}".strip() if user else "Unknown" # Format user's address From 08e14c7161b859965ff10b338077361677d5c950 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 17:31:25 -0500 Subject: [PATCH 03/27] Only show the admin links to admin users --- frontend/src/app/contexts/auth.tsx | 11 ++++++++ frontend/src/components/Header.tsx | 35 ++++++++++++++---------- python-backend/localmart_backend/main.py | 2 ++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/contexts/auth.tsx b/frontend/src/app/contexts/auth.tsx index 59c74b8..e235686 100644 --- a/frontend/src/app/contexts/auth.tsx +++ b/frontend/src/app/contexts/auth.tsx @@ -8,6 +8,7 @@ interface User { email: string; name: string; token: string; + roles: string[]; } interface AuthContextType { @@ -44,12 +45,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const data = await response.json(); + console.log('Login response data:', data); // Debug log + console.log('User roles from backend:', data.user.roles); // Debug log const userData = { id: data.user.id, email: data.user.email, name: `${data.user.first_name} ${data.user.last_name}`.trim(), token: data.token, + roles: data.user.roles || [], }; + console.log('Final user data with roles:', userData); // Debug log setUser(userData); localStorage.setItem('auth', JSON.stringify(userData)); @@ -84,6 +89,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { email: data.user.email, name: `${data.user.first_name} ${data.user.last_name}`.trim(), token: data.token, + roles: data.user.roles || [], }; setUser(userData); @@ -108,4 +114,9 @@ export function useAuth() { throw new Error('useAuth must be used within an AuthProvider'); } return context; +} + +export function useIsAdmin() { + const { user } = useAuth(); + return user?.roles?.includes('admin') || false; } \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b4f7d0e..19f5d82 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useAuth } from '../app/contexts/auth' +import { useAuth, useIsAdmin } from '../app/contexts/auth' import { useCart } from '../app/contexts/cart' import Link from 'next/link' import { useState, useEffect } from 'react' @@ -11,6 +11,7 @@ import { config } from '@/config' export default function Header() { const { user, logout } = useAuth() + const isAdmin = useIsAdmin() const { totalItems } = useCart() const [showOrderHistory, setShowOrderHistory] = useState(false) const [showCart, setShowCart] = useState(false) @@ -82,20 +83,24 @@ export default function Header() { {user && ( <> - - database - - - orders - + {isAdmin && ( + <> + + database + + + orders + + + )} )} diff --git a/python-backend/localmart_backend/main.py b/python-backend/localmart_backend/main.py index f36ffe2..bf4c226 100644 --- a/python-backend/localmart_backend/main.py +++ b/python-backend/localmart_backend/main.py @@ -474,6 +474,7 @@ async def signup(user: UserSignup): "email": auth_data.record.email, "first_name": auth_data.record.first_name, "last_name": auth_data.record.last_name, + "roles": getattr(auth_data.record, 'roles', []), } } except Exception as e: @@ -501,6 +502,7 @@ async def login(user: UserLogin): "email": auth_data.record.email, "first_name": auth_data.record.first_name, "last_name": auth_data.record.last_name, + "roles": getattr(auth_data.record, 'roles', []), } } except Exception as e: From b59777d1636580452d6d2a0a2eece3571f79373e Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:11:46 -0500 Subject: [PATCH 04/27] Add a basic vendor dashboard --- .../1739303004_created_store_roles.js | 78 +++++ frontend/src/app/hooks/useStoreRoles.ts | 56 ++++ frontend/src/app/store/[id]/StoreContent.tsx | 16 +- .../src/app/store/[id]/dashboard/page.tsx | 307 ++++++++++++++++++ frontend/src/components/Header.tsx | 2 +- python-backend/localmart_backend/main.py | 149 ++++++++- 6 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 db/pb_migrations/1739303004_created_store_roles.js create mode 100644 frontend/src/app/hooks/useStoreRoles.ts create mode 100644 frontend/src/app/store/[id]/dashboard/page.tsx diff --git a/db/pb_migrations/1739303004_created_store_roles.js b/db/pb_migrations/1739303004_created_store_roles.js new file mode 100644 index 0000000..2129ea1 --- /dev/null +++ b/db/pb_migrations/1739303004_created_store_roles.js @@ -0,0 +1,78 @@ +/// +migrate((app) => { + const collection = new Collection({ + "id": "store_roles", + "name": "store_roles", + "type": "base", + "system": false, + "fields": [ + { + "id": "user_relation", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "system": false, + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "maxSelect": 1, + "minSelect": 1 + }, + { + "id": "store_relation", + "name": "store", + "type": "relation", + "required": true, + "presentable": false, + "system": false, + "cascadeDelete": true, + "collectionId": "pbc_3800236418", + "maxSelect": 1, + "minSelect": 1 + }, + { + "id": "store_role", + "name": "role", + "type": "select", + "required": true, + "presentable": false, + "system": false, + "values": ["admin", "staff"], + "maxSelect": 1 + }, + { + "id": "created", + "name": "created", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": false + }, + { + "id": "updated", + "name": "updated", + "type": "autodate", + "required": false, + "presentable": false, + "system": false, + "hidden": false, + "onCreate": true, + "onUpdate": true + } + ], + "indexes": ["CREATE UNIQUE INDEX idx_unique_user_store ON store_roles (user, store)"], + "listRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "createRule": "@request.auth.roles.admin = true", + "updateRule": "@request.auth.roles.admin = true", + "deleteRule": "@request.auth.roles.admin = true" + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("store_roles"); + return app.delete(collection); +}); \ No newline at end of file diff --git a/frontend/src/app/hooks/useStoreRoles.ts b/frontend/src/app/hooks/useStoreRoles.ts new file mode 100644 index 0000000..6212777 --- /dev/null +++ b/frontend/src/app/hooks/useStoreRoles.ts @@ -0,0 +1,56 @@ +import { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/auth'; +import { config } from '@/config'; + +export function useStoreRoles(storeId: string) { + const { user } = useAuth(); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRoles = async () => { + if (!user?.token) { + return; + } + + try { + const response = await fetch( + `${config.apiUrl}/api/v0/stores/${storeId}/roles`, + { + headers: { + 'Authorization': `Bearer ${user.token}` + } + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch store roles'); + } + + const data = await response.json(); + setRoles(data.roles); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch store roles'); + setRoles([]); + } finally { + setLoading(false); + } + }; + + fetchRoles(); + }, [user, storeId]); + + const isAdmin = roles.includes('admin'); + const isStaff = roles.includes('staff'); + + console.log('roles', roles); + + return { + roles, + isAdmin, + isStaff, + loading, + error + }; +} \ No newline at end of file diff --git a/frontend/src/app/store/[id]/StoreContent.tsx b/frontend/src/app/store/[id]/StoreContent.tsx index 42ec788..4063772 100644 --- a/frontend/src/app/store/[id]/StoreContent.tsx +++ b/frontend/src/app/store/[id]/StoreContent.tsx @@ -7,6 +7,8 @@ import { FaInstagram, FaFacebook, FaXTwitter } from 'react-icons/fa6'; import { SiBluesky } from 'react-icons/si'; import { ClockIcon, MapPinIcon } from '@heroicons/react/24/outline'; import { config } from '@/config'; +import { useStoreRoles } from '@/app/hooks/useStoreRoles'; +import Link from 'next/link'; interface Store { id: string; @@ -30,6 +32,7 @@ interface StoreItem { export default function StoreContent({ storeId }: { storeId: string }) { const { addItem } = useCart(); + const { isAdmin } = useStoreRoles(storeId); const [store, setStore] = useState(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -105,15 +108,24 @@ export default function StoreContent({ storeId }: { storeId: string }) { return (
{/* Hero Section */} -
+
{/* Store Name and Type */} -
+

{store.name}

Local market & grocery

+ {/* Add dashboard link for store admins */} + {isAdmin && ( + + View Dashboard + + )} {/* Social Links */}
}) { + const { id: storeId } = use(params); + const { user } = useAuth(); + const { isAdmin, loading: rolesLoading } = useStoreRoles(storeId); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const router = useRouter(); + + useEffect(() => { + // Only redirect if we're done loading roles, have a user, and we're not an admin + if (!rolesLoading && user && !isAdmin) { + router.push(`/store/${storeId}`); + toast.error("You don't have permission to access this page"); + } + }, [isAdmin, rolesLoading, router, storeId, user]); + + useEffect(() => { + const fetchOrders = async () => { + if (!user?.token || rolesLoading) return; // Don't fetch if still loading roles + + try { + const response = await fetch(`${config.apiUrl}/api/v0/stores/${storeId}/orders`, { + headers: { + 'Authorization': `Bearer ${user.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch orders'); + } + + const data = await response.json(); + setOrders(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch orders'); + } finally { + setLoading(false); + } + }; + + if (isAdmin) { + fetchOrders(); + } + }, [user, storeId, isAdmin, rolesLoading]); + + const updateOrderStatus = async (orderId: string, newStatus: string) => { + try { + const response = await fetch(`${config.apiUrl}/api/v0/orders/${orderId}/status`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${user?.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ status: newStatus }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to update order status'); + } + + setOrders(currentOrders => + currentOrders.map(order => + order.id === orderId + ? { ...order, status: newStatus } + : order + ) + ); + + toast.success('Order status updated'); + } catch (err) { + console.error('Status update error:', err); + toast.error('Failed to update order status'); + } + }; + + if (rolesLoading || loading) { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

Error

+

{error}

+
+
+
+ ); + } + + return ( +
+
+

Store Orders Dashboard

+ +
+ + + + + + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + + + + ))} + +
+ Order ID + + Date & Time + + Customer + + Phone + + Delivery Address + + Status + + Payment + + Items + + Total +
+ #{order.id.slice(-6)} + + {formatDateTime(order.created)} + + {order.customer_name} + + {order.customer_phone || '-'} + + {order.delivery_address ? ( + <> +
+ {order.delivery_address.street_address.filter(Boolean).join(', ')} +
+
+ {order.delivery_address.city}, {order.delivery_address.state} {order.delivery_address.zip_code} +
+ + ) : ( + No address available + )} +
+
+ + {statusLabels[order.status as keyof typeof statusLabels]} + + +
+
+ + {paymentStatusLabels[order.payment_status as keyof typeof paymentStatusLabels]} + + +
+ {order.stores.map((store) => ( +
+ {store.items.map((item) => ( +
+ {item.quantity}x {item.name} (${item.price.toFixed(2)}) +
+ ))} +
+ ))} +
+
+ ${order.total_amount.toFixed(2)} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 19f5d82..04b706b 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -74,7 +74,7 @@ export default function Header() { } return ( -
+
diff --git a/python-backend/localmart_backend/main.py b/python-backend/localmart_backend/main.py index bf4c226..5932599 100644 --- a/python-backend/localmart_backend/main.py +++ b/python-backend/localmart_backend/main.py @@ -322,7 +322,7 @@ async def get_user_orders(request: Request): query_params={ "filter": f'user = "{user_id}"' if not is_admin else '', "sort": "-created", - "expand": "order_items(order).store_item,order_items(order).store_item.store,user" + "expand": "order_items_via_order.store_item,order_items_via_order.store_item.store,user" } ) @@ -333,7 +333,7 @@ async def get_user_orders(request: Request): stores_dict = {} # Get all order items for this order - order_items = order.expand.get('order_items(order)', []) + order_items = order.expand.get('order_items_via_order', []) for item in order_items: if not hasattr(item, 'expand') or not item.expand.get('store_item'): @@ -841,3 +841,148 @@ async def stripe_webhook(request: Request): logger.error(json.dumps(payment_intent, indent=2)) return {"status": "success"} + +@app.get("/api/v0/stores/{store_id}/roles", response_model=Dict) +async def get_store_roles(store_id: str, request: Request): + """Get the current user's roles for a specific store""" + token = get_token_from_request(request) + decoded_token = decode_jwt(token) + user_id = decoded_token['id'] + + with user_auth_context(token): + try: + # Check if user is a global admin first + user = pb_service.get_user_from_token(token) + if 'admin' in (getattr(user, 'roles', []) or []): + return {"roles": ["admin"]} + + # Get store roles for this user and store + store_roles = pb_service.get_list( + 'store_roles', + query_params={ + "filter": f'user = "{user_id}" && store = "{store_id}"', + "expand": "role" + } + ) + + roles = [sr.role for sr in store_roles.items] + return {"roles": roles} + except Exception as e: + logger.error(f"Error fetching store roles: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to fetch store roles: {str(e)}" + ) + +@app.get("/api/v0/stores/{store_id}/orders", response_model=List[Dict]) +async def get_store_orders(store_id: str, request: Request): + """Get orders for a specific store (requires store admin role)""" + token = get_token_from_request(request) + decoded_token = decode_jwt(token) + user_id = decoded_token['id'] + + with user_auth_context(token): + try: + # Check if user is a global admin or store admin + user = pb_service.get_user_from_token(token) + is_global_admin = 'admin' in (getattr(user, 'roles', []) or []) + + if not is_global_admin: + # Check store roles + store_roles = pb_service.get_list( + 'store_roles', + query_params={ + "filter": f'user = "{user_id}" && store = "{store_id}"' + } + ) + + if not any(sr.role == 'admin' for sr in store_roles.items): + raise HTTPException( + status_code=403, + detail="You don't have permission to view this store's orders" + ) + + # Get all orders with expanded order items and store items + orders = pb_service.get_list( + 'orders', + query_params={ + "sort": "-created", + "expand": "order_items_via_order.store_item,order_items_via_order.store_item.store,user" + } + ) + + # Format orders for response + formatted_orders = [] + for order in orders.items: + stores_dict = {} + order_items = order.expand.get('order_items_via_order', []) + + # Check if any order items belong to this store + has_store_items = False + for item in order_items: + if not hasattr(item, 'expand') or not item.expand.get('store_item'): + continue + + store_item = item.expand['store_item'] + if not hasattr(store_item, 'expand') or not store_item.expand.get('store'): + continue + + store = store_item.expand['store'] + if store.id != store_id: # Skip items not from this store + continue + + has_store_items = True + store_id_key = store.id + if store_id_key not in stores_dict: + stores_dict[store_id_key] = { + 'store': { + 'id': store.id, + 'name': store.name + }, + 'items': [] + } + + stores_dict[store_id_key]['items'].append({ + 'id': item.id, + 'name': store_item.name, + 'quantity': item.quantity, + 'price': item.price_at_time + }) + + # Only include orders that have items from this store + if has_store_items: + user = order.expand.get('user', {}) + customer_name = f"{user.first_name} {user.last_name}".strip() if user else "Unknown" + + delivery_address = { + 'street_address': [user.street_1] + ([user.street_2] if user.street_2 else []), + 'city': user.city, + 'state': user.state, + 'zip_code': user.zip, + 'country': 'US' + } if user else None + + formatted_order = { + 'id': order.id, + 'created': order.created, + 'status': order.status, + 'payment_status': order.payment_status, + 'delivery_fee': order.delivery_fee, + 'total_amount': order.total_amount, + 'tax_amount': order.tax_amount, + 'customer_name': customer_name, + 'customer_phone': getattr(user, 'phone_number', None), + 'delivery_address': delivery_address, + 'stores': list(stores_dict.values()) + } + formatted_orders.append(formatted_order) + + return formatted_orders + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching store orders: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to fetch store orders: {str(e)}" + ) From d2a8676e041d6ab303457edfd306663dc4dd8a79 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:16:46 -0500 Subject: [PATCH 05/27] Update cart modal to show order cost details --- frontend/src/components/CartModal.tsx | 98 +++++++++++++++++++++------ 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/CartModal.tsx b/frontend/src/components/CartModal.tsx index d298338..6e28381 100644 --- a/frontend/src/components/CartModal.tsx +++ b/frontend/src/components/CartModal.tsx @@ -34,6 +34,13 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { const [isCheckingOut, setIsCheckingOut] = useState(false); const [savedCards, setSavedCards] = useState([]); const [selectedCardId, setSelectedCardId] = useState(''); + const [showCheckoutConfirm, setShowCheckoutConfirm] = useState(false); + const [orderSummary, setOrderSummary] = useState<{ + subtotalAmount: number; + taxAmount: number; + deliveryFee: number; + totalAmount: number; + } | null>(null); // Fetch store details when items change useEffect(() => { @@ -103,7 +110,7 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { } }, [isOpen, user]); - const handleCheckout = async () => { + const handleCheckoutClick = () => { if (!user) { toast.error('Please log in to checkout'); return; @@ -119,16 +126,30 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { return; } + // Calculate amounts + const subtotalAmount = items.reduce((sum, item) => sum + (item.price * item.quantity), 0); + const taxRate = 0.08875; // Example tax rate (8.875%) + const taxAmount = subtotalAmount * taxRate; + const deliveryFee = 5.99; // Example delivery fee + const totalAmount = subtotalAmount + taxAmount + deliveryFee; + + setOrderSummary({ + subtotalAmount, + taxAmount, + deliveryFee, + totalAmount + }); + setShowCheckoutConfirm(true); + }; + + const handleCheckout = async () => { + if (!user || !store || !selectedCardId || !orderSummary) { + return; + } + setIsCheckingOut(true); try { - // Calculate amounts - const subtotalAmount = items.reduce((sum, item) => sum + (item.price * item.quantity), 0); - const taxRate = 0.08875; // Example tax rate (8.875%) - const taxAmount = subtotalAmount * taxRate; - const deliveryFee = 5.99; // Example delivery fee - const totalAmount = subtotalAmount + taxAmount + deliveryFee; - // Create order payload const orderData = { user_id: user.id, @@ -138,10 +159,10 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { quantity: item.quantity, price: item.price })), - subtotal_amount: subtotalAmount, - tax_amount: taxAmount, - delivery_fee: deliveryFee, - total_amount: totalAmount, + subtotal_amount: orderSummary.subtotalAmount, + tax_amount: orderSummary.taxAmount, + delivery_fee: orderSummary.deliveryFee, + total_amount: orderSummary.totalAmount, payment_method_id: selectedCardId, delivery_address: { street_address: ["123 Main St"], // TODO: Get from user input @@ -182,6 +203,7 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { toast.error('Failed to place order. Please try again.'); } finally { setIsCheckingOut(false); + setShowCheckoutConfirm(false); } }; @@ -321,13 +343,51 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { Shipping and taxes will be calculated at checkout.

- + {showCheckoutConfirm ? ( +
+
+
+ Subtotal + ${orderSummary?.subtotalAmount.toFixed(2)} +
+
+ Tax (8.875%) + ${orderSummary?.taxAmount.toFixed(2)} +
+
+ Delivery Fee + ${orderSummary?.deliveryFee.toFixed(2)} +
+
+ Total + ${orderSummary?.totalAmount.toFixed(2)} +
+
+
+ + +
+
+ ) : ( + + )}
From 97b93157fb7150c35bae74b5625d31eba150a9e6 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:21:24 -0500 Subject: [PATCH 06/27] Reset the checkout modal when focus is lost --- frontend/src/components/CartModal.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/components/CartModal.tsx b/frontend/src/components/CartModal.tsx index 6e28381..46add91 100644 --- a/frontend/src/components/CartModal.tsx +++ b/frontend/src/components/CartModal.tsx @@ -42,6 +42,22 @@ export default function CartModal({ isOpen, onClose }: CartModalProps) { totalAmount: number; } | null>(null); + // Reset checkout state when modal closes + useEffect(() => { + if (!isOpen) { + setShowCheckoutConfirm(false); + setOrderSummary(null); + setIsCheckingOut(false); + } + }, [isOpen]); + + // Reset checkout state when items change + useEffect(() => { + setShowCheckoutConfirm(false); + setOrderSummary(null); + setIsCheckingOut(false); + }, [items]); + // Fetch store details when items change useEffect(() => { const fetchStore = async () => { From ed26548491552a72c2613028548fde8b88e9a185 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:31:46 -0500 Subject: [PATCH 07/27] Add metrics to vendor dashboard --- .../src/app/store/[id]/dashboard/page.tsx | 194 ++++++++++++++---- 1 file changed, 152 insertions(+), 42 deletions(-) diff --git a/frontend/src/app/store/[id]/dashboard/page.tsx b/frontend/src/app/store/[id]/dashboard/page.tsx index 93c4b8e..af1cae3 100644 --- a/frontend/src/app/store/[id]/dashboard/page.tsx +++ b/frontend/src/app/store/[id]/dashboard/page.tsx @@ -7,6 +7,7 @@ import { toast } from 'react-hot-toast'; import { config } from '@/config'; import { useRouter } from 'next/navigation'; import { use } from 'react'; +import { CurrencyDollarIcon, ShoppingBagIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline'; interface OrderItem { id: string; @@ -85,15 +86,35 @@ function formatDateTime(isoString: string) { }); } +function calculateMetrics(orders: Order[]) { + const totalOrders = orders.length; + const totalRevenue = orders.reduce((sum, order) => sum + order.total_amount, 0); + const pendingOrders = orders.filter(order => order.status === 'pending').length; + const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0; + + return { + totalOrders, + totalRevenue, + pendingOrders, + avgOrderValue + }; +} + export default function StoreDashboard({ params }: { params: Promise<{ id: string }> }) { const { id: storeId } = use(params); const { user } = useAuth(); const { isAdmin, loading: rolesLoading } = useStoreRoles(storeId); const [orders, setOrders] = useState([]); + const [filteredOrders, setFilteredOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [store, setStore] = useState<{ name: string } | null>(null); + const [statusFilter, setStatusFilter] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); const router = useRouter(); + const metrics = calculateMetrics(orders); + useEffect(() => { // Only redirect if we're done loading roles, have a user, and we're not an admin if (!rolesLoading && user && !isAdmin) { @@ -131,36 +152,45 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin } }, [user, storeId, isAdmin, rolesLoading]); - const updateOrderStatus = async (orderId: string, newStatus: string) => { - try { - const response = await fetch(`${config.apiUrl}/api/v0/orders/${orderId}/status`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${user?.token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ status: newStatus }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || 'Failed to update order status'); + // Fetch store details + useEffect(() => { + const fetchStore = async () => { + try { + const response = await fetch(`${config.apiUrl}/api/v0/stores/${storeId}`); + if (!response.ok) { + throw new Error('Failed to fetch store details'); + } + const data = await response.json(); + setStore(data); + } catch (err) { + console.error('Error fetching store:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch store details'); } + }; - setOrders(currentOrders => - currentOrders.map(order => - order.id === orderId - ? { ...order, status: newStatus } - : order - ) - ); + fetchStore(); + }, [storeId]); - toast.success('Order status updated'); - } catch (err) { - console.error('Status update error:', err); - toast.error('Failed to update order status'); + // Filter orders when status filter or search term changes + useEffect(() => { + let result = [...orders]; + + // Apply status filter + if (statusFilter !== 'all') { + result = result.filter(order => order.status === statusFilter); } - }; + + // Apply search filter (on order ID or customer name) + if (searchTerm) { + const lowercaseSearch = searchTerm.toLowerCase(); + result = result.filter(order => + order.id.toLowerCase().includes(lowercaseSearch) || + order.customer_name.toLowerCase().includes(lowercaseSearch) + ); + } + + setFilteredOrders(result); + }, [orders, statusFilter, searchTerm]); if (rolesLoading || loading) { return ( @@ -195,8 +225,99 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin return (
-

Store Orders Dashboard

+ {/* Header Section */} +
+
+
+ {store && ( +

{store.name}

+ )} +

Vendor Dashboard

+
+ + {/* Metrics Grid */} +
+ {/* Pending Orders */} +
+
+
+ +
+

Pending Orders

+
+

{metrics.pendingOrders}

+
+ + {/* Total Orders */} +
+
+
+ +
+

Total Orders

+
+

{metrics.totalOrders}

+
+ + {/* Average Order Value */} +
+
+
+ +
+

Avg. Order Value

+
+

${metrics.avgOrderValue.toFixed(2)}

+
+ + {/* Total Revenue */} +
+
+
+ +
+

Total Revenue

+
+

${metrics.totalRevenue.toFixed(2)}

+
+
+
+
+ + {/* Filters Section */} +
+

Orders

+
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 rounded-lg border border-[#2A9D8F]/20 bg-white focus:ring-2 focus:ring-[#2A9D8F] focus:border-transparent" + /> +
+ +
+
+ {/* Results Summary */} +
+ Showing {filteredOrders.length} {filteredOrders.length === 1 ? 'order' : 'orders'} + {statusFilter !== 'all' && ` with status "${statusLabels[statusFilter as keyof typeof statusLabels]}"`} + {searchTerm && ` matching "${searchTerm}"`} +
+ + {/* Table */}
@@ -231,7 +352,7 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin - {orders.map((order) => ( + {filteredOrders.map((order) => (
#{order.id.slice(-6)} @@ -260,20 +381,9 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin )} -
- - {statusLabels[order.status as keyof typeof statusLabels]} - - -
+ + {statusLabels[order.status as keyof typeof statusLabels]} +
From 66c9aab2c362ca2f1ae747aaa5e4fd57a257965d Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:37:49 -0500 Subject: [PATCH 08/27] Add metrics to overall dashboard --- Makefile | 2 +- frontend/package-lock.json | 348 +++++++++++++++++- frontend/package.json | 3 +- frontend/src/app/orders/page.tsx | 151 +++++++- .../src/app/store/[id]/dashboard/page.tsx | 74 ++++ 5 files changed, 573 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index dbbf0a2..0addab2 100644 --- a/Makefile +++ b/Makefile @@ -41,4 +41,4 @@ deploy-meilisearch-prod: deploy-search-prod: cd search && fly deploy --config fly.prod.toml -deploy-all-prod: deploy-frontend-prod deploy-backend-prod deploy-pocketbase-prod deploy-meilisearch-prod deploy-search-prod \ No newline at end of file +deploy-all-prod: deploy-frontend-prod deploy-backend-prod deploy-pocketbase-prod deploy-meilisearch-prod deploy-search-prod diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3fe5599..07ceea0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.5.1", - "react-icons": "^5.4.0" + "react-icons": "^5.4.0", + "recharts": "^2.15.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -45,6 +46,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", @@ -1140,6 +1153,69 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2052,6 +2128,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2131,6 +2328,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2211,6 +2414,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2894,6 +3107,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2901,6 +3120,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -3444,6 +3672,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4068,6 +4305,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4869,6 +5112,37 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4892,6 +5166,44 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4915,6 +5227,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -5740,6 +6058,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5940,6 +6264,28 @@ "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2118c43..079e290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.5.1", - "react-icons": "^5.4.0" + "react-icons": "^5.4.0", + "recharts": "^2.15.1" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/frontend/src/app/orders/page.tsx b/frontend/src/app/orders/page.tsx index c861881..6f38288 100644 --- a/frontend/src/app/orders/page.tsx +++ b/frontend/src/app/orders/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/app/contexts/auth'; import { toast } from 'react-hot-toast'; import { config } from '@/config'; +import { CurrencyDollarIcon, ShoppingBagIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; interface OrderItem { id: string; @@ -85,12 +87,61 @@ const formatDateTime = (isoString: string) => { }).format(utcDate); }; +function calculateMetrics(orders: Order[]) { + const totalOrders = orders.length; + const totalRevenue = orders.reduce((sum, order) => sum + order.total_amount, 0); + const pendingOrders = orders.filter(order => order.status === 'pending').length; + const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0; + + return { + totalOrders, + totalRevenue, + pendingOrders, + avgOrderValue + }; +} + +function calculateDailyOrderCounts(orders: Order[]) { + // Get date range for last 30 days + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 29); // 30 days including today + + // Create array of last 30 days + const days = Array.from({ length: 30 }, (_, i) => { + const date = new Date(thirtyDaysAgo); + date.setDate(thirtyDaysAgo.getDate() + i); + return date; + }); + + // Initialize counts for each day + const dailyCounts = days.map(date => ({ + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + count: 0, + timestamp: date.getTime() // for sorting + })); + + // Count orders for each day + orders.forEach(order => { + const orderDate = new Date(order.created); + const orderDateStr = orderDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const dayData = dailyCounts.find(d => d.date === orderDateStr); + if (dayData) { + dayData.count++; + } + }); + + return dailyCounts; +} + export default function OrdersDashboard() { const { user } = useAuth(); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const metrics = calculateMetrics(orders); + useEffect(() => { const fetchOrders = async () => { try { @@ -182,8 +233,104 @@ export default function OrdersDashboard() { return (
-

Orders Dashboard

- + {/* Header Section */} +
+
+
+

Orders Dashboard

+

All Orders Overview

+
+ + {/* Metrics Grid */} +
+ {/* Pending Orders */} +
+
+
+ +
+

Pending Orders

+
+

{metrics.pendingOrders}

+
+ + {/* Total Orders */} +
+
+
+ +
+

Total Orders

+
+

{metrics.totalOrders}

+
+ + {/* Average Order Value */} +
+
+
+ +
+

Avg. Order Value

+
+

${metrics.avgOrderValue.toFixed(2)}

+
+ + {/* Total Revenue */} +
+
+
+ +
+

Total Revenue

+
+

${metrics.totalRevenue.toFixed(2)}

+
+
+
+
+ + {/* Orders Chart */} +
+
+

Daily Orders (Last 30 Days)

+
+ + + + + `Orders on ${label}`} + /> + + + +
+
+
+ + {/* Orders Table */}
diff --git a/frontend/src/app/store/[id]/dashboard/page.tsx b/frontend/src/app/store/[id]/dashboard/page.tsx index af1cae3..9d3002a 100644 --- a/frontend/src/app/store/[id]/dashboard/page.tsx +++ b/frontend/src/app/store/[id]/dashboard/page.tsx @@ -8,6 +8,7 @@ import { config } from '@/config'; import { useRouter } from 'next/navigation'; import { use } from 'react'; import { CurrencyDollarIcon, ShoppingBagIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; interface OrderItem { id: string; @@ -100,6 +101,39 @@ function calculateMetrics(orders: Order[]) { }; } +function calculateDailyOrderCounts(orders: Order[]) { + // Get date range for last 30 days + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 29); // 30 days including today + + // Create array of last 30 days + const days = Array.from({ length: 30 }, (_, i) => { + const date = new Date(thirtyDaysAgo); + date.setDate(thirtyDaysAgo.getDate() + i); + return date; + }); + + // Initialize counts for each day + const dailyCounts = days.map(date => ({ + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + count: 0, + timestamp: date.getTime() // for sorting + })); + + // Count orders for each day + orders.forEach(order => { + const orderDate = new Date(order.created); + const orderDateStr = orderDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const dayData = dailyCounts.find(d => d.date === orderDateStr); + if (dayData) { + dayData.count++; + } + }); + + return dailyCounts; +} + export default function StoreDashboard({ params }: { params: Promise<{ id: string }> }) { const { id: storeId } = use(params); const { user } = useAuth(); @@ -284,6 +318,46 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin + {/* Orders Chart */} +
+
+

Daily Orders (Last 30 Days)

+
+ + + + + `Orders on ${label}`} + /> + + + +
+
+
+ {/* Filters Section */}

Orders

From 00d1e3b0ad688f887cdb393fb7e9a585ec3c7500 Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:48:32 -0500 Subject: [PATCH 09/27] Color code the all orders dash --- frontend/src/app/orders/page.tsx | 236 ++++++++++++++++++++----------- 1 file changed, 150 insertions(+), 86 deletions(-) diff --git a/frontend/src/app/orders/page.tsx b/frontend/src/app/orders/page.tsx index 6f38288..a60c75b 100644 --- a/frontend/src/app/orders/page.tsx +++ b/frontend/src/app/orders/page.tsx @@ -5,7 +5,7 @@ import { useAuth } from '@/app/contexts/auth'; import { toast } from 'react-hot-toast'; import { config } from '@/config'; import { CurrencyDollarIcon, ShoppingBagIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'; interface OrderItem { id: string; @@ -43,6 +43,12 @@ interface Order { }>; } +interface DailyCount { + date: string; + timestamp: number; + [storeId: string]: string | number; // Allow string indexes for store IDs +} + const statusColors = { pending: 'bg-yellow-100 text-yellow-800', confirmed: 'bg-blue-100 text-blue-800', @@ -114,24 +120,65 @@ function calculateDailyOrderCounts(orders: Order[]) { return date; }); - // Initialize counts for each day - const dailyCounts = days.map(date => ({ - date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - count: 0, - timestamp: date.getTime() // for sorting - })); + // Get unique stores from all orders + const storeIds = new Set(); + const storeNames = new Map(); + orders.forEach(order => { + order.stores.forEach(store => { + storeIds.add(store.store.id); + storeNames.set(store.store.id, store.store.name); + }); + }); + + // Initialize counts for each day with store-specific counts + const dailyCounts: DailyCount[] = days.map(date => { + const baseCount = { + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + timestamp: date.getTime() + }; + + // Add a count property for each store, initialized to 0 + const storeCounts: { [key: string]: number } = {}; + storeIds.forEach(storeId => { + storeCounts[storeId] = 0; + }); + + return { + ...baseCount, + ...storeCounts + }; + }); - // Count orders for each day + // Count orders for each day and store orders.forEach(order => { const orderDate = new Date(order.created); const orderDateStr = orderDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const dayData = dailyCounts.find(d => d.date === orderDateStr); + if (dayData) { - dayData.count++; + order.stores.forEach(store => { + const currentCount = dayData[store.store.id] as number; + dayData[store.store.id] = currentCount + 1; + }); } }); - return dailyCounts; + // Convert storeIds to array for consistent ordering + const storeIdsArray = Array.from(storeIds); + + return { + data: dailyCounts, + stores: storeIdsArray.map(id => ({ + id, + name: storeNames.get(id) || 'Unknown Store' + })) + }; +} + +// Add color generation function +function getStoreColor(index: number, opacity: number = 0.9) { + const hue = (166 + index * 30) % 360; + return `hsla(${hue}, 85%, 35%, ${opacity})`; } export default function OrdersDashboard() { @@ -293,10 +340,13 @@ export default function OrdersDashboard() { {/* Orders Chart */}
-

Daily Orders (Last 30 Days)

+

Daily Orders by Store (Last 30 Days)

- + `Orders on ${label}`} - /> - + + {calculateDailyOrderCounts(orders).stores.map((store, index) => ( + + ))}
@@ -365,78 +419,88 @@ export default function OrdersDashboard() {
- {orders.map((order) => ( - - - - - - - + + + + + + + + - - - - - ))} + + + + + ); + })}
- #{order.id.slice(-6)} - - {formatDateTime(order.created)} - - {order.customer_name} - - {order.customer_phone || '-'} - - {order.delivery_address ? ( - <> -
- {order.delivery_address.street_address.filter(Boolean).join(', ')} -
-
- {order.delivery_address.city}, {order.delivery_address.state} {order.delivery_address.zip_code} -
- - ) : ( - No address available - )} -
-
- - {statusLabels[order.status as keyof typeof statusLabels]} + {orders.map((order) => { + // Get the first store's index for coloring + const storeIndex = calculateDailyOrderCounts(orders).stores + .findIndex(s => s.id === order.stores[0]?.store.id); + + return ( +
+ #{order.id.slice(-6)} + + {formatDateTime(order.created)} + + {order.customer_name} + + {order.customer_phone || '-'} + + {order.delivery_address ? ( + <> +
+ {order.delivery_address.street_address.filter(Boolean).join(', ')} +
+
+ {order.delivery_address.city}, {order.delivery_address.state} {order.delivery_address.zip_code} +
+ + ) : ( + No address available + )} +
+
+ + {statusLabels[order.status as keyof typeof statusLabels]} + + +
+
+ + {paymentStatusLabels[order.payment_status as keyof typeof paymentStatusLabels]} - +
+ {order.stores.map((store) => ( +
+
{store.store.name}
+ {store.items.map((item) => ( +
+ {item.quantity}x {item.name} (${item.price.toFixed(2)}) +
+ ))} +
))} - -
-
- - {paymentStatusLabels[order.payment_status as keyof typeof paymentStatusLabels]} - - -
- {order.stores.map((store) => ( -
-
{store.store.name}
- {store.items.map((item) => ( -
- {item.quantity}x {item.name} (${item.price.toFixed(2)}) -
- ))} -
- ))} -
-
- ${order.total_amount.toFixed(2)} -
+ ${order.total_amount.toFixed(2)} +
); -} \ No newline at end of file +} \ No newline at end of file From 4c213ea0271217d053158dd2ffafc2215bd4e93a Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Mon, 17 Feb 2025 18:51:16 -0500 Subject: [PATCH 10/27] Add status filters to all orders dash --- frontend/src/app/orders/page.tsx | 103 ++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/orders/page.tsx b/frontend/src/app/orders/page.tsx index a60c75b..ec51e70 100644 --- a/frontend/src/app/orders/page.tsx +++ b/frontend/src/app/orders/page.tsx @@ -186,9 +186,39 @@ export default function OrdersDashboard() { const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState>(new Set(Object.keys(statusLabels))); + const [searchTerm, setSearchTerm] = useState(''); const metrics = calculateMetrics(orders); + // Filter orders based on selected statuses and search term + const filteredOrders = orders.filter(order => { + const matchesStatus = selectedStatuses.has(order.status); + const searchLower = searchTerm.toLowerCase(); + const matchesSearch = !searchTerm || + order.id.toLowerCase().includes(searchLower) || + order.customer_name.toLowerCase().includes(searchLower); + return matchesStatus && matchesSearch; + }); + + // Toggle status filter + const toggleStatus = (status: string) => { + setSelectedStatuses(prev => { + const newSet = new Set(prev); + if (newSet.has(status)) { + newSet.delete(status); + } else { + newSet.add(status); + } + return newSet; + }); + }; + + // Select/deselect all statuses + const toggleAll = (select: boolean) => { + setSelectedStatuses(new Set(select ? Object.keys(statusLabels) : [])); + }; + useEffect(() => { const fetchOrders = async () => { try { @@ -384,6 +414,77 @@ export default function OrdersDashboard() { + {/* Filters Section */} +
+
+
+ {/* Status Filter */} +
+
+

Filter by Status

+
+ + +
+
+
+ {Object.entries(statusLabels).map(([value, label]) => ( + + ))} +
+
+ + {/* Search */} +
+

Search Orders

+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 rounded-lg border border-[#2A9D8F]/20 bg-white focus:ring-2 focus:ring-[#2A9D8F] focus:border-transparent" + /> +
+
+
+
+ + {/* Results Summary */} +
+ Showing {filteredOrders.length} {filteredOrders.length === 1 ? 'order' : 'orders'} + {selectedStatuses.size < Object.keys(statusLabels).length && + ` with status${selectedStatuses.size === 1 ? '' : 'es'}: ${Array.from(selectedStatuses).map(s => statusLabels[s as keyof typeof statusLabels]).join(', ')}`} + {searchTerm && ` matching "${searchTerm}"`} +
+ {/* Orders Table */}
@@ -419,7 +520,7 @@ export default function OrdersDashboard() { - {orders.map((order) => { + {filteredOrders.map((order) => { // Get the first store's index for coloring const storeIndex = calculateDailyOrderCounts(orders).stores .findIndex(s => s.id === order.stores[0]?.store.id); From 24414a81cc7dfb85010cbbd490cde772da04183a Mon Sep 17 00:00:00 2001 From: Peter Valdez Date: Tue, 18 Feb 2025 00:57:54 -0500 Subject: [PATCH 11/27] Add an inventory dashboard --- .../1739857597_updated_order_items.js | 28 ++ .../1739857689_updated_order_items.js | 28 ++ .../1739857745_updated_store_items.js | 20 + .../1739857799_updated_order_items.js | 28 ++ .../1739857921_updated_order_items.js | 28 ++ .../1739858051_updated_store_items.js | 22 ++ frontend/src/app/orders/page.tsx | 3 +- frontend/src/app/store/[id]/StoreContent.tsx | 26 +- .../src/app/store/[id]/dashboard/page.tsx | 283 +++++++++++--- .../src/app/store/[id]/inventory/page.tsx | 365 ++++++++++++++++++ python-backend/localmart_backend/main.py | 108 +++++- 11 files changed, 875 insertions(+), 64 deletions(-) create mode 100644 db/pb_migrations/1739857597_updated_order_items.js create mode 100644 db/pb_migrations/1739857689_updated_order_items.js create mode 100644 db/pb_migrations/1739857745_updated_store_items.js create mode 100644 db/pb_migrations/1739857799_updated_order_items.js create mode 100644 db/pb_migrations/1739857921_updated_order_items.js create mode 100644 db/pb_migrations/1739858051_updated_store_items.js create mode 100644 frontend/src/app/store/[id]/inventory/page.tsx diff --git a/db/pb_migrations/1739857597_updated_order_items.js b/db/pb_migrations/1739857597_updated_order_items.js new file mode 100644 index 0000000..a9eb975 --- /dev/null +++ b/db/pb_migrations/1739857597_updated_order_items.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "deleteRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "listRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "updateRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "viewRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": null, + "listRule": "", + "updateRule": null, + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739857689_updated_order_items.js b/db/pb_migrations/1739857689_updated_order_items.js new file mode 100644 index 0000000..8e46dc9 --- /dev/null +++ b/db/pb_migrations/1739857689_updated_order_items.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "deleteRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "listRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "updateRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "viewRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "deleteRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "listRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "updateRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'", + "viewRule": "@request.auth.roles ?~ 'admin' || @collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin'" + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739857745_updated_store_items.js b/db/pb_migrations/1739857745_updated_store_items.js new file mode 100644 index 0000000..71eb9fe --- /dev/null +++ b/db/pb_migrations/1739857745_updated_store_items.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1842453536") + + // update collection data + unmarshal({ + "updateRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1842453536") + + // update collection data + unmarshal({ + "updateRule": null + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739857799_updated_order_items.js b/db/pb_migrations/1739857799_updated_order_items.js new file mode 100644 index 0000000..51025b4 --- /dev/null +++ b/db/pb_migrations/1739857799_updated_order_items.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin'", + "deleteRule": "@request.auth.roles ?~ 'admin'", + "listRule": "@request.auth.roles ?~ 'admin'", + "updateRule": "@request.auth.roles ?~ 'admin'", + "viewRule": "@request.auth.roles ?~ 'admin'" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "deleteRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "listRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "updateRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "viewRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')" + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739857921_updated_order_items.js b/db/pb_migrations/1739857921_updated_order_items.js new file mode 100644 index 0000000..2620d5b --- /dev/null +++ b/db/pb_migrations/1739857921_updated_order_items.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.role ?= 'admin' || @request.body.order.user.id = @request.auth.id", + "deleteRule": null, + "listRule": "@request.auth.role ?= 'admin' || @request.body.order.user.id = @request.auth.id", + "updateRule": null, + "viewRule": "@request.auth.role ?= 'admin' || @request.body.order.user.id = @request.auth.id" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2456927940") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin'", + "deleteRule": "@request.auth.roles ?~ 'admin'", + "listRule": "@request.auth.roles ?~ 'admin'", + "updateRule": "@request.auth.roles ?~ 'admin'", + "viewRule": "@request.auth.roles ?~ 'admin'" + }, collection) + + return app.save(collection) +}) diff --git a/db/pb_migrations/1739858051_updated_store_items.js b/db/pb_migrations/1739858051_updated_store_items.js new file mode 100644 index 0000000..a22e7b1 --- /dev/null +++ b/db/pb_migrations/1739858051_updated_store_items.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1842453536") + + // update collection data + unmarshal({ + "createRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')", + "deleteRule": "@request.auth.roles ?~ 'admin' || (@collection.store_roles.user.id = @request.auth.id && @collection.store_roles.store.id = @request.body.store_item.store.id && @collection.store_roles.role = 'admin')" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1842453536") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null + }, collection) + + return app.save(collection) +}) diff --git a/frontend/src/app/orders/page.tsx b/frontend/src/app/orders/page.tsx index ec51e70..124f7c3 100644 --- a/frontend/src/app/orders/page.tsx +++ b/frontend/src/app/orders/page.tsx @@ -314,8 +314,7 @@ export default function OrdersDashboard() {
-

Orders Dashboard

-

All Orders Overview

+

Localmart Dashboard

{/* Metrics Grid */} diff --git a/frontend/src/app/store/[id]/StoreContent.tsx b/frontend/src/app/store/[id]/StoreContent.tsx index 4063772..ca2e8c5 100644 --- a/frontend/src/app/store/[id]/StoreContent.tsx +++ b/frontend/src/app/store/[id]/StoreContent.tsx @@ -5,7 +5,7 @@ import { useCart } from '../../contexts/cart'; import { toast } from 'react-hot-toast'; import { FaInstagram, FaFacebook, FaXTwitter } from 'react-icons/fa6'; import { SiBluesky } from 'react-icons/si'; -import { ClockIcon, MapPinIcon } from '@heroicons/react/24/outline'; +import { ClockIcon, MapPinIcon, ChartBarIcon, ShoppingBagIcon } from '@heroicons/react/24/outline'; import { config } from '@/config'; import { useStoreRoles } from '@/app/hooks/useStoreRoles'; import Link from 'next/link'; @@ -117,14 +117,24 @@ export default function StoreContent({ storeId }: { storeId: string }) {

{store.name}

Local market & grocery

- {/* Add dashboard link for store admins */} + {/* Vendor Links */} {isAdmin && ( - - View Dashboard - +
+ + + View Orders + + + + Manage Inventory + +
)} {/* Social Links */}
diff --git a/frontend/src/app/store/[id]/dashboard/page.tsx b/frontend/src/app/store/[id]/dashboard/page.tsx index 9d3002a..75ebc1d 100644 --- a/frontend/src/app/store/[id]/dashboard/page.tsx +++ b/frontend/src/app/store/[id]/dashboard/page.tsx @@ -8,7 +8,8 @@ import { config } from '@/config'; import { useRouter } from 'next/navigation'; import { use } from 'react'; import { CurrencyDollarIcon, ShoppingBagIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import Link from 'next/link'; interface OrderItem { id: string; @@ -44,6 +45,12 @@ interface Order { stores: Store[]; } +interface DailyCount { + date: string; + timestamp: number; + [itemName: string]: string | number; // Allow string indexes for item names +} + const statusColors = { pending: 'bg-yellow-100 text-yellow-800', confirmed: 'bg-blue-100 text-blue-800', @@ -93,14 +100,67 @@ function calculateMetrics(orders: Order[]) { const pendingOrders = orders.filter(order => order.status === 'pending').length; const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0; + // Calculate items per order + const totalItems = orders.reduce((sum, order) => { + return sum + order.stores[0]?.items.reduce((itemSum, item) => itemSum + item.quantity, 0) || 0; + }, 0); + const avgItemsPerOrder = totalOrders > 0 ? totalItems / totalOrders : 0; + + // Calculate popular items + const itemCounts: { [key: string]: { count: number; revenue: number; name: string } } = {}; + orders.forEach(order => { + order.stores[0]?.items.forEach(item => { + if (!itemCounts[item.name]) { + itemCounts[item.name] = { count: 0, revenue: 0, name: item.name }; + } + itemCounts[item.name].count += item.quantity; + itemCounts[item.name].revenue += item.price * item.quantity; + }); + }); + + const popularItems = Object.entries(itemCounts) + .map(([name, data]) => ({ id: name, ...data })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + // Calculate week-over-week growth + const now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + const thisWeekRevenue = orders + .filter(order => new Date(order.created) >= oneWeekAgo) + .reduce((sum, order) => sum + order.total_amount, 0); + + const lastWeekRevenue = orders + .filter(order => { + const date = new Date(order.created); + return date >= twoWeeksAgo && date < oneWeekAgo; + }) + .reduce((sum, order) => sum + order.total_amount, 0); + + const weekOverWeekGrowth = lastWeekRevenue > 0 + ? ((thisWeekRevenue - lastWeekRevenue) / lastWeekRevenue) * 100 + : 0; + return { totalOrders, totalRevenue, pendingOrders, - avgOrderValue + avgOrderValue, + avgItemsPerOrder, + popularItems, + weekOverWeekGrowth, + thisWeekRevenue, + lastWeekRevenue }; } +function getStoreColor(index: number, opacity: number = 0.8) { + const hue = (166 + index * 30) % 360; + return `hsla(${hue}, 85%, 35%, ${opacity})`; +} + function calculateDailyOrderCounts(orders: Order[]) { // Get date range for last 30 days const today = new Date(); @@ -114,24 +174,50 @@ function calculateDailyOrderCounts(orders: Order[]) { return date; }); + // Get unique items from all orders + const uniqueItems = new Set(); + orders.forEach(order => { + order.stores[0]?.items.forEach(item => { + uniqueItems.add(item.name); + }); + }); + // Initialize counts for each day - const dailyCounts = days.map(date => ({ - date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - count: 0, - timestamp: date.getTime() // for sorting - })); + const dailyCounts: DailyCount[] = days.map(date => { + const baseCount = { + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + timestamp: date.getTime() + }; + + // Add a count property for each unique item, initialized to 0 + const itemCounts: { [key: string]: number } = {}; + uniqueItems.forEach(itemName => { + itemCounts[itemName] = 0; + }); - // Count orders for each day + return { + ...baseCount, + ...itemCounts + }; + }); + + // Count items for each day orders.forEach(order => { const orderDate = new Date(order.created); const orderDateStr = orderDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const dayData = dailyCounts.find(d => d.date === orderDateStr); + if (dayData) { - dayData.count++; + order.stores[0]?.items.forEach(item => { + dayData[item.name] = (dayData[item.name] as number || 0) + item.quantity; + }); } }); - return dailyCounts; + return { + data: dailyCounts, + items: Array.from(uniqueItems) + }; } export default function StoreDashboard({ params }: { params: Promise<{ id: string }> }) { @@ -144,6 +230,7 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin const [error, setError] = useState(null); const [store, setStore] = useState<{ name: string } | null>(null); const [statusFilter, setStatusFilter] = useState('all'); + const [selectedStatuses, setSelectedStatuses] = useState>(new Set(Object.keys(statusLabels))); const [searchTerm, setSearchTerm] = useState(''); const router = useRouter(); @@ -205,13 +292,31 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin fetchStore(); }, [storeId]); - // Filter orders when status filter or search term changes + // Toggle status filter + const toggleStatus = (status: string) => { + setSelectedStatuses(prev => { + const newSet = new Set(prev); + if (newSet.has(status)) { + newSet.delete(status); + } else { + newSet.add(status); + } + return newSet; + }); + }; + + // Select/deselect all statuses + const toggleAll = (select: boolean) => { + setSelectedStatuses(new Set(select ? Object.keys(statusLabels) : [])); + }; + + // Update filter effect useEffect(() => { let result = [...orders]; // Apply status filter - if (statusFilter !== 'all') { - result = result.filter(order => order.status === statusFilter); + if (selectedStatuses.size < Object.keys(statusLabels).length) { + result = result.filter(order => selectedStatuses.has(order.status)); } // Apply search filter (on order ID or customer name) @@ -224,7 +329,7 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin } setFilteredOrders(result); - }, [orders, statusFilter, searchTerm]); + }, [orders, selectedStatuses, searchTerm]); if (rolesLoading || loading) { return ( @@ -264,12 +369,23 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin
{store && ( -

{store.name}

+ +

{store.name}

+ )} -

Vendor Dashboard

+
+

Vendor Dashboard

+ + Manage Inventory + + +
- {/* Metrics Grid */} + {/* Key Metrics Grid */}
{/* Pending Orders */}
@@ -318,13 +434,38 @@ export default function StoreDashboard({ params }: { params: Promise<{ id: strin
- {/* Orders Chart */} -
+ {/* New Insights Sections */} +
+ {/* Popular Items */} +
+

Top Selling Items

+
+ {metrics.popularItems.map((item, index) => ( +
+
+
+ #{index + 1} +
+

{item.name}

+

{item.count} units sold

+
+
+

${item.revenue.toFixed(2)}

+
+
+ ))} +
+
+ + {/* Daily Orders Chart */}

Daily Orders (Last 30 Days)

- + `Orders on ${label}`} - /> - + + {calculateDailyOrderCounts(orders).items.map((itemName, index) => ( + + ))}
+ {/* Orders Table Title */} +

All Orders

+ {/* Filters Section */} -
-

Orders

-
-
- setSearchTerm(e.target.value)} - className="w-full px-4 py-2 rounded-lg border border-[#2A9D8F]/20 bg-white focus:ring-2 focus:ring-[#2A9D8F] focus:border-transparent" - /> +
+
+
+ {/* Status Filter */} +
+
+

Filter by Status

+
+ + +
+
+
+ {Object.entries(statusLabels).map(([value, label]) => ( + + ))} +
+
+ + {/* Search */} +
+

Search Orders

+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 rounded-lg border border-[#2A9D8F]/20 bg-white focus:ring-2 focus:ring-[#2A9D8F] focus:border-transparent" + /> +
-
{/* Results Summary */}
Showing {filteredOrders.length} {filteredOrders.length === 1 ? 'order' : 'orders'} - {statusFilter !== 'all' && ` with status "${statusLabels[statusFilter as keyof typeof statusLabels]}"`} + {selectedStatuses.size < Object.keys(statusLabels).length && + ` with status${selectedStatuses.size === 1 ? '' : 'es'}: ${Array.from(selectedStatuses).map(s => statusLabels[s as keyof typeof statusLabels]).join(', ')}`} {searchTerm && ` matching "${searchTerm}"`}
diff --git a/frontend/src/app/store/[id]/inventory/page.tsx b/frontend/src/app/store/[id]/inventory/page.tsx new file mode 100644 index 0000000..bc8af32 --- /dev/null +++ b/frontend/src/app/store/[id]/inventory/page.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAuth } from '@/app/contexts/auth'; +import { useStoreRoles } from '@/app/hooks/useStoreRoles'; +import { toast } from 'react-hot-toast'; +import { config } from '@/config'; +import { useRouter } from 'next/navigation'; +import { use } from 'react'; +import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; +import Link from 'next/link'; + +interface StoreItem { + id: string; + name: string; + price: number; + description?: string; + imageUrl?: string; +} + +interface EditItemModalProps { + item?: StoreItem; + isOpen: boolean; + onClose: () => void; + onSave: (item: Partial) => void; +} + +function EditItemModal({ item, isOpen, onClose, onSave }: EditItemModalProps) { + const [name, setName] = useState(item?.name || ''); + const [price, setPrice] = useState(item?.price?.toString() || ''); + const [description, setDescription] = useState(item?.description || ''); + + useEffect(() => { + if (isOpen) { + setName(item?.name || ''); + setPrice(item?.price?.toString() || ''); + setDescription(item?.description || ''); + } + }, [isOpen, item]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ + name, + price: parseFloat(price), + description + }); + }; + + if (!isOpen) return null; + + return ( +
+
+

+ {item ? 'Edit Item' : 'Add New Item'} +

+
+
+ + setName(e.target.value)} + className="mt-1 block w-full rounded-md border border-[#2A9D8F]/20 px-3 py-2 focus:border-[#2A9D8F] focus:ring focus:ring-[#2A9D8F]/50" + required + /> +
+
+ + setPrice(e.target.value)} + step="0.01" + min="0" + className="mt-1 block w-full rounded-md border border-[#2A9D8F]/20 px-3 py-2 focus:border-[#2A9D8F] focus:ring focus:ring-[#2A9D8F]/50" + required + /> +
+
+ +