feat: Implement vendor dashboard
This commit is contained in:
@@ -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());
|
||||
|
||||
export default VendorDashboard
|
||||
// 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>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user