feat: Implement client facing dashboard

This commit is contained in:
dhinesh-m24
2026-02-12 10:56:10 +05:30
parent 4b2c7bfc23
commit 6d4aa4d0b9
2 changed files with 438 additions and 34 deletions

View File

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

View File

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