feat: Implement administrative dashboard
This commit is contained in:
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user