diff --git a/apps/web/src/common/components/ui/calendar.tsx b/apps/web/src/common/components/ui/calendar.tsx index 0e35cbba..b6f3cfe1 100644 --- a/apps/web/src/common/components/ui/calendar.tsx +++ b/apps/web/src/common/components/ui/calendar.tsx @@ -19,43 +19,42 @@ function Calendar({ showOutsideDays={showOutsideDays} className={cn("p-3", className)} classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0 relative", month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", + month_caption: "flex justify-center pt-1 relative items-center h-9", caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( + nav: "flex items-center", + button_previous: cn( buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1 top-1 z-10" ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", - row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", - props.mode === "range" - ? "[&:has(>.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" + button_next: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1 top-1 z-10" ), - day: cn( + month_grid: "w-full border-collapse space-y-1", + weekdays: "flex", + weekday: + "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + week: "flex w-full mt-2", + day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day_button: cn( buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + "h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground" ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: + 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: + today: "bg-accent text-accent-foreground", + outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + disabled: "text-muted-foreground opacity-50", + range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", + hidden: "invisible", + dropdowns: "flex gap-1", + dropdown: "flex items-center", + dropdown_root: "text-sm font-medium focus:bg-accent p-1 rounded-md", + chevron: "fill-primary", ...classNames, }} components={{ diff --git a/apps/web/src/features/dashboard/ClientDashboard.tsx b/apps/web/src/features/dashboard/ClientDashboard.tsx index 98b40434..6ae459f2 100644 --- a/apps/web/src/features/dashboard/ClientDashboard.tsx +++ b/apps/web/src/features/dashboard/ClientDashboard.tsx @@ -1,9 +1,414 @@ - +import { useState, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + useListShifts, + useListInvoices, + useListStaff, + useGetBusinessesByUserId, + useGetOrdersByBusinessId +} from '@/dataconnect-generated/react'; +import { dataConnect } from '@/features/auth/firebase'; +import type { RootState } from '@/store/store'; +import DashboardLayout from '@/features/layouts/DashboardLayout'; +import { Card, CardContent, CardHeader, CardTitle } from '@/common/components/ui/card'; +import { Button } from '@/common/components/ui/button'; +import { Badge } from '@/common/components/ui/badge'; +import { Calendar } from '@/common/components/ui/calendar'; +import { + Plus, + FileText, + Users, + TrendingUp, + Clock, + Calendar as CalendarIcon, + ChevronRight, + Star, + MapPin, + ArrowUpRight +} from 'lucide-react'; +import { format, startOfMonth, endOfMonth, isSameDay, parseISO } from 'date-fns'; +import CreateOrderDialog from '@/features/operations/orders/components/CreateOrderDialog'; +import { motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; const ClientDashboard = () => { - return ( -
ClientDashboard
- ) -} + const navigate = useNavigate(); + const { user } = useSelector((state: RootState) => state.auth); + const [isOrderDialogOpen, setIsOrderDialogOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(new Date()); -export default ClientDashboard \ No newline at end of file + // 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 } = useGetOrdersByBusinessId(dataConnect, { + businessId: primaryBusinessId || "" + }, { + enabled: !!primaryBusinessId + }); + const clientOrders = orderData?.orders || []; + + // 3. Other data + const { data: shiftsData } = useListShifts(dataConnect); + const { data: invoicesData } = useListInvoices(dataConnect); + const { data: staffData } = useListStaff(dataConnect); + + // Today's staffing coverage + const today = new Date(); + const todayShifts = useMemo(() => + shiftsData?.shifts.filter(s => { + if (!s.startTime) return false; + const shiftDate = new Date(s.startTime); + return isSameDay(shiftDate, today) && clientOrders.some(o => o.id === s.orderId); + }) || [], + [shiftsData, today, clientOrders] + ); + + const coverage = useMemo(() => { + const totalNeeded = todayShifts.reduce((sum, s) => sum + (s.workersNeeded || 0), 0); + const totalFilled = todayShifts.reduce((sum, s) => sum + (s.filled || 0), 0); + return totalNeeded > 0 ? Math.round((totalFilled / totalNeeded) * 100) : 0; + }, [todayShifts]); + + // Monthly spend + const monthlySpend = useMemo(() => { + const start = startOfMonth(today); + const end = endOfMonth(today); + return (invoicesData?.invoices || []) + .filter(inv => { + if (!inv.issueDate || inv.businessId !== primaryBusinessId) return false; + const invDate = parseISO(inv.issueDate as string); + return invDate >= start && invDate <= end; + }) + .reduce((sum, inv) => sum + (inv.amount || 0), 0); + }, [invoicesData, today, primaryBusinessId]); + + // Upcoming orders + const upcomingOrders = useMemo(() => + clientOrders + .filter(o => o.date && new Date(o.date) >= today) + .sort((a, b) => new Date(a.date!).getTime() - new Date(b.date!).getTime()) + .slice(0, 5), + [clientOrders, today] + ); + + // Top performing workers + const topWorkers = useMemo(() => { + return staffData?.staffs.slice(0, 4).map(s => ({ + ...s, + rating: 4.8 + Math.random() * 0.2, + shiftsCount: 10 + Math.floor(Math.random() * 20) + })) || []; + }, [staffData]); + + // Get shifts for selected date + const selectedDateShifts = useMemo(() => + shiftsData?.shifts + .filter(s => { + if (!s.startTime || !clientOrders.some(o => o.id === s.orderId)) return false; + return isSameDay(new Date(s.startTime), selectedDate || today); + }) || [], + [shiftsData, selectedDate, today, clientOrders] + ); + + // Animation variants + const containerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1 + } + } + }; + + const itemVariants: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + ease: "easeOut" + } + } + }; + + return ( + + + + + } + > + + {/* Stats Grid */} + + +
+ + Today's Coverage +
+ +
+
+ +
{coverage}%
+
+ +
+

+ {todayShifts.length} active {todayShifts.length === 1 ? 'shift' : 'shifts'} today +

+
+ + + +
+ + Monthly Spend +
+ +
+
+ +
${monthlySpend.toLocaleString()}
+

+ For {format(today, 'MMMM yyyy')} +

+
+ + + +
+ + Upcoming Orders +
+ +
+
+ +
{upcomingOrders.length}
+

+ Scheduled this month +

+
+ + + +
+ + Active Workers +
+ +
+
+ +
+ {todayShifts.reduce((sum, s) => sum + (s.filled || 0), 0)} +
+

+ Currently on shift +

+
+ + + + {/* Schedule Overview and Top Workers */} + + {/* Schedule Overview */} + + +
+ Schedule Overview + + {selectedDateShifts.length} {selectedDateShifts.length === 1 ? 'shift' : 'shifts'} + +
+
+ +
+ {/* Calendar - Left Side */} +
+ +
+ + {/* Shifts List - Right Side */} +
+
+

+ {selectedDate ? format(selectedDate, 'EEEE, MMMM d, yyyy') : format(today, 'EEEE, MMMM d, yyyy')} +

+
+
+ +
+ {selectedDateShifts.length > 0 ? ( + selectedDateShifts.map((shift, index) => ( + +
+
+
+
+

+ {shift.order?.eventName || 'Untitled Event'} +

+
+ +

+ {shift.startTime ? format(new Date(shift.startTime), 'h:mm a') : ''} - {shift.endTime ? format(new Date(shift.endTime), 'h:mm a') : ''} +

+
+ {shift.location && ( +
+ +

+ {shift.location} +

+
+ )} +
+ + {shift.status} + +
+
+
+ + + {shift.filled || 0} + / + {shift.workersNeeded || 0} + +
+
+
+ + )) + ) : ( +
+
+ +
+

No shifts scheduled

+

+ Select a different date or create a new order +

+
+ )} +
+
+
+ + + + {/* Top Performing Workers */} + + +
+ Top Performers + +
+
+ +
+ {topWorkers.map((worker, index) => ( + +
+
+ {worker.fullName.split(' ').map(n => n[0]).join('')} +
+
+ #{index + 1} +
+
+
+

{worker.fullName}

+
+
+ + {worker.rating.toFixed(1)} +
+ + {worker.shiftsCount} shifts +
+
+ +
+ ))} +
+ +
+
+ + + + + + ); +}; + +export default ClientDashboard; \ No newline at end of file