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