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/common/config/navigation.ts b/apps/web/src/common/config/navigation.ts index c74bd4c2..ede3d92a 100644 --- a/apps/web/src/common/config/navigation.ts +++ b/apps/web/src/common/config/navigation.ts @@ -165,7 +165,7 @@ export const NAV_CONFIG: NavGroup[] = [ label: 'Documents', path: '/documents', icon: FileText, - allowedRoles: ['Vendor', 'Admin'], + allowedRoles: ['Admin','Vendor'], }, ], }, diff --git a/apps/web/src/features/dashboard/AdminDashboard.tsx b/apps/web/src/features/dashboard/AdminDashboard.tsx index 4f61639c..7f8263bf 100644 --- a/apps/web/src/features/dashboard/AdminDashboard.tsx +++ b/apps/web/src/features/dashboard/AdminDashboard.tsx @@ -1,8 +1,336 @@ +import { + useListShifts, + useListApplications, + useListInvoices, + useListStaff, + useListStaffDocumentsByStatus +} from '@/dataconnect-generated/react'; +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/common/components/ui/card'; +import { Button } from '@/common/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/common/components/ui/alert'; +import { + Clock, + FileText, + TrendingUp, + Users, + AlertTriangle, + PlusCircle, + UserPlus +} from 'lucide-react'; +import { format } from 'date-fns'; +import { DocumentStatus } from '@/dataconnect-generated'; +import CreateOrderDialog from '@/features/operations/orders/components/CreateOrderDialog'; +import { motion, type Variants } from 'framer-motion'; + +// Animation variants +const containerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.2 + } + } +}; + +const itemVariants: Variants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + type: "spring", + stiffness: 100, + damping: 12 + } + } +}; + +const headerVariants: Variants = { + hidden: { y: -20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + type: "spring", + stiffness: 120, + damping: 14 + } + } +}; + +const alertVariants: Variants = { + hidden: { scale: 0.95, opacity: 0 }, + visible: { + scale: 1, + opacity: 1, + transition: { + type: "spring", + stiffness: 100, + damping: 15 + } + } +}; const AdminDashboard = () => { - return ( -
Admin Dashboard
- ) -} + const navigate = useNavigate(); + const [isOrderDialogOpen, setIsOrderDialogOpen] = useState(false); -export default AdminDashboard \ No newline at end of file + // Data fetching + const { data: shiftsData } = useListShifts(); + const { data: applicationsData } = useListApplications(); + const { data: invoicesData } = useListInvoices(); + const { data: staffData } = useListStaff(); + const { data: complianceData } = useListStaffDocumentsByStatus({ status: DocumentStatus.EXPIRING }); + + // Metrics calculation + const today = new Date(); + const todayStart = new Date(today.setHours(0, 0, 0, 0)); + const todayEnd = new Date(today.setHours(23, 59, 59, 999)); + + const activeShiftsToday = shiftsData?.shifts.filter(shift => { + if (!shift.startTime) return false; + const shiftDate = new Date(shift.startTime); + return shiftDate >= todayStart && shiftDate <= todayEnd; + }).length || 0; + + const pendingApplications = applicationsData?.applications.filter(app => app.status === 'PENDING').length || 0; + // Assuming timesheets are also pending approvals if they exist in a similar way + const pendingApprovals = pendingApplications; // Extend if timesheet hook found + + const monthlyRevenue = invoicesData?.invoices + .filter(inv => { + if (!inv.issueDate) return false; + const invDate = new Date(inv.issueDate); + return invDate.getMonth() === new Date().getMonth() && + invDate.getFullYear() === new Date().getFullYear(); + }) + .reduce((sum, inv) => sum + (inv.amount || 0), 0) || 0; + + const totalStaff = staffData?.staffs.length || 0; + const staffUtilization = totalStaff > 0 ? + Math.round((activeShiftsToday / totalStaff) * 100) : 0; + + const unfilledPositions = shiftsData?.shifts.filter(shift => + shift.status === 'OPEN' || (shift.workersNeeded || 0) > (shift.filled || 0) + ).length || 0; + const complianceIssues = complianceData?.staffDocuments.length || 0; + + const metrics = [ + { + title: "Today's Active Shifts", + value: activeShiftsToday, + description: "Running shifts for today", + icon: Clock, + color: "text-blue-500" + }, + { + title: "Pending Approvals", + value: pendingApprovals, + description: "Applications & Timesheets", + icon: FileText, + color: "text-amber-500" + }, + { + title: "Monthly Revenue", + value: `$${monthlyRevenue.toLocaleString()}`, + description: `For ${format(today, 'MMMM yyyy')}`, + icon: TrendingUp, + color: "text-green-500" + }, + { + title: "Staff Utilization", + value: `${staffUtilization}%`, + description: null, + icon: Users, + color: "text-purple-500", + showProgress: true, + progress: staffUtilization + } + ]; + + return ( +
+ +

Admin Dashboard

+
+ + + + + + +
+
+ + + + {/* Metrics Widgets */} + + {metrics.map((metric, index) => { + const Icon = metric.icon; + return ( + + + + + {metric.title} + + + + + + + {metric.value} + + {metric.description && ( +

+ {metric.description} +

+ )} + {metric.showProgress && ( +
+ +
+ )} +
+
+
+ ); + })} +
+ + {/* Alerts Section */} + {(unfilledPositions > 0 || complianceIssues > 0) && ( + + + + + + Critical Alerts + +
+ {unfilledPositions > 0 && ( + + + + Unfilled Positions + + There are {unfilledPositions} shifts that currently have no staff assigned. + + + + )} + {complianceIssues > 0 && ( + + + + Compliance Issues + + {complianceIssues} staff members have expired or missing documentation. + + + + )} +
+
+ )} +
+ ); +}; + +export default AdminDashboard; \ No newline at end of file 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 diff --git a/apps/web/src/features/dashboard/VendorDashboard.tsx b/apps/web/src/features/dashboard/VendorDashboard.tsx index 3e78459a..b7f34167 100644 --- a/apps/web/src/features/dashboard/VendorDashboard.tsx +++ b/apps/web/src/features/dashboard/VendorDashboard.tsx @@ -1,9 +1,310 @@ - +import { useState, useMemo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + useGetVendorByUserId, + useGetOrdersByVendorId, + useListShiftsForDailyOpsByVendor, + useListApplicationsForDailyOps, + useListInvoicesForSpendByVendor, + useListShiftsForPerformanceByVendor +} 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 { + ClipboardList, + Users, + DollarSign, + BarChart3, + Clock, + Calendar as CalendarIcon, + UserCheck +} from 'lucide-react'; +import { format, startOfMonth, endOfMonth } from 'date-fns'; +import { motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; const VendorDashboard = () => { - return ( -
VendorDashboard
- ) -} + const navigate = useNavigate(); + const { user } = useSelector((state: RootState) => state.auth); + const [selectedDate, setSelectedDate] = useState(new Date()); -export default VendorDashboard \ No newline at end of file + // 1. Get vendor for the logged in user + const { data: vendorData, isLoading: isLoadingVendor } = useGetVendorByUserId(dataConnect, { + userId: user?.uid || "" + }, { + enabled: !!user?.uid + }); + + // FIXED: Wrap in useMemo to prevent infinite re-renders + const vendor = useMemo(() => vendorData?.vendors[0], [vendorData]); + const vendorId = useMemo(() => vendor?.id, [vendor]); + + // 2. Fetch Dashboard Data + // FIXED: Use useMemo for date calculations to prevent recalculation on every render + const dateRange = useMemo(() => { + const today = new Date(); + return { + today, + monthStart: startOfMonth(today), + monthEnd: endOfMonth(today) + }; + }, []); + + const { data: ordersData } = useGetOrdersByVendorId(dataConnect, { + vendorId: vendorId || "" + }, { + enabled: !!vendorId + }); + + const { data: dailyShiftsData } = useListShiftsForDailyOpsByVendor(dataConnect, { + vendorId: vendorId || "", + date: dateRange.today.toISOString() + }, { + enabled: !!vendorId + }); + + const shiftIds = useMemo(() => + dailyShiftsData?.shifts.map(s => s.id) || [], + [dailyShiftsData] + ); + + const { data: dailyAppsData } = useListApplicationsForDailyOps(dataConnect, { + shiftIds + }, { + enabled: shiftIds.length > 0 + }); + + const { data: revenueData } = useListInvoicesForSpendByVendor(dataConnect, { + vendorId: vendorId || "", + startDate: dateRange.monthStart.toISOString(), + endDate: dateRange.monthEnd.toISOString() + }, { + enabled: !!vendorId + }); + + const { data: performanceData } = useListShiftsForPerformanceByVendor(dataConnect, { + vendorId: vendorId || "", + startDate: dateRange.monthStart.toISOString(), + endDate: dateRange.monthEnd.toISOString() + }, { + enabled: !!vendorId + }); + + // 3. KPI Calculations + const stats = useMemo(() => { + const activeOrders = (ordersData?.orders || []).filter(o => + o.status !== 'COMPLETED' && o.status !== 'CANCELLED' + ).length; + + const staffOnShift = (dailyAppsData?.applications || []).filter(a => + a.status === 'CHECKED_IN' + ).length; + + const monthlyRevenue = (revenueData?.invoices || []).reduce((sum, inv) => + sum + (inv.amount || 0), 0 + ); + + const shifts = performanceData?.shifts || []; + const totalNeeded = shifts.reduce((sum, s) => sum + (s.workersNeeded || 0), 0); + const totalFilled = shifts.reduce((sum, s) => sum + (s.filled || 0), 0); + const fillRate = totalNeeded > 0 ? Math.round((totalFilled / totalNeeded) * 100) : 0; + + return { + activeOrders, + staffOnShift, + monthlyRevenue, + fillRate + }; + }, [ordersData, dailyAppsData, revenueData, performanceData]); + + // FIXED: Stable navigation handlers with useCallback + const handleNavigateToOrders = useCallback(() => { + console.log("Navigating to vendor orders page"); + navigate('/orders/vendor'); + }, [navigate]); + + const handleNavigateToStaff = useCallback(() => { + navigate('/staff'); + }, [navigate]); + + // 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" + } + } + }; + + if (isLoadingVendor) { + return
Loading dashboard...
; + } + + return ( + + + +
+ } + > + + {/* Stats Grid */} + + + + Active Orders +
+ +
+
+ +
{stats.activeOrders}
+

Currently in progress

+
+
+ + + + Staff on Shift +
+ +
+
+ +
{stats.staffOnShift}
+

Working right now

+
+
+ + + + Monthly Revenue +
+ +
+
+ +
${stats.monthlyRevenue.toLocaleString()}
+

Total for {format(dateRange.today, 'MMMM')}

+
+
+ + + + Shift Fill Rate +
+ +
+
+ +
{stats.fillRate}%
+
+ +
+
+
+
+ + {/* Schedule Overview */} + + + + Daily Operations + + +
+
+ +
+
+

+ {selectedDate ? format(selectedDate, 'EEEE, MMMM d, yyyy') : 'Select a date'} +

+
+ {dailyShiftsData?.shifts.length === 0 ? ( +
+ +

No shifts for this date

+
+ ) : ( + dailyShiftsData?.shifts.map(shift => ( +
+
+
+

{shift.title}

+
+ + {shift.startTime ? format(new Date(shift.startTime), 'p') : ''} +
+
+ + {shift.status} + +
+
+ )) + )} +
+
+
+
+
+
+
+ + ); +}; + +export default VendorDashboard; \ No newline at end of file diff --git a/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx b/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx new file mode 100644 index 00000000..718e6e82 --- /dev/null +++ b/apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/common/components/ui/dialog"; +import EventFormWizard from "./EventFormWizard"; +import { useCreateOrder, useListBusinesses, useListHubs } from "@/dataconnect-generated/react"; +import { OrderType, OrderStatus } from "@/dataconnect-generated"; +import { dataConnect } from "@/features/auth/firebase"; +import { useToast } from "@/common/components/ui/use-toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { Label } from "@/common/components/ui/label"; + +interface CreateOrderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function CreateOrderDialog({ open, onOpenChange }: CreateOrderDialogProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selectedBusinessId, setSelectedBusinessId] = React.useState(""); + const [selectedHubId, setSelectedHubId] = React.useState(""); + + const { data: businessesData } = useListBusinesses(dataConnect); + const { data: hubsData } = useListHubs(dataConnect); + + const createOrderMutation = useCreateOrder(dataConnect, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listOrders"] }); + toast({ + title: "✅ Order Created", + description: "Your new order has been created successfully.", + }); + onOpenChange(false); + }, + onError: (error) => { + toast({ + title: "❌ Creation Failed", + description: error.message || "Could not create the order. Please try again.", + variant: "destructive", + }); + }, + }); + + const handleSubmit = (eventData: Record) => { + if (!selectedBusinessId || !selectedHubId) { + toast({ + title: "Missing Information", + description: "Please select a business and a hub.", + variant: "destructive", + }); + return; + } + + // Recalculate requested count from current roles + const totalRequested = eventData.shifts.reduce((sum: number, shift: any) => { + const roles = Array.isArray(shift.roles) ? shift.roles : []; + return sum + roles.reduce((roleSum: number, role: any) => roleSum + (parseInt(role.count) || 0), 0); + }, 0); + + createOrderMutation.mutate({ + eventName: eventData.event_name, + businessId: selectedBusinessId, + teamHubId: selectedHubId, + orderType: OrderType.RAPID, // Defaulting to RAPID as per common use in this app + date: eventData.date, + startDate: eventData.startDate || eventData.date, + endDate: eventData.endDate, + notes: eventData.notes, + shifts: eventData.shifts, + requested: totalRequested, + total: eventData.total, + poReference: eventData.po_reference, + status: OrderStatus.POSTED + }); + }; + + return ( + + + + Create New Order + + +
+
+ + +
+
+ + +
+
+ + onOpenChange(false)} + /> +
+
+ ); +} diff --git a/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx b/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx new file mode 100644 index 00000000..a2e4af32 --- /dev/null +++ b/apps/web/src/features/workforce/compliance/ComplianceDashboard.tsx @@ -0,0 +1,316 @@ +import { useMemo, useState } from "react"; +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Input } from "@/common/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { useListCertificates, useListStaff, useListTaxForms } from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import { format } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { + CheckCircle2, + Download, + FileText, + Search, + Users, + XCircle, + Clock +} from "lucide-react"; + + +export default function ComplianceDashboard() { + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + const { data: staffData, isLoading: staffLoading } = useListStaff(dataConnect); + const { data: taxFormsData, isLoading: taxFormsLoading } = useListTaxForms(dataConnect); + const { data: certificatesData, isLoading: certificatesLoading } = useListCertificates(dataConnect); + + const staff = staffData?.staffs || []; + const taxForms = taxFormsData?.taxForms || []; + const certificates = certificatesData?.certificates || []; + + const isLoading = staffLoading || taxFormsLoading || certificatesLoading; + + // Build compliance matrix + const complianceMatrix = useMemo(() => { + return staff.map(s => { + const staffTaxForms = taxForms.filter(tf => tf.staffId === s.id); + const i9 = staffTaxForms.find(tf => tf.formType === 'I9'); + const w4 = staffTaxForms.find(tf => tf.formType === 'W4'); + const staffCerts = certificates.filter(c => c.staffId === s.id); + + const hasExpiredCerts = staffCerts.some(c => c.status === 'EXPIRED'); + const hasExpiringCerts = staffCerts.some(c => c.status === 'EXPIRING_SOON' || c.status === 'EXPIRING'); + + let certsStatus = 'MISSING'; + if (staffCerts.length > 0) { + if (hasExpiredCerts) certsStatus = 'EXPIRED'; + else if (hasExpiringCerts) certsStatus = 'EXPIRING'; + else certsStatus = 'CURRENT'; + } + + const isI9Compliant = i9?.status === 'APPROVED'; + const isW4Compliant = w4?.status === 'APPROVED'; + const isCertsCompliant = staffCerts.length > 0 && !hasExpiredCerts; + + const isCompliant = isI9Compliant && isW4Compliant && isCertsCompliant; + + return { + id: s.id, + fullName: s.fullName, + i9Status: i9?.status || 'MISSING', + w4Status: w4?.status || 'MISSING', + certsStatus, + isCompliant, + hasExpiringCerts, + hasExpiredCerts + }; + }); + }, [staff, taxForms, certificates]); + + // Filtered matrix + const filteredMatrix = useMemo(() => { + let result = complianceMatrix; + + if (searchTerm) { + result = result.filter(row => + row.fullName.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + if (statusFilter === 'compliant') { + result = result.filter(row => row.isCompliant); + } else if (statusFilter === 'non-compliant') { + result = result.filter(row => !row.isCompliant); + } else if (statusFilter === 'expiring') { + result = result.filter(row => row.hasExpiringCerts); + } else if (statusFilter === 'missing') { + result = result.filter(row => row.i9Status === 'MISSING' || row.w4Status === 'MISSING' || row.certsStatus === 'MISSING'); + } + + return result; + }, [complianceMatrix, searchTerm, statusFilter]); + + // Stats + const stats = useMemo(() => { + const total = complianceMatrix.length; + const compliant = complianceMatrix.filter(r => r.isCompliant).length; + const missingDocs = complianceMatrix.reduce((acc, r) => { + if (r.i9Status === 'MISSING') acc++; + if (r.w4Status === 'MISSING') acc++; + return acc; + }, 0); + const expiringCerts = certificates.filter(c => c.status === 'EXPIRING_SOON' || c.status === 'EXPIRING').length; + const complianceRate = total > 0 ? Math.round((compliant / total) * 100) : 0; + + return { total, compliant, missingDocs, expiringCerts, complianceRate }; + }, [complianceMatrix, certificates]); + + const handleExport = () => { + const headers = ["Staff Name", "I-9 Status", "W-4 Status", "Certifications Status", "Overall Compliance"]; + const rows = filteredMatrix.map(r => [ + r.fullName, + r.i9Status, + r.w4Status, + r.certsStatus, + r.isCompliant ? "Compliant" : "Non-Compliant" + ]); + + const csvContent = [headers, ...rows].map(e => e.join(",")).join("\n"); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", `compliance_report_${format(new Date(), 'yyyy-MM-dd')}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'APPROVED': + case 'CURRENT': + return Current; + case 'EXPIRING': + case 'EXPIRING_SOON': + return Expiring; + case 'EXPIRED': + return Expired; + case 'MISSING': + case 'NOT_STARTED': + return Missing; + case 'PENDING': + case 'SUBMITTED': + return Pending; + default: + return {status}; + } + }; + + return ( + +
+ {/* Stats Bar */} +
+ + +
+ +
+
+

{stats.complianceRate}%

+

Compliance Rate

+
+
+
+ + + +
+ +
+
+

{stats.missingDocs}

+

Missing Docs

+
+
+
+ + + +
+ +
+
+

{stats.expiringCerts}

+

Expiring Certs

+
+
+
+ + + +
+ +
+
+

{stats.total}

+

Total Staff

+
+
+
+
+ + {/* Filters & Actions */} +
+
+ setSearchTerm(e.target.value)} + leadingIcon={} + className="max-w-xs" + /> + +
+ +
+ + {/* Table */} + + +
+ + + + + + + + + + + + + {filteredMatrix.map((row) => ( + + + + + + + + ))} + + +
Staff NameI-9 StatusW-4 StatusCertificationsOverall
+
+
+ {row.fullName.charAt(0)} +
+ {row.fullName} +
+
+ {getStatusBadge(row.i9Status)} + + {getStatusBadge(row.w4Status)} + + {getStatusBadge(row.certsStatus)} + + {row.isCompliant ? ( +
+ + COMPLIANT +
+ ) : ( +
+ + NON-COMPLIANT +
+ )} +
+ {filteredMatrix.length === 0 && !isLoading && ( +
+ No staff members found matching the criteria. +
+ )} + {isLoading && ( +
+ Loading compliance data... +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/workforce/directory/StaffList.tsx b/apps/web/src/features/workforce/directory/StaffList.tsx index e039a7f7..32b4d788 100644 --- a/apps/web/src/features/workforce/directory/StaffList.tsx +++ b/apps/web/src/features/workforce/directory/StaffList.tsx @@ -1,5 +1,8 @@ import { useState, useMemo} from "react"; import { Link } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { useToast } from "@/common/components/ui/use-toast"; import { Button } from "../../../common/components/ui/button"; import { Card, CardContent } from "../../../common/components/ui/card"; import { Badge } from "../../../common/components/ui/badge"; @@ -30,14 +33,29 @@ function StaffActiveStatus({ staffId }: { staffId: string }) { } export default function StaffList() { + const { toast } = useToast(); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [skillsFilter, setSkillsFilter] = useState([]); const [ratingRange, setRatingRange] = useState<[number, number]>([0, 5]); const [currentPage, setCurrentPage] = useState(1); + const user = useSelector((state: RootState) => state.auth.user); + const isAdmin = user?.userRole === "admin" || user?.userRole === "ADMIN"; + const { data: staffData, isLoading } = useListStaff(dataConnect); + const handleRestrictedAction = (e: React.MouseEvent) => { + if (!isAdmin) { + e.preventDefault(); + toast({ + title: "Access Restricted", + description: "Only administrators can perform this action.", + variant: "destructive" + }); + } + }; + const staff = useMemo(() => { return staffData?.staffs || []; }, [staffData]); @@ -103,8 +121,11 @@ export default function StaffList() { title="Staff Directory" subtitle={`${filteredStaff.length} staff members`} actions={ - - @@ -289,7 +310,11 @@ export default function StaffList() { className="hover:bg-primary/5 transition-colors group" > - + {member.fullName || 'N/A'} diff --git a/apps/web/src/features/workforce/documents/DocumentVault.tsx b/apps/web/src/features/workforce/documents/DocumentVault.tsx new file mode 100644 index 00000000..fc147dbf --- /dev/null +++ b/apps/web/src/features/workforce/documents/DocumentVault.tsx @@ -0,0 +1,608 @@ +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/common/components/ui/dialog"; +import { Input } from "@/common/components/ui/input"; +import { Label } from "@/common/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { Textarea } from "@/common/components/ui/textarea"; +import { Alert, AlertDescription } from "@/common/components/ui/alert"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { useQueryClient } from "@tanstack/react-query"; +import { differenceInDays, format } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { + CheckCircle2, + Clock, + FileText, + Loader2, + Plus, + Search, + Upload, + Users, + Download, + ShieldCheck, + Eye, + Calendar, + XCircle, + AlertCircle, + X, + FileCheck, + Flag +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { + useListStaff, + useListDocuments, + useCreateStaffDocument, + useUpdateStaffDocument +} from "@/dataconnect-generated/react"; +import { DocumentStatus, DocumentType } from "@/dataconnect-generated"; +import { dataConnect } from "@/features/auth/firebase"; +import { useToast } from "@/common/components/ui/use-toast"; +import { getStorage, ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; + +const DOCUMENT_TYPE_LABELS: Record = { + [DocumentType.W4_FORM]: "W-4 FORM", + [DocumentType.I9_FORM]: "I-9 FORM", + [DocumentType.STATE_TAX_FORM]: "STATE TAX FORM", + [DocumentType.DIRECT_DEPOSIT]: "DIRECT DEPOSIT", + [DocumentType.ID_COPY]: "ID COPY", + [DocumentType.SSN_CARD]: "SSN CARD", + [DocumentType.WORK_PERMIT]: "WORK PERMIT", +}; + +export default function DocumentVault() { + const [searchTerm, setSearchTerm] = useState(""); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showVerificationModal, setShowVerificationModal] = useState(false); + const [selectedCell, setSelectedCell] = useState(null); + const [docTypeFilter, setDocTypeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [expiryDate, setExpiryDate] = useState(""); + const [verificationNotes, setVerificationNotes] = useState(""); + const [flagReason, setFlagReason] = useState(""); + + const { toast } = useToast(); + const queryClient = useQueryClient(); + const user = useSelector((state: RootState) => state.auth.user); + const storage = getStorage(); + + // Data Connect Queries + const { data: staffData, isLoading: staffLoading } = useListStaff(dataConnect); + const { data: docTypesData } = useListDocuments(dataConnect); + + const staff = useMemo(() => staffData?.staffs || [], [staffData]); + const staffDocuments = useMemo(() => { + return staff.flatMap(s => (s as any).staffDocuments_on_staff || []); + }, [staff]); + + const availableDocTypes = useMemo(() => { + const dbDocs = docTypesData?.documents || []; + return Object.entries(DOCUMENT_TYPE_LABELS).map(([type, label]) => { + // Find the template document from DB for this type + const dbDoc = dbDocs.find(d => d.documentType === type); + return { + id: dbDoc?.id || type, + name: label, + documentType: type, + dbDoc: dbDoc + }; + }); + }, [docTypesData]); + + // Mutations + const { mutateAsync: createDoc } = useCreateStaffDocument(dataConnect); + const { mutateAsync: updateDoc } = useUpdateStaffDocument(dataConnect); + + const userRole = user?.userRole?.toLowerCase() || "admin"; + const isVendor = userRole === "vendor"; + const isAdmin = userRole === "admin"; + + // Filter staff by vendor + const filteredStaff = useMemo(() => { + let result = [...staff]; + if (isVendor && user?.uid) { + result = result.filter(s => s.ownerId === user.uid || s.createdBy === user.email); + } + if (searchTerm) { + result = result.filter(s => + s.fullName?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + return result; + }, [staff, isVendor, user, searchTerm]); + + // Build document matrix + const documentMatrix = useMemo(() => { + const matrix: Record = {}; + + filteredStaff.forEach(emp => { + matrix[emp.id] = { + employee: emp, + documents: {}, + completionRate: 0, + }; + + availableDocTypes.forEach(type => { + matrix[emp.id].documents[type.documentType] = null; + }); + }); + + staffDocuments.forEach(doc => { + if (matrix[doc.staffId]) { + // Calculate status based on expiry + let status = doc.status; + if (doc.expiryDate && status !== DocumentStatus.VERIFIED) { + const days = differenceInDays(new Date(doc.expiryDate), new Date()); + if (days < 0) status = DocumentStatus.EXPIRING; + else if (days <= 30) status = DocumentStatus.EXPIRING; + } + + // Map by documentType if available, otherwise fallback to documentId + const docType = doc.document?.documentType || + availableDocTypes.find(t => t.id === doc.documentId)?.documentType; + + if (docType) { + matrix[doc.staffId].documents[docType] = { ...doc, status }; + } + } + }); + + // Calculate completion rates + Object.values(matrix).forEach(row => { + const uploaded = Object.values(row.documents).filter((d: any) => + d && (d.status === DocumentStatus.UPLOADED || d.status === DocumentStatus.VERIFIED) + ).length; + row.completionRate = availableDocTypes.length > 0 ? Math.round((uploaded / availableDocTypes.length) * 100) : 0; + }); + + return matrix; + }, [filteredStaff, staffDocuments, availableDocTypes]); + + // Stats + const stats = useMemo(() => { + let uploaded = 0, pending = 0, expiring = 0, expired = 0, missing = 0; + + Object.values(documentMatrix).forEach(row => { + Object.values(row.documents).forEach((doc: any) => { + if (!doc) { + missing++; + return; + } + if (doc.status === DocumentStatus.UPLOADED || doc.status === DocumentStatus.VERIFIED) { + uploaded++; + } else if (doc.status === DocumentStatus.PENDING) { + pending++; + } else if (doc.status === DocumentStatus.EXPIRING) { + // Check if actually expired + if (doc.expiryDate && differenceInDays(new Date(doc.expiryDate), new Date()) < 0) { + expired++; + } else { + expiring++; + } + } + }); + }); + + return { uploaded, pending, expiring, expired, missing }; + }, [documentMatrix]); + + // Filter matrix by status + const filteredMatrix = useMemo(() => { + let result = { ...documentMatrix }; + + if (statusFilter !== "all") { + result = Object.fromEntries( + Object.entries(result).filter(([_, row]) => { + const docs = Object.values(row.documents); + if (statusFilter === "uploaded") return docs.some((d: any) => d?.status === DocumentStatus.UPLOADED || d?.status === DocumentStatus.VERIFIED); + if (statusFilter === "pending") return docs.some((d: any) => d?.status === DocumentStatus.PENDING); + if (statusFilter === "expiring") return docs.some((d: any) => { + if (d?.status === DocumentStatus.EXPIRING && d?.expiryDate) { + const days = differenceInDays(new Date(d.expiryDate), new Date()); + return days >= 0; + } + return false; + }); + if (statusFilter === "expired") return docs.some((d: any) => { + if (d?.status === DocumentStatus.EXPIRING && d?.expiryDate) { + const days = differenceInDays(new Date(d.expiryDate), new Date()); + return days < 0; + } + return false; + }); + if (statusFilter === "missing") return docs.some((d: any) => !d || d.status === DocumentStatus.MISSING); + return true; + }) + ); + } + + return result; + }, [documentMatrix, statusFilter]); + + const handleCellClick = (staffId: string, documentId: string, existingDoc: any) => { + const emp = documentMatrix[staffId]?.employee; + const docTypeInfo = availableDocTypes.find(t => t.documentType === documentId || t.id === documentId); + setSelectedCell({ + id: existingDoc?.id, + staffId: staffId, + staffName: emp?.fullName, + documentId: docTypeInfo?.id || documentId, + documentTypeName: docTypeInfo?.name, + ...existingDoc, + }); + setExpiryDate(existingDoc?.expiryDate || ""); + setShowUploadModal(true); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']; + if (!allowedTypes.includes(file.type)) { + toast({ title: "Invalid file type", description: "Please upload PDF, JPG, or PNG files only", variant: "destructive" }); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast({ title: "File too large", description: "Please upload files smaller than 5MB", variant: "destructive" }); + return; + } + setSelectedFile(file); + } + }; + + const handleUpload = async () => { + if (!selectedFile || !selectedCell) return; + setUploading(true); + setUploadProgress(0); + try { + const fileName = `documents/${selectedCell.staffId}/${selectedCell.documentId}/${Date.now()}_${selectedFile.name}`; + const storageRef = ref(storage, fileName); + const uploadTask = uploadBytesResumable(storageRef, selectedFile); + uploadTask.on('state_changed', (snapshot) => { + const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + setUploadProgress(progress); + }, (error) => { + toast({ title: "Upload failed", description: error.message, variant: "destructive" }); + setUploading(false); + }, async () => { + const downloadURL = await getDownloadURL(uploadTask.snapshot.ref); + if (selectedCell.id) { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.UPLOADED, + documentUrl: downloadURL, + expiryDate: expiryDate || null + }); + toast({ title: "Success", description: "Document updated successfully" }); + } else { + await createDoc({ + staffId: selectedCell.staffId, + staffName: selectedCell.staffName, + documentId: selectedCell.documentId, + status: DocumentStatus.UPLOADED, + documentUrl: downloadURL, + expiryDate: expiryDate || null + }); + toast({ title: "Success", description: "Document uploaded successfully" }); + } + queryClient.invalidateQueries(); + setShowUploadModal(false); + setSelectedCell(null); + setSelectedFile(null); + setExpiryDate(""); + setUploadProgress(0); + setUploading(false); + }); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + setUploading(false); + } + }; + + const handleVerify = async () => { + if (!selectedCell?.id) return; + setShowUploadModal(false); + setShowVerificationModal(true); + }; + + const confirmVerification = async () => { + if (!selectedCell?.id) return; + try { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.VERIFIED + }); + toast({ title: "Document Verified", description: `Document verified. ${verificationNotes ? 'Notes recorded.' : ''}` }); + queryClient.invalidateQueries(); + setShowVerificationModal(false); + setShowUploadModal(false); + setVerificationNotes(""); + setSelectedCell(null); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + } + }; + + const handleFlagIssue = async () => { + if (!selectedCell?.id || !flagReason) { + toast({ title: "Missing Information", description: "Please provide a reason for flagging this document", variant: "destructive" }); + return; + } + try { + await updateDoc({ + staffId: selectedCell.staffId, + documentId: selectedCell.documentId, + status: DocumentStatus.PENDING + }); + toast({ title: "Document Flagged", description: `Document flagged: ${flagReason}.` }); + queryClient.invalidateQueries(); + setShowUploadModal(false); + setFlagReason(""); + setSelectedCell(null); + } catch (error: any) { + toast({ title: "Error", description: error.message, variant: "destructive" }); + } + }; + + const renderCell = (doc: any, staffId: string, documentId: string) => { + const status = doc?.status || DocumentStatus.MISSING; + const daysUntilExpiry = doc?.expiryDate ? differenceInDays(new Date(doc.expiryDate), new Date()) : null; + + return ( + + ); + }; + + if (staffLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Stats Bar */} +
+ setStatusFilter('all')}> + +
+
{staff.length}

Employees

+
+
+ setStatusFilter('uploaded')}> + +
+
{stats.uploaded}

Uploaded

+
+
+ setStatusFilter('pending')}> + +
+
{stats.pending}

Pending

+
+
+ setStatusFilter('expiring')}> + +
+
{stats.expiring}

Expiring

+
+
+ setStatusFilter('expired')}> + +
+
{stats.expired}

Expired

+
+
+ setStatusFilter('missing')}> + +
+
{stats.missing}

Missing

+
+
+
+ + {/* Search & Filter Bar */} +
+
+ + setSearchTerm(e.target.value)} /> +
+ + +
+ + {/* Document Matrix Table */} +
+
+ + + + + {availableDocTypes.map((type, idx) => ( + + ))} + + + + + {Object.entries(filteredMatrix).map(([empId, row]) => ( + + + {availableDocTypes.map(type => ( + + ))} + + ))} + + {Object.keys(filteredMatrix).length === 0 && ( + + )} + +
Employees +
+ {type.name} +
+
+
+
+
{row.employee.fullName?.charAt(0) || '?'}
+
+
+
+ {row.employee.fullName} +
+
+ {row.completionRate}% COMPLETE +
+
+
+
+ {renderCell(row.documents[type.documentType], empId, type.documentType)} +

No employees match your current filters

+
+
+
+ + + + + +
+ {selectedCell?.documentTypeName} +
+

Document management for {selectedCell?.staffName}

+
+
+ {selectedCell?.documentUrl ? ( +
+
+ +

Document Uploaded

{selectedCell.status}

+
+ + +
+
+
+
+ {selectedCell.status === DocumentStatus.VERIFIED ? <>Verified : selectedCell.status === DocumentStatus.UPLOADED ? <>Uploaded : {selectedCell.status}} +
+
+ {selectedCell.expiryDate ? format(new Date(selectedCell.expiryDate), 'MMM d, yyyy') : 'No Expiry'} +
+
+
+ ) : ( +
+
+ +
document.getElementById('file-upload')?.click()}> + {selectedFile ? ( +
+ +

{selectedFile.name}

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

+ +
+ ) : ( + <>

Click or drag to upload

PDF, JPG or PNG (max 5MB)

+ )} + +
+ {uploading && ( +
+
Uploading...{Math.round(uploadProgress)}%
+
+
+ )} +
+
+ + setExpiryDate(e.target.value)} min={new Date().toISOString().split('T')[0]} /> +
+
+ )} +
+ + {selectedCell?.id ? ( + <> + {isAdmin && selectedCell.status !== DocumentStatus.VERIFIED && ( + + )} + {isAdmin && ( + + )} + + ) : ( + + )} + + +
+ + + +
Verify Document Authenticity
+
+ By verifying this document, you confirm that you have reviewed it for authenticity and validity. +