feat: implement staff directory list view with search and filters

This commit is contained in:
dhinesh-m24
2026-01-29 16:26:08 +05:30
parent 7133e59e57
commit 9e19ee7592
22 changed files with 2379 additions and 39 deletions

View File

@@ -3,7 +3,6 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
import { LogOut, Menu, X } from 'lucide-react';
import { Button } from '../../common/components/ui/button';
import { NAV_CONFIG } from "../../common/config/navigation";
import type { Role } from '../../common/config/navigation';
interface SidebarProps {
sidebarOpen: boolean;
@@ -38,11 +37,12 @@ const Sidebar: React.FC<SidebarProps> = ({
// Filter navigation based on user role
const filteredNav = useMemo(() => {
const userRole = (user?.role || 'Client') as Role;
const userRoleRaw = (user?.role || 'Client') as string;
const userRole = userRoleRaw.toLowerCase();
return NAV_CONFIG.map(group => {
const visibleItems = group.items.filter(item =>
item.allowedRoles.includes(userRole)
item.allowedRoles.some(r => r.toLowerCase() === userRole)
);
return {
...group,

View File

@@ -0,0 +1,47 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/common/components/ui/button";
import { ArrowLeft } from "lucide-react";
import StaffForm from "./components/StaffForm";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { workforceService } from "@/services/workforceService";
import type { Staff } from "../type";
export default function AddStaff() {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (staffData: Omit<Staff, 'id'>) => {
setIsSubmitting(true);
try {
await workforceService.entities.Staff.create(staffData);
navigate("/staff");
} catch (error) {
console.error("Failed to create staff", error);
} finally {
setIsSubmitting(false);
}
};
return (
<DashboardLayout
title="Onboard New Staff"
subtitle="Fill in the professional profile of your new team member"
backAction={
<Button
variant="ghost"
onClick={() => navigate("/staff")}
leadingIcon={<ArrowLeft />}
className="mb-6 text-muted-foreground hover:bg-primary/5 hover:text-primary rounded-xl transition-premium -ml-2"
>
Back to Directory
</Button>
}
>
<StaffForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Button } from "@/common/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
import StaffForm from "./components/StaffForm";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { workforceService } from "@/services/workforceService";
import type { Staff } from "../type";
export default function EditStaff() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [staff, setStaff] = useState<Staff | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const fetchStaff = async () => {
if (!id) return;
setIsLoading(true);
try {
const staffList = await workforceService.entities.Staff.list();
const foundStaff = staffList.find(s => s.id === id);
if (foundStaff) {
setStaff(foundStaff);
} else {
console.error("Staff member not found");
navigate("/staff");
}
} catch (error) {
console.error("Failed to fetch staff", error);
} finally {
setIsLoading(false);
}
};
fetchStaff();
}, [id, navigate]);
const handleSubmit = async (staffData: Staff) => {
if (!id) return;
setIsSubmitting(true);
try {
await workforceService.entities.Staff.update(id, staffData);
navigate("/staff");
} catch (error) {
console.error("Failed to update staff", error);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Loader2 className="w-10 h-10 animate-spin text-primary" />
<p className="text-muted-foreground font-bold text-sm uppercase tracking-widest">Loading Profile...</p>
</div>
);
}
return (
<DashboardLayout
title={`Edit: ${staff?.employee_name || 'Staff Member'}`}
subtitle={`Management of ${staff?.employee_name}'s professional records`}
backAction={
<Button
variant="ghost"
onClick={() => navigate("/staff")}
leadingIcon={<ArrowLeft />}
className="mb-6 text-muted-foreground hover:bg-primary/5 hover:text-primary rounded-xl transition-premium -ml-2"
>
Back to Directory
</Button>
}
>
{staff && (
<StaffForm
staff={staff}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,435 @@
import { useState, useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import { Button } from "../../../common/components/ui/button";
import { Card, CardContent } from "../../../common/components/ui/card";
import { Badge } from "../../../common/components/ui/badge";
import { UserPlus, Users, Star, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { motion } from "framer-motion";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { workforceService } from "@/services/workforceService";
import type { Staff, User } from "../type";
const ITEMS_PER_PAGE = 10;
export default function StaffList() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
const [ratingRange, setRatingRange] = useState<[number, number]>([0, 5]);
const [currentPage, setCurrentPage] = useState(1);
const [user, setUser] = useState<User | null>(null);
const [staff, setStaff] = useState<Staff[]>([]);
const [isLoading, setIsLoading] = useState(true);
console.log(user);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const currentUser = await workforceService.auth.me();
setUser(currentUser);
const staffList = await workforceService.entities.Staff.list('-created_date');
setStaff(staffList);
} catch (error) {
console.error("Failed to fetch data", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const allSkills = useMemo(() => {
const skillSet = new Set<string>();
staff.forEach(member => {
if (member.skills && Array.isArray(member.skills)) {
member.skills.forEach(skill => skillSet.add(skill));
}
});
return Array.from(skillSet).sort();
}, [staff]);
const filteredStaff = useMemo(() => {
return staff.filter(member => {
const matchesSearch = !searchTerm ||
member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.email?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || member.status?.toLowerCase() === statusFilter.toLowerCase();
const matchesSkills = skillsFilter.length === 0 ||
(member.skills && skillsFilter.some(skill => member.skills?.includes(skill)));
const rating = member.averageRating || 0;
const matchesRating = rating >= ratingRange[0] && rating <= ratingRange[1];
return matchesSearch && matchesStatus && matchesSkills && matchesRating;
});
}, [staff, searchTerm, statusFilter, skillsFilter, ratingRange]);
const totalPages = Math.ceil(filteredStaff.length / ITEMS_PER_PAGE);
const paginatedStaff = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredStaff.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredStaff, currentPage]);
const handleSkillToggle = (skill: string) => {
setSkillsFilter(prev =>
prev.includes(skill)
? prev.filter(s => s !== skill)
: [...prev, skill]
);
setCurrentPage(1);
};
const getStatusColor = (status?: string) => {
switch (status?.toLowerCase()) {
case 'active':
return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20';
case 'pending':
return 'bg-amber-500/10 text-amber-600 border-amber-500/20';
case 'suspended':
return 'bg-rose-500/10 text-rose-600 border-rose-500/20';
default:
return 'bg-muted/50 text-muted-foreground';
}
};
const getLastActiveText = (lastActive?: string) => {
if (!lastActive) return 'Never';
const date = new Date(lastActive);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
return (
<DashboardLayout
title="Staff Directory"
subtitle={`${filteredStaff.length} staff members`}
actions={
<Link to="/staff/add">
<Button leadingIcon={<UserPlus />}>
Add New Staff
</Button>
</Link>
}
>
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mb-8"
>
<Card className="border-border/50! glass overflow-visible">
<CardContent className="p-4">
<div className="space-y-4">
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name or email..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border/50 rounded-xl text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
</div>
{/* Filters Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Status Filter */}
<div className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase text-muted-foreground">Status</label>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 bg-background border border-border/50 rounded-xl text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
</select>
</div>
{/* Rating Range Filter */}
<div className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase text-muted-foreground">Rating Range</label>
<div className="space-y-2">
<div className="flex gap-2">
<input
type="number"
min="0"
max="5"
step="0.1"
value={ratingRange[0].toFixed(1)}
onChange={(e) => {
const val = parseFloat(e.target.value);
if (val <= ratingRange[1]) {
setRatingRange([val, ratingRange[1]]);
setCurrentPage(1);
}
}}
className="w-full px-2 py-1.5 bg-background border border-border/50 rounded-lg text-foreground text-xs focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="Min"
/>
<input
type="number"
min="0"
max="5"
step="0.1"
value={ratingRange[1].toFixed(1)}
onChange={(e) => {
const val = parseFloat(e.target.value);
if (val >= ratingRange[0]) {
setRatingRange([ratingRange[0], val]);
setCurrentPage(1);
}
}}
className="w-full px-2 py-1.5 bg-background border border-border/50 rounded-lg text-foreground text-xs focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="Max"
/>
</div>
<span className="text-xs text-muted-foreground block text-center">{ratingRange[0].toFixed(1)} - {ratingRange[1].toFixed(1)}</span>
</div>
</div>
{/* Skills Filter Label */}
<div className="flex flex-col gap-2">
<label className="text-xs font-bold uppercase text-muted-foreground">Skills</label>
<button
onClick={() => {
const container = document.getElementById('skills-dropdown');
if (container) {
container.classList.toggle('hidden');
}
}}
className="px-3 py-2 bg-background border border-border/50 rounded-xl text-foreground text-sm text-left focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
>
{skillsFilter.length === 0 ? 'All Skills' : `${skillsFilter.length} selected`}
</button>
</div>
{/* Clear Filters */}
<div className="flex items-center mt-6 md:mt-0">
<button
onClick={() => {
setSearchTerm("");
setStatusFilter("all");
setSkillsFilter([]);
setRatingRange([0, 5]);
setCurrentPage(1);
}}
className="w-full px-3 py-2 mt-2 bg-muted/50 hover:bg-muted/70 border border-border/50 rounded-xl text-foreground text-sm font-medium transition-colors"
>
Clear All
</button>
</div>
</div>
{/* Skills Dropdown */}
<div
id="skills-dropdown"
className="hidden pt-4 border-t border-border/40 grid grid-cols-2 md:grid-cols-4 gap-2"
>
{allSkills.map(skill => (
<label
key={skill}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-primary/5 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={skillsFilter.includes(skill)}
onChange={() => handleSkillToggle(skill)}
className="w-4 h-4 rounded border-border/50 text-primary focus:ring-2 focus:ring-primary/20"
/>
<span className="text-sm text-foreground">{skill}</span>
</label>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-20 bg-card border border-border/50 rounded-2xl overflow-hidden animate-pulse" />
))}
</div>
) : paginatedStaff.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="border-border glass overflow-hidden">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/40 border-b border-border">
<tr>
<th className="text-left py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Name</th>
<th className="text-left py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Photo</th>
<th className="text-center py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Status</th>
<th className="text-left py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Skills</th>
<th className="text-center py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Rating</th>
<th className="text-left py-5 px-6 font-bold text-xs uppercase tracking-wider text-secondary-text">Last Active</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{paginatedStaff.map((member) => (
<motion.tr
key={member.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="hover:bg-primary/5 transition-colors group"
>
<td className="py-4 px-6">
<Link to={`/staff/${member.id}/edit`} className="font-bold text-foreground group-hover:text-primary transition-colors hover:underline">
{member.employee_name || 'N/A'}
</Link>
</td>
<td className="py-4 px-6">
<div className="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-black text-sm border border-primary/20 group-hover:scale-110 transition-premium">
{member.photo ? (
<img src={member.photo} alt={member.employee_name} className="w-full h-full rounded-xl object-cover" />
) : (
member.employee_name?.charAt(0) || '?'
)}
</div>
</td>
<td className="py-4 px-6 text-center">
<Badge className={`${getStatusColor(member.status)} font-black text-xs border`}>
{member.status || 'Active'}
</Badge>
</td>
<td className="py-4 px-6">
<div className="flex flex-wrap gap-1">
{member.skills && member.skills.length > 0 ? (
member.skills.slice(0, 2).map((skill) => (
<Badge key={skill} variant="secondary" className="bg-muted/50 text-muted-foreground font-bold text-[10px] uppercase">
{skill}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground"></span>
)}
{member.skills && member.skills.length > 2 && (
<Badge variant="secondary" className="bg-muted/50 text-muted-foreground font-bold text-[10px] uppercase">
+{member.skills.length - 2}
</Badge>
)}
</div>
</td>
<td className="py-4 px-6 text-center">
<div className="flex items-center justify-center gap-1.5 font-bold text-sm">
<Star className="w-4 h-4 fill-amber-400 text-amber-400" />
{member.averageRating ? member.averageRating.toFixed(1) : '0.0'}
</div>
</td>
<td className="py-4 px-6 text-sm text-muted-foreground">
{getLastActiveText(member.last_active)}
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="flex items-center justify-center gap-3 mt-8"
>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
leadingIcon={<ChevronLeft className="w-4 h-4" />}
>
Previous
</Button>
<div className="flex items-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-10 h-10 rounded-lg font-bold transition-all ${
currentPage === page
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/70 text-foreground'
}`}
>
{page}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
leadingIcon={<ChevronRight className="w-4 h-4" />}
>
Next
</Button>
<span className="text-sm text-muted-foreground ml-4">
Page {currentPage} of {totalPages}
</span>
</motion.div>
)}
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-24 bg-card/60 backdrop-blur-sm rounded-3xl border border-dashed border-border"
>
<div className="w-20 h-20 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-2xl font-black text-foreground mb-3">No Staff Members Found</h3>
<p className="text-muted-foreground mb-8 max-w-sm mx-auto font-medium">
{staff.length === 0
? "Your directory is currently empty. Start by adding your first team member."
: "We couldn't find any staff members matching your current filters."}
</p>
<Link to="/staff/add">
<Button leadingIcon={<UserPlus />}>
Add First Staff Member
</Button>
</Link>
</motion.div>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,193 @@
import { Card, CardContent } from "@/common/components/ui/card";
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { motion } from "framer-motion";
import {
Mail, Phone, MapPin, Calendar, Edit,
Star, TrendingUp, XCircle, CheckCircle, UserX,
Shield, Globe, Briefcase
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Staff } from "../../type";
const getInitials = (name: string) => {
if (!name) return "?";
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
};
const renderStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating || 0);
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
stars.push(<Star key={i} className="w-4 h-4 fill-amber-400 text-amber-400" />);
} else {
stars.push(<Star key={i} className="w-4 h-4 text-muted/40" />);
}
}
return stars;
};
const getReliabilityColor = (score: number) => {
if (score >= 90) return {
bg: 'bg-emerald-500',
text: 'text-emerald-700',
bgLight: 'bg-emerald-50/50',
border: 'border-emerald-200',
icon: <Shield className="w-3 h-3" />
};
if (score >= 70) return {
bg: 'bg-amber-500',
text: 'text-amber-700',
bgLight: 'bg-amber-50/50',
border: 'border-amber-200',
icon: <TrendingUp className="w-3 h-3" />
};
return {
bg: 'bg-rose-500',
text: 'text-rose-700',
bgLight: 'bg-rose-50/50',
border: 'border-rose-200',
icon: <XCircle className="w-3 h-3" />
};
};
const formatDate = (dateStr: string) => {
try {
if (!dateStr) return "N/A";
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) {
console.error("Failed to format date", e);
return "N/A";
}
}
interface EmployeeCardProps {
staff: Staff;
}
export default function EmployeeCard({ staff }: EmployeeCardProps) {
const navigate = useNavigate();
const coveragePercentage = staff.shift_coverage_percentage || 0;
const cancellationCount = staff.cancellation_count || 0;
const noShowCount = staff.no_show_count || 0;
const rating = staff.rating || 0;
const reliabilityScore = staff.reliability_score || 0;
const reliability = getReliabilityColor(reliabilityScore);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="h-full"
>
<Card className="h-full backdrop-blur-sm border-border/50 transition-premium overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardContent className="p-6 space-y-5 relative z-10">
{/* Top Row: Avatar & Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-bold text-xl transform group-hover:scale-105 transition-premium border border-primary/20">
{staff.initial || getInitials(staff.employee_name)}
</div>
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-background border-2 border-border rounded-full flex items-center justify-center">
<div className={`w-2 h-2 rounded-full ${coveragePercentage >= 80 ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
</div>
</div>
<div>
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors">
{staff.employee_name}
</h3>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Briefcase className="w-3.5 h-3.5 text-primary" />
<span className="text-sm font-medium">{staff.position || 'Professional Staff'}</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/staff/${staff.id}/edit`)}
className="rounded-full hover:bg-primary/10 hover:text-primary transition-premium border border-transparent hover:border-primary/20"
>
<Edit className="w-4 h-4" />
</Button>
</div>
{/* Reliability & Rating */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1">
{renderStars(rating)}
<span className="text-sm font-bold text-foreground/80 ml-1">{rating.toFixed(1)}</span>
</div>
<div className={`flex items-center gap-1 px-3 py-1 rounded-full border ${reliability.border} ${reliability.bgLight} ${reliability.text} text-[10px] font-bold uppercase tracking-wider`}>
{reliability.icon}
{reliabilityScore}% Reliable
</div>
</div>
{/* Stats & Professional Details */}
<div className="space-y-1 py-1">
{[
{ label: 'Coverage', value: `${coveragePercentage}%`, icon: <TrendingUp className="w-4 h-4" />, color: 'emerald' },
{ label: 'Cancels', value: cancellationCount, icon: <XCircle className="w-4 h-4" />, color: cancellationCount === 0 ? 'emerald' : 'rose' },
{ label: 'No Shows', value: noShowCount, icon: <UserX className="w-4 h-4" />, color: noShowCount === 0 ? 'emerald' : 'rose' },
staff.profile_type && { label: 'Level', value: staff.profile_type, icon: <Shield className="w-4 h-4" />, color: 'primary' },
staff.english && { label: 'English', value: staff.english, icon: <Globe className="w-4 h-4" />, color: staff.english === 'Fluent' ? 'emerald' : 'blue' },
staff.invoiced && { label: 'Status', value: 'Verified', icon: <CheckCircle className="w-4 h-4" />, color: 'emerald' },
].filter(Boolean).map((item: any, i) => (
<div key={i} className="flex items-center justify-between group/item p-1 rounded-xl hover:bg-muted/40 transition-premium border border-transparent hover:border-border/50">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-premium group-hover/item:scale-110 border border-transparent group-hover/item:border-border/50 ${item.color === 'emerald' ? 'bg-emerald-500/10 text-emerald-600' :
item.color === 'rose' ? 'bg-rose-500/10 text-rose-600' :
item.color === 'primary' ? 'bg-primary/10 text-primary' :
'bg-blue-500/10 text-blue-600'
}`}>
{item.icon}
</div>
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">{item.label}</span>
</div>
<Badge
variant="outline"
className={`font-black text-[10px] h-6 px-2.5 border-transparent ${item.color === 'emerald' ? 'bg-emerald-500/10 text-emerald-700' :
item.color === 'rose' ? 'bg-rose-500/10 text-rose-700' :
item.color === 'primary' ? 'bg-primary/10 text-primary' :
'bg-blue-500/10 text-blue-700'
}`}
>
{item.value}
</Badge>
</div>
))}
</div>
{/* Contact Details with Glass effect */}
<div className="space-y-1 pt-4 border-t border-border/40 relative">
{[
{ icon: <Mail className="w-4 h-4" />, text: staff.email, visible: !!staff.email },
{ icon: <Phone className="w-4 h-4" />, text: staff.contact_number, visible: !!staff.contact_number },
{ icon: <MapPin className="w-4 h-4" />, text: staff.hub_location, visible: !!staff.hub_location },
{ icon: <Calendar className="w-4 h-4" />, text: `Updated ${formatDate(staff.check_in || '')}`, visible: !!staff.check_in },
].filter(d => d.visible).map((detail, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground group/detail cursor-pointer hover:text-foreground transition-colors text-ellipsis overflow-hidden">
<div className="w-8 h-8 shrink-0 rounded-lg bg-secondary/50 flex items-center justify-center group-hover/detail:bg-primary/10 group-hover/detail:text-primary transition-premium text-ellipsis">
{detail.icon}
</div>
<span className="truncate flex-1 font-medium">{detail.text}</span>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,75 @@
import { Input } from "../../../../common/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../common/components/ui/select";
import { Search, MapPin, Briefcase } from "lucide-react";
interface FilterBarProps {
searchTerm: string;
setSearchTerm: (value: string) => void;
departmentFilter: string;
setDepartmentFilter: (value: string) => void;
locationFilter: string;
setLocationFilter: (value: string) => void;
departments?: string[];
locations: string[];
}
export default function FilterBar({
searchTerm,
setSearchTerm,
departmentFilter,
setDepartmentFilter,
locationFilter,
setLocationFilter,
locations
}: FilterBarProps) {
return (
<div className="flex flex-col shadow-none lg:flex-row items-center gap-4 w-full">
<Input
placeholder="Search staff by name, role, or manager..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leadingIcon={<Search />}
className="flex-1"
/>
<div className="flex items-center gap-3 w-full lg:w-auto">
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<SelectTrigger
className="w-full lg:w-48 font-bold text-xs uppercase tracking-wider"
leadingIcon={<Briefcase />}
>
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent className="rounded-2xl border-border glass">
<SelectItem value="all" className="font-bold text-xs uppercase cursor-pointer">All Departments</SelectItem>
<SelectItem value="Operations" className="font-bold text-xs uppercase cursor-pointer">Operations</SelectItem>
<SelectItem value="Sales" className="font-bold text-xs uppercase cursor-pointer">Sales</SelectItem>
<SelectItem value="HR" className="font-bold text-xs uppercase cursor-pointer">HR</SelectItem>
<SelectItem value="Finance" className="font-bold text-xs uppercase cursor-pointer">Finance</SelectItem>
<SelectItem value="IT" className="font-bold text-xs uppercase cursor-pointer">IT</SelectItem>
<SelectItem value="Marketing" className="font-bold text-xs uppercase cursor-pointer">Marketing</SelectItem>
<SelectItem value="Customer Service" className="font-bold text-xs uppercase cursor-pointer">Customer Service</SelectItem>
<SelectItem value="Logistics" className="font-bold text-xs uppercase cursor-pointer">Logistics</SelectItem>
</SelectContent>
</Select>
<Select value={locationFilter} onValueChange={setLocationFilter}>
<SelectTrigger
className="w-full lg:w-48 font-bold text-xs uppercase tracking-wider"
leadingIcon={<MapPin />}
>
<SelectValue placeholder="Location" />
</SelectTrigger>
<SelectContent className="rounded-2xl border-border glass">
<SelectItem value="all" className="font-bold text-xs uppercase cursor-pointer">All Locations</SelectItem>
{locations.map(location => (
<SelectItem key={location} value={location} className="font-bold text-xs uppercase cursor-pointer">
{location}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,516 @@
import React, { useState, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import { Textarea } from "@/common/components/ui/textarea";
import { Button } from "@/common/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select";
import { Checkbox } from "@/common/components/ui/checkbox";
import {
Save, Loader2, User, Activity, MapPin,
Calendar, ChevronRight, FileText,
Shield, Star, Info
} from "lucide-react";
import { AnimatePresence } from "framer-motion";
import type { Staff } from "../../type";
import { TabContent } from "./TabContent";
interface StaffFormProps {
staff?: Staff;
onSubmit: (data: Omit<Staff, 'id'>) => Promise<void>;
isSubmitting: boolean;
}
type TabType = "general" | "performance" | "location" | "additional";
export default function StaffForm({ staff, onSubmit, isSubmitting }: StaffFormProps) {
const [activeTab, setActiveTab] = useState<TabType>("general");
const { register, handleSubmit, control, reset } = useForm<Staff>({
defaultValues: staff || {
employee_name: "",
manager: "",
contact_number: "",
phone: "",
email: "",
department: "",
hub_location: "",
event_location: "",
track: "",
address: "",
city: "",
position: "",
position_2: "",
initial: "",
profile_type: "",
employment_type: "",
english: "",
english_required: false,
check_in: "",
replaced_by: "",
ro: "",
mon: "",
schedule_days: "",
invoiced: false,
action: "",
notes: "",
accounting_comments: "",
rating: 0,
shift_coverage_percentage: 100,
cancellation_count: 0,
no_show_count: 0,
total_shifts: 0,
reliability_score: 100
}
});
useEffect(() => {
if (staff) {
reset(staff);
}
}, [staff, reset]);
const onFormSubmit = (data: Staff) => {
onSubmit(data);
};
const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [
{ id: "general", label: "General Info", icon: <User className="w-4 h-4" /> },
{ id: "performance", label: "Performance", icon: <Activity className="w-4 h-4" /> },
{ id: "location", label: "Location", icon: <MapPin className="w-4 h-4" /> },
{ id: "additional", label: "Other", icon: <Info className="w-4 h-4" /> },
];
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-8 animate-in-slide-up">
<div className="flex flex-col lg:flex-row gap-8">
{/* Navigation Sidebar */}
<div className="lg:w-64 shrink-0">
<div className="sticky top-24 space-y-1 bg-card/60 backdrop-blur-md border border-border p-2 rounded-2xl">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-premium ${activeTab === tab.id
? "bg-primary text-primary-foreground border-primary scale-[1.02]"
: "text-muted-foreground hover:bg-secondary/80 hover:text-foreground border-transparent border"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="mt-8 p-4 bg-primary/5 rounded-2xl border border-primary/20">
<h4 className="text-xs font-black text-primary tracking-wider mb-2 flex items-center gap-2">
<Shield className="w-3.5 h-3.5" />
Quick Save
</h4>
<p className="text-[11px] text-muted-foreground font-medium mb-4">
All changes are secured. Press save to finalize.
</p>
<Button
type="submit"
disabled={isSubmitting}
className="text-sm default w-full"
leadingIcon={isSubmitting ? <Loader2 className="animate-spin" /> : <Save />}
>
{isSubmitting ? "Processing..." : "Save Staff Member"}
</Button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 space-y-6">
<AnimatePresence mode="wait">
{activeTab === "general" && (
<TabContent
id="general"
title="Basic Information"
icon={<User className="w-5 h-5" />}
className="p-8 grid grid-cols-1 md:grid-cols-2 gap-8"
footer={
<div className="bg-primary/5 p-6 rounded-3xl border border-primary/10 flex items-center justify-between">
<div>
<h4 className="font-bold text-foreground">Next Step: Performance</h4>
<p className="text-sm text-muted-foreground">Define reliability and coverage metrics.</p>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setActiveTab("performance")}
className="hover:bg-primary/10 hover:text-primary rounded-xl font-bold transition-premium"
>
Continue
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
}
>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Name <span className="text-rose-500">*</span></Label>
<Input
{...register("employee_name", { required: true })}
className="font-medium"
placeholder="John Doe"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Initials</Label>
<Input
{...register("initial")}
maxLength={3}
className="font-medium "
placeholder="JD"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Primary Skill</Label>
<Controller
name="position"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="font-medium">
<SelectValue placeholder="Select primary skill" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Barista">Barista</SelectItem>
<SelectItem value="Server">Server</SelectItem>
<SelectItem value="Cook">Cook</SelectItem>
<SelectItem value="Dishwasher">Dishwasher</SelectItem>
<SelectItem value="Bartender">Bartender</SelectItem>
<SelectItem value="Manager">Manager</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Skill Level</Label>
<Controller
name="profile_type"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="font-medium">
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Skilled">Skilled</SelectItem>
<SelectItem value="Beginner">Beginner</SelectItem>
<SelectItem value="Cross-Trained">Cross-Trained</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Email</Label>
<Input
{...register("email")}
type="email"
leadingIcon={<FileText />}
className="font-medium"
placeholder="j.doe@example.com"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Contact Number</Label>
<Input
{...register("contact_number")}
className="font-medium"
placeholder="+1 (555) 000-0000"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Employment Type</Label>
<Controller
name="employment_type"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="font-medium">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Full Time">Full Time</SelectItem>
<SelectItem value="Part Time">Part Time</SelectItem>
<SelectItem value="On call">On call</SelectItem>
<SelectItem value="Seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Reporting Manager</Label>
<Controller
name="manager"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="font-medium">
<SelectValue placeholder="Select manager" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Fernando">Fernando</SelectItem>
<SelectItem value="Maria">Maria</SelectItem>
<SelectItem value="Paola">Paola</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</TabContent>
)}
{activeTab === "performance" && (
<TabContent
id="performance"
title="Performance Metrics"
icon={<Activity className="w-5 h-5" />}
className="p-8 space-y-8"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-amber-500" />
<Label className="text-xs font-black text-muted-foreground/80">Rating (0-5)</Label>
</div>
<Input
{...register("rating", { valueAsNumber: true })}
type="number"
step="0.1"
min="0"
max="5"
className="h-14 rounded-2xl text-2xl font-black text-center"
/>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
<Label className="text-xs font-black text-muted-foreground/80">Reliability %</Label>
</div>
<Input
{...register("reliability_score", { valueAsNumber: true })}
type="number"
min="0"
max="100"
className="h-14 rounded-2xl text-2xl font-black text-center"
/>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
<Label className="text-xs font-black text-muted-foreground/80">Coverage %</Label>
</div>
<Input
{...register("shift_coverage_percentage", { valueAsNumber: true })}
type="number"
min="0"
max="100"
className="h-14 rounded-2xl text-2xl font-black text-center"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Cancellations</Label>
<Input
{...register("cancellation_count", { valueAsNumber: true })}
type="number"
className="font-medium"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">No Shows</Label>
<Input
{...register("no_show_count", { valueAsNumber: true })}
type="number"
className="font-medium"
/>
</div>
</div>
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40 flex items-center justify-between">
<div className="flex items-center gap-4">
<Controller
name="invoiced"
control={control}
render={({ field }) => (
<Checkbox
id="invoiced"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="w-6 h-6 rounded-lg border-2 border-primary/20 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
)}
/>
<div>
<Label htmlFor="invoiced" className="font-black text-foreground cursor-pointer">Verified Invoicing</Label>
<p className="text-xs text-muted-foreground font-medium">Mark this member as verified for automatic invoicing.</p>
</div>
</div>
</div>
</TabContent>
)}
{activeTab === "location" && (
<TabContent
id="location"
title="Deployment & Location"
icon={<MapPin className="w-5 h-5" />}
className="p-8 grid grid-cols-1 md:grid-cols-2 gap-8"
>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Department</Label>
<Controller
name="department"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select department" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Sales">Sales</SelectItem>
<SelectItem value="Logistics">Logistics</SelectItem>
<SelectItem value="HR">HR</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Primary City</Label>
<Input
{...register("city")}
className="font-medium"
placeholder="e.g., San Francisco"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Hub Site</Label>
<Input
{...register("hub_location")}
className="font-medium"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label className="text-xs font-black text-muted-foreground/80">Detailed Address</Label>
<Textarea
{...register("address")}
className="font-medium"
placeholder="Street address, building, etc."
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">English Fluency</Label>
<Controller
name="english"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent className="rounded-xl glass">
<SelectItem value="Fluent">Fluent</SelectItem>
<SelectItem value="Intermediate">Intermediate</SelectItem>
<SelectItem value="Basic">Basic</SelectItem>
<SelectItem value="None">None</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Controller
name="english_required"
control={control}
render={({ field }) => (
<Checkbox
id="english_required"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<Label htmlFor="english_required" className="text-sm font-bold text-foreground cursor-pointer">Required for role</Label>
</div>
</TabContent>
)}
{activeTab === "additional" && (
<TabContent
id="additional"
title="Notes & Comments"
icon={<Info className="w-5 h-5" />}
className="p-8 space-y-6"
>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Staff Notes</Label>
<Textarea
{...register("notes")}
placeholder="Internal notes about performance, behavior, etc."
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Accounting Comments</Label>
<Textarea
{...register("accounting_comments")}
placeholder="Pay rates, bonus structures, or audit notes."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Last Check-in</Label>
<Input
{...register("check_in")}
type="date"
leadingIcon={<Calendar />}
className="font-medium"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Schedule Override</Label>
<Input
{...register("schedule_days")}
className="font-medium"
placeholder="e.g., M/W/F Only"
/>
</div>
</div>
</TabContent>
)}
</AnimatePresence>
</div>
</div>
</form>
);
}
const TrendingUp = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline><polyline points="16 7 22 7 22 13"></polyline></svg>
);

View File

@@ -0,0 +1,45 @@
import React from "react";
import { motion } from "framer-motion";
import { Card, CardHeader, CardTitle, CardContent } from "@/common/components/ui/card";
interface TabContentProps {
id: string;
title: string;
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
}
export const TabContent = ({
id,
title,
icon,
children,
className,
footer
}: TabContentProps) => (
<motion.div
key={id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="space-y-6"
>
<Card className="glass border-border/50! overflow-hidden rounded-3xl">
<CardHeader className="bg-secondary/30 border-b border-border/40 p-6">
<CardTitle className="text-xl font-bold! flex items-center gap-4">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{icon}
</div>
{title}
</CardTitle>
</CardHeader>
<CardContent className={className}>
{children}
</CardContent>
</Card>
{footer}
</motion.div>
);

View File

@@ -0,0 +1,71 @@
export interface Staff {
id?: string;
vendor_id?: string;
vendor_name?: string;
created_by?: string;
created_date?: string;
// Basic Info
employee_name: string;
initial?: string;
position?: string; // Primary Skill
position_2?: string; // Secondary Skill
profile_type?: string; // Skill Level
employment_type?: string;
manager?: string;
email?: string;
contact_number?: string;
phone?: string; // Additional Phone
photo?: string; // Photo URL
status?: string; // 'Active' | 'Pending' | 'Suspended'
skills?: string[]; // Array of skills
// Performance
averageRating?: number;
reliability_score?: number;
shift_coverage_percentage?: number;
cancellation_count?: number;
no_show_count?: number;
total_shifts?: number;
invoiced?: boolean;
last_active?: string; // ISO timestamp of last activity
// Location & Dept
department?: string;
city?: string;
hub_location?: string;
event_location?: string;
track?: string;
address?: string;
// Lang & Schedule
english?: string;
english_required?: boolean;
check_in?: string;
schedule_days?: string;
// Other
replaced_by?: string;
action?: string;
ro?: string;
mon?: string;
notes?: string;
accounting_comments?: string;
}
export interface User {
id: string;
email: string;
role: string; // 'admin' | 'client' | 'vendor' | 'workforce' | 'operator' | 'procurement' | 'sector'
user_role?: string; // MVP uses both sometimes
company_name?: string;
name: string;
}
export interface Event {
id: string;
client_email?: string;
business_name?: string;
created_by?: string;
assigned_staff?: { staff_id: string }[];
}