feat: Implement Staff Availability View for Administrators
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user