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 = () => {
|
const VendorDashboard = () => {
|
||||||
return (
|
const navigate = useNavigate();
|
||||||
<div>VendorDashboard</div>
|
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 { useState, useMemo} from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { Button } from "../../../common/components/ui/button";
|
||||||
import { Card, CardContent } from "../../../common/components/ui/card";
|
import { Card, CardContent } from "../../../common/components/ui/card";
|
||||||
import { Badge } from "../../../common/components/ui/badge";
|
import { Badge } from "../../../common/components/ui/badge";
|
||||||
@@ -30,14 +33,29 @@ function StaffActiveStatus({ staffId }: { staffId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StaffList() {
|
export default function StaffList() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
const [ratingRange, setRatingRange] = useState<[number, number]>([0, 5]);
|
const [ratingRange, setRatingRange] = useState<[number, number]>([0, 5]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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 { 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(() => {
|
const staff = useMemo(() => {
|
||||||
return staffData?.staffs || [];
|
return staffData?.staffs || [];
|
||||||
}, [staffData]);
|
}, [staffData]);
|
||||||
@@ -103,8 +121,11 @@ export default function StaffList() {
|
|||||||
title="Staff Directory"
|
title="Staff Directory"
|
||||||
subtitle={`${filteredStaff.length} staff members`}
|
subtitle={`${filteredStaff.length} staff members`}
|
||||||
actions={
|
actions={
|
||||||
<Link to="/staff/add">
|
<Link to="/staff/add" onClick={handleRestrictedAction}>
|
||||||
<Button leadingIcon={<UserPlus />}>
|
<Button
|
||||||
|
leadingIcon={<UserPlus />}
|
||||||
|
className={!isAdmin ? "opacity-50 cursor-not-allowed hover:opacity-50" : ""}
|
||||||
|
>
|
||||||
Add New Staff
|
Add New Staff
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -289,7 +310,11 @@ export default function StaffList() {
|
|||||||
className="hover:bg-primary/5 transition-colors group"
|
className="hover:bg-primary/5 transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="py-4 px-6">
|
<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'}
|
{member.fullName || 'N/A'}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user