Merge pull request #402 from Oloodi/authentication-web
Dashboard, Compliance and Document PR
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -165,7 +165,7 @@ export const NAV_CONFIG: NavGroup[] = [
|
||||
label: 'Documents',
|
||||
path: '/documents',
|
||||
icon: FileText,
|
||||
allowedRoles: ['Vendor', 'Admin'],
|
||||
allowedRoles: ['Admin','Vendor'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div> Admin Dashboard</div>
|
||||
)
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
const [isOrderDialogOpen, setIsOrderDialogOpen] = useState(false);
|
||||
|
||||
export default AdminDashboard
|
||||
// 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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<motion.div
|
||||
className="flex justify-between items-center"
|
||||
variants={headerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
||||
<div className="flex gap-3">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsOrderDialogOpen(true)}
|
||||
>
|
||||
<PlusCircle size={18} />
|
||||
Create Order
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate('/staff/add')}
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
Add Staff
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<CreateOrderDialog
|
||||
open={isOrderDialogOpen}
|
||||
onOpenChange={setIsOrderDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Metrics Widgets */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{metrics.map((metric, index) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={metric.title}
|
||||
variants={itemVariants}
|
||||
whileHover={{
|
||||
y: -5,
|
||||
transition: { type: "spring", stiffness: 300, damping: 20 }
|
||||
}}
|
||||
>
|
||||
<Card className="overflow-hidden relative group">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
initial={false}
|
||||
/>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium">{metric.title}</CardTitle>
|
||||
<motion.div
|
||||
whileHover={{ rotate: 360 }}
|
||||
transition={{ duration: 0.6, ease: "easeInOut" }}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${metric.color}`} />
|
||||
</motion.div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<motion.div
|
||||
className="text-2xl font-bold"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3 + index * 0.1, type: "spring" }}
|
||||
>
|
||||
{metric.value}
|
||||
</motion.div>
|
||||
{metric.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{metric.description}
|
||||
</p>
|
||||
)}
|
||||
{metric.showProgress && (
|
||||
<div className="w-full bg-secondary h-2 mt-2 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-primary h-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${metric.progress}%` }}
|
||||
transition={{
|
||||
delay: 0.5 + index * 0.1,
|
||||
duration: 1,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Alerts Section */}
|
||||
{(unfilledPositions > 0 || complianceIssues > 0) && (
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.5 }}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-xl font-semibold flex items-center gap-2"
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: [0, -10, 10, -10, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.8,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 3
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="text-destructive" />
|
||||
</motion.div>
|
||||
Critical Alerts
|
||||
</motion.h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{unfilledPositions > 0 && (
|
||||
<motion.div
|
||||
variants={alertVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Unfilled Positions</AlertTitle>
|
||||
<AlertDescription>
|
||||
There are {unfilledPositions} shifts that currently have no staff assigned.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
{complianceIssues > 0 && (
|
||||
<motion.div
|
||||
variants={alertVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ delay: 0.9 }}
|
||||
>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Compliance Issues</AlertTitle>
|
||||
<AlertDescription>
|
||||
{complianceIssues} staff members have expired or missing documentation.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -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 (
|
||||
<div>ClientDashboard</div>
|
||||
)
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const [isOrderDialogOpen, setIsOrderDialogOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
export default ClientDashboard
|
||||
// 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 (
|
||||
<DashboardLayout
|
||||
title={`Welcome back, ${user?.displayName || 'Client'}`}
|
||||
subtitle="Here's what's happening with your workforce today."
|
||||
actions={
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setIsOrderDialogOpen(true)} leadingIcon={<Plus />}>
|
||||
Create Order
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate('/invoices')} leadingIcon={<FileText />}>
|
||||
View Invoices
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Today's Coverage</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">{coverage}%</div>
|
||||
<div className="w-full bg-secondary h-2 mt-3 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 h-full rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${coverage}%` }}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 0.2 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{todayShifts.length} active {todayShifts.length === 1 ? 'shift' : 'shifts'} today
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Monthly Spend</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-500/10 flex items-center justify-center">
|
||||
<TrendingUp className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">${monthlySpend.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
For {format(today, 'MMMM yyyy')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Upcoming Orders</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<CalendarIcon className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">{upcomingOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Scheduled this month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active Workers</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">
|
||||
{todayShifts.reduce((sum, s) => sum + (s.filled || 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Currently on shift
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Schedule Overview and Top Workers */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-1 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{/* Schedule Overview */}
|
||||
<Card className="lg:col-span-2 hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="border-b bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">Schedule Overview</CardTitle>
|
||||
<Badge variant="secondary" className="font-medium">
|
||||
{selectedDateShifts.length} {selectedDateShifts.length === 1 ? 'shift' : 'shifts'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid md:grid-cols-2 divide-x divide-border">
|
||||
{/* Calendar - Left Side */}
|
||||
<div className="p-6 flex items-center justify-center bg-muted/5">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
className="rounded-xl border shadow-sm bg-card p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shifts List - Right Side */}
|
||||
<div className="flex flex-col bg-card">
|
||||
<div className="px-6 py-4 border-b bg-muted/30 flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm text-foreground">
|
||||
{selectedDate ? format(selectedDate, 'EEEE, MMMM d, yyyy') : format(today, 'EEEE, MMMM d, yyyy')}
|
||||
</h3>
|
||||
<div className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{selectedDateShifts.length > 0 ? (
|
||||
selectedDateShifts.map((shift, index) => (
|
||||
<motion.div
|
||||
key={shift.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="group relative overflow-hidden rounded-lg border border-border bg-card p-4 hover:shadow-md hover:border-primary/50 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
|
||||
<div className="relative space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm text-foreground truncate">
|
||||
{shift.order?.eventName || 'Untitled Event'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{shift.startTime ? format(new Date(shift.startTime), 'h:mm a') : ''} - {shift.endTime ? format(new Date(shift.endTime), 'h:mm a') : ''}
|
||||
</p>
|
||||
</div>
|
||||
{shift.location && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<MapPin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{shift.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={shift.status === 'FILLED' ? 'default' : 'outline'}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{shift.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{shift.filled || 0}</span>
|
||||
<span className="mx-0.5">/</span>
|
||||
<span>{shift.workersNeeded || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="h-14 w-14 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<CalendarIcon className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">No shifts scheduled</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select a different date or create a new order
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Performing Workers */}
|
||||
<Card className="hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">Top Performers</CardTitle>
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{topWorkers.map((worker, index) => (
|
||||
<motion.div
|
||||
key={worker.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group flex items-center gap-4 p-3 rounded-lg hover:bg-accent/50 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center font-bold text-primary-foreground shadow-md">
|
||||
{worker.fullName.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 h-5 w-5 rounded-full bg-emerald-500 border-2 border-background flex items-center justify-center">
|
||||
<span className="text-[10px] font-bold text-white">#{index + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground truncate">{worker.fullName}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
<span className="text-xs font-bold">{worker.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground font-medium">{worker.shiftsCount} shifts</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-6 font-semibold hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
onClick={() => navigate('/workforce')}
|
||||
>
|
||||
View All Workforce
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<CreateOrderDialog
|
||||
open={isOrderDialogOpen}
|
||||
onOpenChange={setIsOrderDialogOpen}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDashboard;
|
||||
@@ -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 (
|
||||
<div>VendorDashboard</div>
|
||||
)
|
||||
const navigate = useNavigate();
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
// 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 <div className="flex items-center justify-center h-full">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
export default VendorDashboard
|
||||
return (
|
||||
<DashboardLayout
|
||||
title={`Welcome, ${vendor?.companyName || user?.displayName || 'Vendor'}`}
|
||||
subtitle="Overview of your operations and performance."
|
||||
actions={
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleNavigateToOrders} leadingIcon={<ClipboardList />}>
|
||||
View Orders
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleNavigateToStaff} leadingIcon={<Users />}>
|
||||
Manage Staff
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<Card
|
||||
className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300 cursor-pointer"
|
||||
onClick={handleNavigateToOrders}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active Orders</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">{stats.activeOrders}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Currently in progress</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Staff on Shift</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center">
|
||||
<UserCheck className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">{stats.staffOnShift}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Working right now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Monthly Revenue</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-500/10 flex items-center justify-center">
|
||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">${stats.monthlyRevenue.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Total for {format(dateRange.today, 'MMMM')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden relative group hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Shift Fill Rate</CardTitle>
|
||||
<div className="h-10 w-10 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<BarChart3 className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">{stats.fillRate}%</div>
|
||||
<div className="w-full bg-secondary h-2 mt-3 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-amber-500 to-amber-600 h-full rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${stats.fillRate}%` }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Schedule Overview */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-1 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<Card className="lg:col-span-2 hover:shadow-lg transition-shadow duration-300">
|
||||
<CardHeader className="border-b bg-muted/20">
|
||||
<CardTitle className="text-xl">Daily Operations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid md:grid-cols-2 divide-x divide-border">
|
||||
<div className="p-6 flex items-center justify-center bg-muted/5">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
className="rounded-xl border shadow-sm bg-card p-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<h3 className="font-bold text-sm text-foreground">
|
||||
{selectedDate ? format(selectedDate, 'EEEE, MMMM d, yyyy') : 'Select a date'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{dailyShiftsData?.shifts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarIcon className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">No shifts for this date</p>
|
||||
</div>
|
||||
) : (
|
||||
dailyShiftsData?.shifts.map(shift => (
|
||||
<div key={shift.id} className="p-3 rounded-lg border bg-card hover:border-primary transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{shift.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{shift.startTime ? format(new Date(shift.startTime), 'p') : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={shift.status === 'FILLED' ? 'default' : 'outline'}>
|
||||
{shift.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorDashboard;
|
||||
@@ -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<string>("");
|
||||
const [selectedHubId, setSelectedHubId] = React.useState<string>("");
|
||||
|
||||
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<string, any>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Order</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Business</Label>
|
||||
<Select value={selectedBusinessId} onValueChange={setSelectedBusinessId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businessesData?.businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.businessName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Team Hub</Label>
|
||||
<Select value={selectedHubId} onValueChange={setSelectedHubId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hubsData?.hubs.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventFormWizard
|
||||
event={null}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={createOrderMutation.isPending}
|
||||
currentUser={null}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20">Current</Badge>;
|
||||
case 'EXPIRING':
|
||||
case 'EXPIRING_SOON':
|
||||
return <Badge className="bg-amber-500/10 text-amber-600 border-amber-500/20">Expiring</Badge>;
|
||||
case 'EXPIRED':
|
||||
return <Badge className="bg-rose-500/10 text-rose-600 border-rose-500/20">Expired</Badge>;
|
||||
case 'MISSING':
|
||||
case 'NOT_STARTED':
|
||||
return <Badge variant="outline" className="text-muted-foreground">Missing</Badge>;
|
||||
case 'PENDING':
|
||||
case 'SUBMITTED':
|
||||
return <Badge className="bg-blue-500/10 text-blue-600 border-blue-500/20">Pending</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Compliance Dashboard"
|
||||
subtitle="Overview of staff compliance status and certifications"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-border/50 glass">
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20">
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-foreground">{stats.complianceRate}%</p>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Compliance Rate</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 glass">
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-rose-500/10 rounded-xl flex items-center justify-center border border-rose-500/20">
|
||||
<FileText className="w-6 h-6 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-foreground">{stats.missingDocs}</p>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Missing Docs</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 glass">
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center border border-amber-500/20">
|
||||
<Clock className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-foreground">{stats.expiringCerts}</p>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Expiring Certs</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 glass">
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-cyan-500/10 rounded-xl flex items-center justify-center border border-cyan-500/20">
|
||||
<Users className="w-6 h-6 text-cyan-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-foreground">{stats.total}</p>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total Staff</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters & Actions */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-4 w-full md:w-auto">
|
||||
<Input
|
||||
placeholder="Search staff..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leadingIcon={<Search className="w-4 h-4" />}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="compliant">Compliant</SelectItem>
|
||||
<SelectItem value="non-compliant">Non-Compliant</SelectItem>
|
||||
<SelectItem value="expiring">Expiring Certs</SelectItem>
|
||||
<SelectItem value="missing">Missing Docs</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleExport} variant="outline" className="gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 border-b border-border/50">
|
||||
<th className="text-left py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Staff Name</th>
|
||||
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">I-9 Status</th>
|
||||
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">W-4 Status</th>
|
||||
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Certifications</th>
|
||||
<th className="text-center py-4 px-6 font-bold text-xs uppercase tracking-wider text-muted-foreground">Overall</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredMatrix.map((row) => (
|
||||
<motion.tr
|
||||
key={row.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="hover:bg-primary/5 transition-colors group"
|
||||
>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold text-xs">
|
||||
{row.fullName.charAt(0)}
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{row.fullName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
{getStatusBadge(row.i9Status)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
{getStatusBadge(row.w4Status)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
{getStatusBadge(row.certsStatus)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
{row.isCompliant ? (
|
||||
<div className="flex items-center justify-center gap-1.5 text-emerald-600 font-bold text-xs">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
COMPLIANT
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1.5 text-rose-600 font-bold text-xs">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
NON-COMPLIANT
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredMatrix.length === 0 && !isLoading && (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
No staff members found matching the criteria.
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
Loading compliance data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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={
|
||||
<Link to="/staff/add">
|
||||
<Button leadingIcon={<UserPlus />}>
|
||||
<Link to="/staff/add" onClick={handleRestrictedAction}>
|
||||
<Button
|
||||
leadingIcon={<UserPlus />}
|
||||
className={!isAdmin ? "opacity-50 cursor-not-allowed hover:opacity-50" : ""}
|
||||
>
|
||||
Add New Staff
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -289,7 +310,11 @@ export default function StaffList() {
|
||||
className="hover:bg-primary/5 transition-colors group"
|
||||
>
|
||||
<td className="py-4 px-6">
|
||||
<Link to={`/staff/${member.id}/edit`} className="font-bold text-foreground group-hover:text-primary transition-colors hover:underline">
|
||||
<Link
|
||||
to={`/staff/${member.id}/edit`}
|
||||
onClick={handleRestrictedAction}
|
||||
className={`font-bold transition-colors hover:underline ${!isAdmin ? "text-muted-foreground cursor-not-allowed" : "text-foreground group-hover:text-primary"}`}
|
||||
>
|
||||
{member.fullName || 'N/A'}
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
608
apps/web/src/features/workforce/documents/DocumentVault.tsx
Normal file
608
apps/web/src/features/workforce/documents/DocumentVault.tsx
Normal file
@@ -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<string, string> = {
|
||||
[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<any>(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<File | null>(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<string, any> = {};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<button
|
||||
onClick={() => handleCellClick(staffId, documentId, doc)}
|
||||
className={`w-full h-16 rounded-xl flex flex-col items-center justify-center transition-all hover:scale-[1.02] relative group border-none ${
|
||||
status === DocumentStatus.MISSING ? "bg-gray-50/50" :
|
||||
status === DocumentStatus.UPLOADED || status === DocumentStatus.VERIFIED ? "bg-cyan-50/50" :
|
||||
"bg-orange-50/50"
|
||||
}`}
|
||||
>
|
||||
{status === DocumentStatus.MISSING ? (
|
||||
<Plus className="w-5 h-5 text-gray-300" />
|
||||
) : status === DocumentStatus.UPLOADED || status === DocumentStatus.VERIFIED ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<FileText className="w-5 h-5 text-cyan-500" />
|
||||
{status === DocumentStatus.VERIFIED && (
|
||||
<span className="text-[8px] text-cyan-600 font-bold uppercase tracking-tighter">Verified</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Clock className="w-5 h-5 text-orange-400" />
|
||||
<span className="text-[9px] text-orange-600 font-bold tracking-tight">
|
||||
{status === DocumentStatus.PENDING ? 'Pending' : (daysUntilExpiry !== null && daysUntilExpiry < 0 ? 'Expired' : `${daysUntilExpiry}d`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
if (staffLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Employee Documents" subtitle="Track and manage all required documents">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Employee Documents" subtitle="Track and manage all required documents">
|
||||
<div className="space-y-6">
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'all' ? 'ring-2 ring-blue-500' : ''}`} onClick={() => setStatusFilter('all')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center"><Users className="w-6 h-6 text-blue-600" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{staff.length}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Employees</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'uploaded' ? 'ring-2 ring-blue-500' : ''}`} onClick={() => setStatusFilter('uploaded')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-cyan-50 rounded-2xl flex items-center justify-center"><CheckCircle2 className="w-6 h-6 text-cyan-500" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{stats.uploaded}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Uploaded</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'pending' ? 'ring-2 ring-orange-500' : ''}`} onClick={() => setStatusFilter('pending')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-orange-50 rounded-2xl flex items-center justify-center"><Clock className="w-6 h-6 text-orange-500" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{stats.pending}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Pending</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'expiring' ? 'ring-2 ring-amber-500' : ''}`} onClick={() => setStatusFilter('expiring')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-amber-50 rounded-2xl flex items-center justify-center"><AlertCircle className="w-6 h-6 text-amber-500" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{stats.expiring}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Expiring</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'expired' ? 'ring-2 ring-red-500' : ''}`} onClick={() => setStatusFilter('expired')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center"><XCircle className="w-6 h-6 text-red-500" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{stats.expired}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Expired</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`cursor-pointer border-none shadow-sm ${statusFilter === 'missing' ? 'ring-2 ring-gray-500' : ''}`} onClick={() => setStatusFilter('missing')}>
|
||||
<CardContent className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gray-50 rounded-2xl flex items-center justify-center"><Plus className="w-6 h-6 text-gray-400" /></div>
|
||||
<div><span className="text-2xl font-bold text-gray-900">{stats.missing}</span><p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Missing</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input placeholder="Search employees..." className="pl-12 h-12 bg-white border-gray-100 rounded-xl shadow-sm" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
||||
</div>
|
||||
<Select value={docTypeFilter} onValueChange={setDocTypeFilter}>
|
||||
<SelectTrigger className="w-[80px] h-12 bg-white border-gray-100 rounded-xl"><SelectValue placeholder="All" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{availableDocTypes.map(type => <SelectItem key={type.documentType} value={type.documentType}>{type.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[80px] h-12 bg-white border-gray-100 rounded-xl"><SelectValue placeholder="All" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="uploaded">Uploaded</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="expiring">Expiring</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
<SelectItem value="missing">Missing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Document Matrix Table */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-6 px-8 font-bold text-gray-400 text-[10px] uppercase tracking-wider min-w-[280px]">Employees</th>
|
||||
{availableDocTypes.map((type, idx) => (
|
||||
<th key={type.documentType} className="p-4 min-w-[160px]">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="font-bold text-gray-600 text-[10px] uppercase tracking-wider">{type.name}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{Object.entries(filteredMatrix).map(([empId, row]) => (
|
||||
<motion.tr key={empId} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="group">
|
||||
<td className="py-6 px-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-50 flex items-center justify-center font-bold text-blue-600 text-lg">{row.employee.fullName?.charAt(0) || '?'}</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-blue-600 rounded-full border-2 border-white" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-bold text-gray-900 text-sm">{row.employee.fullName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 bg-gray-50 rounded-full overflow-hidden"><div className="h-full bg-blue-600" style={{ width: `${row.completionRate}%` }} /></div>
|
||||
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-wider">{row.completionRate}% COMPLETE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{availableDocTypes.map(type => (
|
||||
<td key={type.documentType} className="p-3">
|
||||
{renderCell(row.documents[type.documentType], empId, type.documentType)}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{Object.keys(filteredMatrix).length === 0 && (
|
||||
<tr><td colSpan={availableDocTypes.length + 1} className="py-12 text-center"><p className="text-gray-500">No employees match your current filters</p></td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
|
||||
{selectedCell?.documentTypeName}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500">Document management for <span className="font-semibold text-gray-900">{selectedCell?.staffName}</span></p>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{selectedCell?.documentUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-[16/9] rounded-xl border border-gray-200 bg-gray-50 flex flex-col items-center justify-center gap-3 relative overflow-hidden group">
|
||||
<FileText className="w-12 h-12 text-gray-400" />
|
||||
<div className="text-center"><p className="text-sm font-medium">Document Uploaded</p><p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mt-1">{selectedCell.status}</p></div>
|
||||
<div className="absolute inset-0 bg-white/90 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4">
|
||||
<Button size="sm" onClick={() => window.open(selectedCell.documentUrl, '_blank')}><Eye className="w-4 h-4 mr-2" /> View</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { const link = document.createElement('a'); link.href = selectedCell.documentUrl; link.download = `${selectedCell.staffName}_${selectedCell.documentTypeName}`; link.click(); }}><Download className="w-4 h-4 mr-2" /> Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label className="text-xs font-semibold uppercase text-gray-500">Status</Label><div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||
{selectedCell.status === DocumentStatus.VERIFIED ? <><ShieldCheck className="w-4 h-4 text-emerald-500" /><span className="text-sm font-medium text-emerald-600">Verified</span></> : selectedCell.status === DocumentStatus.UPLOADED ? <><FileCheck className="w-4 h-4 text-blue-500" /><span className="text-sm font-medium">Uploaded</span></> : <span className="text-sm font-medium">{selectedCell.status}</span>}
|
||||
</div></div>
|
||||
<div className="space-y-2"><Label className="text-xs font-semibold uppercase text-gray-500">Expiry Date</Label><div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<Calendar className="w-4 h-4 text-blue-600" /><span className="text-sm font-medium">{selectedCell.expiryDate ? format(new Date(selectedCell.expiryDate), 'MMM d, yyyy') : 'No Expiry'}</span>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Upload Document</Label>
|
||||
<div className="aspect-[16/9] rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center gap-4 hover:bg-gray-100 transition-colors cursor-pointer" onClick={() => document.getElementById('file-upload')?.click()}>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileCheck className="w-12 h-12 text-emerald-500" />
|
||||
<div className="text-center"><p className="text-sm font-medium">{selectedFile.name}</p><p className="text-xs text-gray-500 mt-1">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p></div>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedFile(null); }}><X className="w-4 h-4 mr-1" /> Remove</Button>
|
||||
</div>
|
||||
) : (
|
||||
<><div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center"><Upload className="w-6 h-6 text-blue-600" /></div><div className="text-center"><p className="text-sm font-medium">Click or drag to upload</p><p className="text-xs text-gray-500 mt-1">PDF, JPG or PNG (max 5MB)</p></div></>
|
||||
)}
|
||||
<input id="file-upload" type="file" accept=".pdf,.jpg,.jpeg,.png" onChange={handleFileSelect} className="hidden" />
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-gray-500"><span>Uploading...</span><span>{Math.round(uploadProgress)}%</span></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden"><div className="h-full bg-blue-600 transition-all duration-300" style={{ width: `${uploadProgress}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Expiry Date (Optional)</Label>
|
||||
<Input type="date" className="bg-white border-gray-200" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} min={new Date().toISOString().split('T')[0]} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0 flex-col sm:flex-row">
|
||||
{selectedCell?.id ? (
|
||||
<>
|
||||
{isAdmin && selectedCell.status !== DocumentStatus.VERIFIED && (
|
||||
<Button variant="outline" className="text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 border-emerald-200" onClick={handleVerify}><ShieldCheck className="w-4 h-4 mr-2" /> Verify Document</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button variant="outline" className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 border-amber-200" onClick={() => { const reason = window.prompt("Enter reason for flagging this document:"); if (reason) { setFlagReason(reason); handleFlagIssue(); } }}><Flag className="w-4 h-4 mr-2" /> Flag Issue</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-700" onClick={handleUpload} disabled={!selectedFile || uploading}>{uploading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Uploading...</> : <><Upload className="w-4 h-4 mr-2" /> Complete Upload</>}</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showVerificationModal} onOpenChange={setShowVerificationModal}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle className="flex items-center gap-2 text-xl"><div className="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center"><ShieldCheck className="w-5 h-5 text-emerald-600" /></div>Verify Document Authenticity</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert className="border-emerald-200 bg-emerald-50"><ShieldCheck className="h-4 w-4 text-emerald-600" /><AlertDescription className="text-emerald-800">By verifying this document, you confirm that you have reviewed it for authenticity and validity.</AlertDescription></Alert>
|
||||
<div className="space-y-2"><Label className="text-sm font-medium">Verification Notes (Optional)</Label><Textarea placeholder="Add any notes..." className="min-h-[100px] resize-none" value={verificationNotes} onChange={(e) => setVerificationNotes(e.target.value)} /></div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2"><Button variant="outline" onClick={() => setShowVerificationModal(false)}>Cancel</Button><Button className="bg-emerald-600 hover:bg-emerald-700" onClick={confirmVerification}><ShieldCheck className="w-4 h-4 mr-2" />Confirm Verification</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import TaskBoard from './features/operations/tasks/TaskBoard';
|
||||
import InvoiceList from './features/finance/invoices/InvoiceList';
|
||||
import InvoiceDetail from './features/finance/invoices/InvoiceDetail';
|
||||
import InvoiceEditor from './features/finance/invoices/InvoiceEditor';
|
||||
import ComplianceDashboard from './features/workforce/compliance/ComplianceDashboard';
|
||||
import DocumentVault from './features/workforce/documents/DocumentVault';
|
||||
|
||||
/**
|
||||
* AppRoutes Component
|
||||
@@ -96,6 +98,8 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/staff" element={<StaffList />} />
|
||||
<Route path="/staff/add" element={<AddStaff />} />
|
||||
<Route path="/staff/:id/edit" element={<EditStaff />} />
|
||||
<Route path="/compliance" element={<ComplianceDashboard />} />
|
||||
<Route path="/documents" element={<DocumentVault />} />
|
||||
{/* Business Routes */}
|
||||
<Route path="/clients" element={<ClientList />} />
|
||||
<Route path="/clients/:id/edit" element={<EditClient />} />
|
||||
|
||||
Reference in New Issue
Block a user