feat: Implement Staff Availability View for Administrators

This commit is contained in:
dhinesh-m24
2026-02-09 13:33:37 +05:30
parent 651700348d
commit f52ee4583a
2 changed files with 356 additions and 0 deletions

View File

@@ -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<string>();
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 (
<DashboardLayout
title="Resource Availability"
subtitle={`${filteredStaff.length} command personnel assets tracked`}
>
<div className="space-y-6">
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{ 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) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<Card className="bg-card border-border/50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-6">
<div className="flex items-center justify-between pointer-events-none">
<div>
<p className="text-secondary-text text-sm font-medium mb-1">{metric.label}</p>
<p className="text-3xl font-medium tracking-tight text-primary-text">{metric.value}</p>
</div>
<div className={`w-12 h-12 ${metric.color === 'primary' ? 'bg-primary/10' : `bg-${metric.color}-500/10`} rounded-xl flex items-center justify-center border ${metric.color === 'primary' ? 'border-primary/20' : `border-${metric.color}-500/20`}`}>
<metric.icon className={`w-6 h-6 ${metric.color === 'primary' ? 'text-primary' : `text-${metric.color}-600`}`} />
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* Filter Toolbar */}
<div className="flex flex-col md:flex-row gap-4 items-start">
<div className="relative flex-1">
<Input
placeholder="Search by personnel name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leadingIcon={<Search />}
/>
</div>
<div className="flex flex-wrap gap-3">
<Select value={selectedSkill} onValueChange={setSelectedSkill}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<Briefcase className="w-4 h-4 text-muted-foreground/50" />
<SelectValue placeholder="All Skills" />
</div>
</SelectTrigger>
<SelectContent className="rounded-lg border-border/50 bg-card">
<SelectItem value="all">All Skills</SelectItem>
{allSkills.map(skill => (
<SelectItem key={skill} value={skill}>{skill}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Main View Transition */}
<AnimatePresence mode="wait">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-pulse">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-muted/40 rounded-3xl border border-border/50" />
))}
</div>
) : paginatedStaff.length === 0 ? (
<div className="text-center py-20 bg-card rounded-2xl border border-border/50 shadow-sm">
<div className="w-20 h-20 bg-muted/30 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-10 h-10 text-muted-text" />
</div>
<h3 className="text-xl font-medium text-primary-text mb-2">No personnel located</h3>
<p className="text-secondary-text font-medium max-w-xs mx-auto">Try adjusting your filters or search criteria to locate command assets.</p>
</div>
) : (
<motion.div
key="grid"
initial="hidden"
animate="visible"
exit="hidden"
variants={{
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.05 } }
}}
className="space-y-4"
>
{paginatedStaff.map((staff) => {
const staffAvails = allAvailabilities.filter(a => a.staffId === staff.id);
const staffStats = allStats.find(s => s.staffId === staff.id);
return (
<motion.div
key={staff.id}
variants={{
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 }
}}
>
<Card className="bg-card border-border/50 shadow-sm hover:shadow-md transition-all group overflow-hidden">
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row">
{/* Staff Info Sidebar */}
<div
className="p-4 lg:w-64 border-b lg:border-b-0 lg:border-r border-border/40 bg-muted/5 cursor-pointer hover:bg-muted/10 transition-colors"
onClick={() => navigate(`/staff/${staff.id}/edit`)}
>
<div className="flex items-center gap-3 mb-3">
<Avatar className="w-10 h-10 rounded-lg border border-border/50 shadow-sm bg-white">
<AvatarFallback className="bg-primary/10 text-primary font-medium uppercase">
{staff.fullName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-semibold text-sm text-primary-text truncate">{staff.fullName}</p>
<p className="text-[10px] text-secondary-text uppercase font-medium tracking-wider">{staff.role || 'Field Staff'}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
{staff.skills?.slice(0, 3).map(skill => (
<Badge key={skill} variant="secondary" className="text-[9px] px-1 py-0 h-4 bg-muted/40 text-secondary-text border-none">
{skill}
</Badge>
))}
</div>
</div>
{/* Availability Grid */}
<div className="flex-1 grid grid-cols-7 divide-x divide-border/40">
{days.map(day => {
const avail = staffAvails.find(a => a.day === day);
return (
<div key={day} className="flex flex-col min-h-[80px]">
<div className="px-2 py-1.5 border-b border-border/40 bg-muted/20 text-center">
<span className="text-[10px] font-bold text-secondary-text uppercase tracking-widest">{day.substring(0, 3)}</span>
</div>
<div className="flex-1 p-2 flex items-center justify-center">
<div
className={`w-full h-8 rounded-md ${getStatusColor(avail?.status)} opacity-80 flex items-center justify-center text-[10px] font-bold text-white shadow-sm`}
title={avail?.notes || ""}
>
{avail?.status === AvailabilityStatus.CONFIRMED_AVAILABLE ? "AVAIL" :
avail?.status === AvailabilityStatus.BLOCKED ? "BLOCK" : "UNKNOWN"}
</div>
</div>
</div>
);
})}
</div>
{/* Stats Sidebar */}
<div className="p-4 lg:w-48 bg-muted/5 border-t lg:border-t-0 lg:border-l border-border/40 flex flex-col justify-center gap-2">
<div className="flex justify-between items-center">
<span className="text-[10px] font-medium text-secondary-text uppercase tracking-wider">Utilization</span>
<span className="text-xs font-bold text-primary-text">{Math.round(staffStats?.utilizationPercentage || 0)}%</span>
</div>
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${
(staffStats?.utilizationPercentage || 0) < 50 ? 'bg-rose-500' :
(staffStats?.utilizationPercentage || 0) < 80 ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${Math.min(100, staffStats?.utilizationPercentage || 0)}%` }}
/>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] font-medium text-secondary-text uppercase tracking-wider">Score</span>
<span className="text-xs font-bold text-emerald-600">{staffStats?.acceptanceRate || 0}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Pagination Controls */}
<div className="py-4 border-t border-border/40 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4 text-sm font-medium text-secondary-text">
<span className="tracking-tight">Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)} of {filteredStaff.length} personnel</span>
<Select value={itemsPerPage.toString()} onValueChange={(val: string) => { setItemsPerPage(parseInt(val)); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px] h-8 rounded-md bg-card border-border/50 text-[11px] font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-lg border-border/50 bg-card">
<SelectItem value="25">25 / page</SelectItem>
<SelectItem value="50">50 / page</SelectItem>
<SelectItem value="100">100 / page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 px-3 rounded-md border-border/50 hover:bg-muted/5 transition-all"
>
<ChevronLeft className="w-4 h-4 mr-1" />
<span className="text-[11px] font-medium uppercase tracking-wider">Previous</span>
</Button>
<div className="flex items-center gap-1 px-2 font-medium text-xs tabular-nums text-secondary-text">
<span className="text-primary-text">{currentPage}</span>
<span className="opacity-40">/</span>
<span>{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 px-3 rounded-md border-border/50 hover:bg-muted/5 transition-all"
>
<span className="text-[11px] font-medium uppercase tracking-wider">Next</span>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -22,6 +22,7 @@ import ClientOrderList from './features/operations/orders/ClientOrderList';
import VendorOrderList from './features/operations/orders/VendorOrderList'; import VendorOrderList from './features/operations/orders/VendorOrderList';
import EditOrder from './features/operations/orders/EditOrder'; import EditOrder from './features/operations/orders/EditOrder';
import Schedule from './features/operations/schedule/Schedule'; import Schedule from './features/operations/schedule/Schedule';
import StaffAvailability from './features/operations/availability/StaffAvailability';
/** /**
* AppRoutes Component * AppRoutes Component
@@ -96,6 +97,7 @@ const AppRoutes: React.FC = () => {
<Route path="/clients/:id/edit" element={<EditClient />} /> <Route path="/clients/:id/edit" element={<EditClient />} />
<Route path="/clients/add" element={<AddClient />} /> <Route path="/clients/add" element={<AddClient />} />
<Route path="/rates" element={<ServiceRates />} /> <Route path="/rates" element={<ServiceRates />} />
{/* Operations Routes */} {/* Operations Routes */}
<Route path="/orders" element={<OrderList />} /> <Route path="/orders" element={<OrderList />} />
<Route path="/orders/client" element={<ClientOrderList />} /> <Route path="/orders/client" element={<ClientOrderList />} />
@@ -105,6 +107,7 @@ const AppRoutes: React.FC = () => {
<Route path="/schedule" element={<Schedule />} /> <Route path="/schedule" element={<Schedule />} />
<Route path='/availability' element={<StaffAvailability />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />