From 9dab3fef05f7239da1609938e389dc617d96615e Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Thu, 5 Feb 2026 16:28:09 +0530 Subject: [PATCH] feat: Implement Client Order List --- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 29 ++ apps/web/src/common/components/ui/button.tsx | 2 +- .../web/src/common/components/ui/calendar.tsx | 73 ++++ apps/web/src/common/components/ui/command.tsx | 152 +++++++++ .../components/ui/{dailog.tsx => dialog.tsx} | 0 apps/web/src/common/components/ui/popover.tsx | 29 ++ .../rates/components/RateCardModal.tsx | 2 +- .../operations/orders/ClientOrderList.tsx | 319 ++++++++++++++++++ apps/web/src/routes.tsx | 3 +- 10 files changed, 608 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/common/components/ui/calendar.tsx create mode 100644 apps/web/src/common/components/ui/command.tsx rename apps/web/src/common/components/ui/{dailog.tsx => dialog.tsx} (100%) create mode 100644 apps/web/src/common/components/ui/popover.tsx create mode 100644 apps/web/src/features/operations/orders/ClientOrderList.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 34ed9716..adc206a8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@firebase/data-connect": "^0.3.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -33,6 +34,7 @@ "lucide-react": "^0.563.0", "react": "^19.2.0", "react-datepicker": "^9.1.0", + "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", "react-redux": "^9.2.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 09332592..1f511454 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.10)(react@19.2.4) @@ -80,6 +83,9 @@ importers: react-datepicker: specifier: ^9.1.0 version: 9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-day-picker: + specifier: ^9.13.0 + version: 9.13.0(react@19.2.4) react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) @@ -236,6 +242,9 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -2016,6 +2025,9 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -2583,6 +2595,12 @@ packages: date-fns-tz: optional: true + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3042,6 +3060,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@date-fns/tz@1.4.1': {} + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -4857,6 +4877,8 @@ snapshots: d3-timer@3.0.1: {} + date-fns-jalali@4.1.0-0: {} + date-fns@4.1.0: {} debug@4.4.3: @@ -5454,6 +5476,13 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react-day-picker@9.13.0(react@19.2.4): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/apps/web/src/common/components/ui/button.tsx b/apps/web/src/common/components/ui/button.tsx index a61355ad..ece11a54 100644 --- a/apps/web/src/common/components/ui/button.tsx +++ b/apps/web/src/common/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" -const buttonVariants = cva( +export const buttonVariants = cva( "inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]", { variants: { diff --git a/apps/web/src/common/components/ui/calendar.tsx b/apps/web/src/common/components/ui/calendar.tsx new file mode 100644 index 00000000..0e35cbba --- /dev/null +++ b/apps/web/src/common/components/ui/calendar.tsx @@ -0,0 +1,73 @@ +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker, type DayPickerProps } from "react-day-picker" + +import { buttonVariants } from "@/common/components/ui/button" +import { cn } from "@/lib/utils" + +export type CalendarProps = DayPickerProps & { + className?: string; +}; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + Chevron: ({ orientation }) => { + const Icon = orientation === "left" ? ChevronLeft : ChevronRight; + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar } diff --git a/apps/web/src/common/components/ui/command.tsx b/apps/web/src/common/components/ui/command.tsx new file mode 100644 index 00000000..36135312 --- /dev/null +++ b/apps/web/src/common/components/ui/command.tsx @@ -0,0 +1,152 @@ +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/common/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends React.ComponentPropsWithoutRef {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/apps/web/src/common/components/ui/dailog.tsx b/apps/web/src/common/components/ui/dialog.tsx similarity index 100% rename from apps/web/src/common/components/ui/dailog.tsx rename to apps/web/src/common/components/ui/dialog.tsx diff --git a/apps/web/src/common/components/ui/popover.tsx b/apps/web/src/common/components/ui/popover.tsx new file mode 100644 index 00000000..9d172edb --- /dev/null +++ b/apps/web/src/common/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cn } from "@/lib/utils" + + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/web/src/features/business/rates/components/RateCardModal.tsx b/apps/web/src/features/business/rates/components/RateCardModal.tsx index d4535736..60fe4891 100644 --- a/apps/web/src/features/business/rates/components/RateCardModal.tsx +++ b/apps/web/src/features/business/rates/components/RateCardModal.tsx @@ -6,7 +6,7 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/common/components/ui/dailog"; +} from "@/common/components/ui/dialog"; import { Input } from "@/common/components/ui/input"; import { Label } from "@/common/components/ui/label"; import { diff --git a/apps/web/src/features/operations/orders/ClientOrderList.tsx b/apps/web/src/features/operations/orders/ClientOrderList.tsx new file mode 100644 index 00000000..2ee50b7a --- /dev/null +++ b/apps/web/src/features/operations/orders/ClientOrderList.tsx @@ -0,0 +1,319 @@ +import { useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { format, parseISO, isValid } from "date-fns"; +import { + Search, MapPin, FileText, + Clock, Package, CheckCircle, Check, ChevronsUpDown, + Plus +} from "lucide-react"; + +import { Card, CardContent } from "@/common/components/ui/card"; +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Input } from "@/common/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/common/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/common/components/ui/command"; + +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { useGetBusinessesByUserId, useGetOrdersByBusinessId } from "@/dataconnect-generated/react"; +import { OrderStatus } from "@/dataconnect-generated"; +import { dataConnect } from "@/features/auth/firebase"; + +const safeParseDate = (dateString: any): Date | null => { + if (!dateString) return null; + try { + const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString); + return isValid(date) ? date : null; + } catch { return null; } +}; + +export default function ClientOrderList() { + const navigate = useNavigate(); + const { user } = useSelector((state: RootState) => state.auth); + + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [locationFilter, setLocationFilter] = useState("all"); + const [locationOpen, setLocationOpen] = useState(false); + + // 1. Get businesses for the logged in user + const { data: businessData } = useGetBusinessesByUserId(dataConnect, { userId: user?.uid || "" }); + const businesses = businessData?.businesses || []; + const primaryBusinessId = businesses[0]?.id; + + // 2. Get orders for the primary business + const { data: orderData, isLoading } = useGetOrdersByBusinessId(dataConnect, { + businessId: primaryBusinessId || "" + }, { + enabled: !!primaryBusinessId + }); + + const orders = orderData?.orders || []; + + const filteredOrders = useMemo(() => { + let filtered = [...orders]; + + if (searchTerm) { + const lower = searchTerm.toLowerCase(); + filtered = filtered.filter(o => + o.eventName?.toLowerCase().includes(lower) || + o.teamHub?.hubName?.toLowerCase().includes(lower) + ); + } + + if (statusFilter !== "all") { + filtered = filtered.filter(o => o.status === statusFilter); + } + + if (locationFilter !== "all") { + filtered = filtered.filter(o => o.teamHub?.hubName === locationFilter); + } + + return filtered; + }, [orders, searchTerm, statusFilter, locationFilter]); + + const uniqueLocations = useMemo(() => { + const locations = new Set(); + orders.forEach(o => { + if (o.teamHub?.hubName) locations.add(o.teamHub.hubName); + }); + return Array.from(locations).sort(); + }, [orders]); + + const stats = useMemo(() => { + const total = orders.length; + const active = orders.filter(o => o.status !== OrderStatus.COMPLETED && o.status !== OrderStatus.CANCELLED).length; + const completed = orders.filter(o => o.status === OrderStatus.COMPLETED).length; + const filled = orders.filter(o => o.status === OrderStatus.FILLED || o.status === OrderStatus.FULLY_STAFFED).length; + + return { total, active, completed, filled }; + }, [orders]); + + const getFillRate = (order: any) => { + const requested = order.requested || 0; + const assigned = Array.isArray(order.assignedStaff) ? order.assignedStaff.length : 0; + if (requested === 0) return 0; + return Math.round((assigned / requested) * 100); + }; + + const getStatusBadge = (status: OrderStatus) => { + switch (status) { + case OrderStatus.COMPLETED: + return Completed; + case OrderStatus.CANCELLED: + return Cancelled; + case OrderStatus.FULLY_STAFFED: + case OrderStatus.FILLED: + return Filled; + case OrderStatus.PARTIAL_STAFFED: + return Partial; + case OrderStatus.POSTED: + return Posted; + default: + return {status}; + } + }; + + return ( + +
+ {/* Stats Section */} +
+ + +
+
+

Total Orders

+

{stats.total}

+
+
+ +
+
+
+
+ + + +
+
+

Active

+

{stats.active}

+
+
+ +
+
+
+
+ + + +
+
+

Filled

+

{stats.filled}

+
+
+ +
+
+
+
+ + + navigate('/orders/create')}> + +

Create New Order

+
+
+
+ + {/* Filters and Search */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+
+ + + + + + + + No location found. + + { setLocationFilter("all"); setLocationOpen(false); }}> + + All Locations + + {uniqueLocations.map((loc) => ( + { setLocationFilter(loc); setLocationOpen(false); }}> + + {loc} + + ))} + + + + + + +
+
+ + {/* Orders Table */} +
+ + + + Order # + Event Name + Event Date + Status + Positions + Fill Rate + + + + {isLoading ? ( + + + Loading orders... + + + ) : filteredOrders.length === 0 ? ( + + + No orders found. + + + ) : ( + filteredOrders.map((order) => { + const eventDate = safeParseDate(order.date); + const fillRate = getFillRate(order); + + return ( + navigate(`/orders/${order.id}`)}> + + {order.id.substring(0, 8)} + + + {order.eventName} +
+ + {order.teamHub?.hubName || "No location"} +
+
+ +
+ {eventDate ? format(eventDate, 'MMM dd, yyyy') : 'No date'} + {eventDate ? format(eventDate, 'EEEE') : ''} +
+
+ + {getStatusBadge(order.status)} + + + {order.requested || 0} + + +
+
+
0 ? 'bg-blue-500' : 'bg-slate-300'}`} + style={{ width: `${fillRate}%` }} + /> +
+ {fillRate}% +
+ + + + + + ); + }) + )} + +
+
+
+
+ ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 41b03fc6..bfadf1a6 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -18,7 +18,7 @@ import AddClient from './features/business/clients/AddClient'; import ServiceRates from './features/business/rates/ServiceRates'; import OrderList from './features/operations/orders/OrderList'; import OrderDetail from './features/operations/orders/OrderDetail'; - +import ClientOrderList from './features/operations/orders/ClientOrderList'; /** * AppRoutes Component @@ -95,6 +95,7 @@ const AppRoutes: React.FC = () => { } /> {/* Operations Routes */} } /> + } /> } />