feat: Implement vendor dashboard

This commit is contained in:
dhinesh-m24
2026-02-12 12:46:57 +05:30
parent 6d4aa4d0b9
commit 02374b6b05
2 changed files with 335 additions and 9 deletions

View File

@@ -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;

View File

@@ -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>