feat: implement staff directory list view with search and filters
This commit is contained in:
@@ -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,
|
||||
|
||||
47
apps/web/src/features/workforce/directory/AddStaff.tsx
Normal file
47
apps/web/src/features/workforce/directory/AddStaff.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/features/workforce/directory/EditStaff.tsx
Normal file
85
apps/web/src/features/workforce/directory/EditStaff.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
435
apps/web/src/features/workforce/directory/StaffList.tsx
Normal file
435
apps/web/src/features/workforce/directory/StaffList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
71
apps/web/src/features/workforce/type.ts
Normal file
71
apps/web/src/features/workforce/type.ts
Normal 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 }[];
|
||||
}
|
||||
Reference in New Issue
Block a user