From f52ee4583a0eaf71d6762a9654f9581824635a39 Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Mon, 9 Feb 2026 13:33:37 +0530 Subject: [PATCH] feat: Implement Staff Availability View for Administrators --- .../availability/StaffAvailability.tsx | 353 ++++++++++++++++++ apps/web/src/routes.tsx | 3 + 2 files changed, 356 insertions(+) create mode 100644 apps/web/src/features/operations/availability/StaffAvailability.tsx diff --git a/apps/web/src/features/operations/availability/StaffAvailability.tsx b/apps/web/src/features/operations/availability/StaffAvailability.tsx new file mode 100644 index 00000000..43bbd062 --- /dev/null +++ b/apps/web/src/features/operations/availability/StaffAvailability.tsx @@ -0,0 +1,353 @@ +import { useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Badge } from "@/common/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/common/components/ui/avatar"; +import { Input } from "@/common/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { + Users, + TrendingUp, + TrendingDown, + XCircle, + Search, + ChevronLeft, + ChevronRight, + Briefcase +} from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + useListStaff, + useListStaffAvailabilities, + useListStaffAvailabilityStats +} from "@/dataconnect-generated/react"; +import { DayOfWeek, AvailabilityStatus } from "@/dataconnect-generated"; +import { dataConnect } from "@/features/auth/firebase"; + +/** + * StaffAvailability Feature Component + * Displays worker availability in a grid view (Staff x Days). + */ +export default function StaffAvailability() { + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedSkill, setSelectedSkill] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(50); + + const { data: staffData, isLoading: loadingStaff } = useListStaff(dataConnect); + const { data: availabilityData, isLoading: loadingAvail } = useListStaffAvailabilities(dataConnect); + const { data: statsData, isLoading: loadingStats } = useListStaffAvailabilityStats(dataConnect); + + const isLoading = loadingStaff || loadingAvail || loadingStats; + + const allStaff = staffData?.staffs || []; + const allAvailabilities = availabilityData?.staffAvailabilities || []; + const allStats = statsData?.staffAvailabilityStatss || []; + + // Get unique skills for filtering + const allSkills = useMemo(() => { + const skills = new Set(); + allStaff.forEach(s => { + s.skills?.forEach(skill => skills.add(skill)); + }); + return Array.from(skills).sort(); + }, [allStaff]); + + // Calculate metrics + const metrics = useMemo(() => { + const availableNow = allAvailabilities.filter(a => a.status === AvailabilityStatus.CONFIRMED_AVAILABLE).length; + const blocked = allAvailabilities.filter(a => a.status === AvailabilityStatus.BLOCKED).length; + const totalStaff = allStaff.length; + const highUtilization = allStats.filter(s => (s.utilizationPercentage || 0) >= 90).length; + + return { availableNow, blocked, totalStaff, highUtilization }; + }, [allAvailabilities, allStaff, allStats]); + + // Filter and search logic + const filteredStaff = useMemo(() => { + let filtered = [...allStaff]; + + // Search + if (searchTerm) { + filtered = filtered.filter(s => + s.fullName.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Skill filter + if (selectedSkill !== "all") { + filtered = filtered.filter(s => s.skills?.includes(selectedSkill)); + } + + return filtered; + }, [allStaff, searchTerm, selectedSkill]); + + const days = [ + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + DayOfWeek.SATURDAY, + DayOfWeek.SUNDAY + ]; + + // Pagination + const totalPages = Math.ceil(filteredStaff.length / itemsPerPage) || 1; + const paginatedStaff = filteredStaff.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + const getStatusColor = (status?: AvailabilityStatus) => { + switch (status) { + case AvailabilityStatus.CONFIRMED_AVAILABLE: + return "bg-emerald-500"; + case AvailabilityStatus.BLOCKED: + return "bg-rose-500"; + case AvailabilityStatus.UNKNOWN: + default: + return "bg-gray-400"; + } + }; + + return ( + +
+ {/* Metrics Grid */} +
+ {[ + { label: 'Available Now', value: metrics.availableNow, icon: TrendingDown, color: 'emerald', subtext: 'Ready for deployment' }, + { label: 'Blocked Assets', value: metrics.blocked, icon: XCircle, color: 'rose', subtext: 'Non-deployable' }, + { label: 'High Utilization', value: metrics.highUtilization, icon: TrendingUp, color: 'indigo', subtext: 'Above 90%' }, + { label: 'Total Personnel', value: metrics.totalStaff, icon: Users, color: 'primary', subtext: 'Total force' } + ].map((metric, i) => ( + + + +
+
+

{metric.label}

+

{metric.value}

+
+
+ +
+
+
+
+
+ ))} +
+ + {/* Filter Toolbar */} +
+
+ setSearchTerm(e.target.value)} + leadingIcon={} + /> +
+ +
+ +
+
+ + {/* Main View Transition */} + + {isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : paginatedStaff.length === 0 ? ( +
+
+ +
+

No personnel located

+

Try adjusting your filters or search criteria to locate command assets.

+
+ ) : ( + + {paginatedStaff.map((staff) => { + const staffAvails = allAvailabilities.filter(a => a.staffId === staff.id); + const staffStats = allStats.find(s => s.staffId === staff.id); + + return ( + + + +
+ {/* Staff Info Sidebar */} +
navigate(`/staff/${staff.id}/edit`)} + > +
+ + + {staff.fullName.charAt(0)} + + +
+

{staff.fullName}

+

{staff.role || 'Field Staff'}

+
+
+
+ {staff.skills?.slice(0, 3).map(skill => ( + + {skill} + + ))} +
+
+ + {/* Availability Grid */} +
+ {days.map(day => { + const avail = staffAvails.find(a => a.day === day); + return ( +
+
+ {day.substring(0, 3)} +
+
+
+ {avail?.status === AvailabilityStatus.CONFIRMED_AVAILABLE ? "AVAIL" : + avail?.status === AvailabilityStatus.BLOCKED ? "BLOCK" : "UNKNOWN"} +
+
+
+ ); + })} +
+ + {/* Stats Sidebar */} +
+
+ Utilization + {Math.round(staffStats?.utilizationPercentage || 0)}% +
+
+
+
+
+ Score + {staffStats?.acceptanceRate || 0}% +
+
+
+ + + + ); + })} + + )} + + + {/* Pagination Controls */} +
+
+ Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)} of {filteredStaff.length} personnel + +
+ +
+ + +
+ {currentPage} + / + {totalPages} +
+ + +
+
+
+ + ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 9db84937..a344f9b1 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -22,6 +22,7 @@ import ClientOrderList from './features/operations/orders/ClientOrderList'; import VendorOrderList from './features/operations/orders/VendorOrderList'; import EditOrder from './features/operations/orders/EditOrder'; import Schedule from './features/operations/schedule/Schedule'; +import StaffAvailability from './features/operations/availability/StaffAvailability'; /** * AppRoutes Component @@ -96,6 +97,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {/* Operations Routes */} } /> } /> @@ -105,6 +107,7 @@ const AppRoutes: React.FC = () => { } /> + } /> } />