feat: Implement administrative dashboard

This commit is contained in:
dhinesh-m24
2026-02-11 16:59:26 +05:30
parent 1361253731
commit 4b2c7bfc23
2 changed files with 465 additions and 5 deletions

View File

@@ -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 = () => { const AdminDashboard = () => {
return ( const navigate = useNavigate();
<div> Admin Dashboard</div> 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;

View File

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