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

@@ -2,9 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>Krow-web</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -1,37 +1,80 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
export interface ButtonProps import { cn } from "@/lib/utils"
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const buttonVariants = cva(
({ className, variant = "default", size = "default", ...props }, ref) => { "inline-flex items-center justify-center transition-premium gap-2 whitespace-nowrap rounded-xl text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]",
const baseStyles = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" {
variants: {
const variants = { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", destructive:
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-destructive text-destructive-foreground hover:opacity-90",
outline:
"border border-primary text-primary bg-transparent hover:bg-primary/5 hover:text-primary/90 hover:border-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
} },
size: {
const sizes = { default: "h-10 px-5 py-6",
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs", sm: "h-9 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8", lg: "h-11 rounded-lg px-8 text-base",
icon: "h-10 w-10", icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
/**
* Button component based on Shadcn UI.
* Supports variants (default, destructive, outline, secondary, ghost, link)
* and sizes (default, sm, lg, icon).
* Now supports leadingIcon and trailingIcon props.
*/
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, leadingIcon, trailingIcon, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
// If asChild is true, we just render children as per standard Slot behavior
if (asChild) {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{children}
</Comp>
)
} }
return ( return (
<button <Comp
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className || ''}`} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> >
{leadingIcon && <span className="w-4 h-4 shrink-0">{leadingIcon}</span>}
{children}
{trailingIcon && <span className="w-4 h-4 shrink-0">{trailingIcon}</span>}
</Comp>
) )
} }
) )

View File

@@ -0,0 +1,80 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
/**
* Card component family based on Shadcn UI.
* Used for grouping content in a contained area.
*/
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-2xl border border-slate-200 bg-white transition-all duration-300",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-8", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-xl font-bold leading-none tracking-tight text-slate-900", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500/90 leading-relaxed", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-8 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-8 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => {
return (
<input
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
ref={ref}
{...props}
/>
)
}
)
Checkbox.displayName = "Checkbox"
export { Checkbox }

View File

@@ -1,17 +1,48 @@
import * as React from "react" import * as React from "react"
export interface InputProps import { cn } from "@/lib/utils"
extends React.InputHTMLAttributes<HTMLInputElement> {}
/**
* Input component based on Shadcn UI.
* A basic input field with consistent styling.
*/
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => ( ({ className, type, leadingIcon, trailingIcon, ...props }, ref) => {
return (
<div className="relative w-full group">
{leadingIcon && (
<div className="absolute left-4 top-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none text-muted-foreground/60 group-focus-within:text-primary transition-colors">
<div className="w-4 h-4 flex items-center justify-center">
{leadingIcon}
</div>
</div>
)}
<input <input
type={type} type={type}
className={`flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm transition-colors ${className || ''}`} className={cn(
"flex h-12 w-full rounded-xl border border-input bg-white/70 px-4 py-3 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:border-primary focus-visible:bg-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50",
leadingIcon && "pl-11",
trailingIcon && "pr-11",
className
)}
ref={ref} ref={ref}
{...props} {...props}
/> />
{trailingIcon && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none text-muted-foreground/60 group-focus-within:text-primary transition-colors">
<div className="w-4 h-4 flex items-center justify-center">
{trailingIcon}
</div>
</div>
)}
</div>
) )
}
) )
Input.displayName = "Input" Input.displayName = "Input"

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
import { ChevronDown } from "lucide-react"
const SelectContext = React.createContext<any>(null);
export const Select = ({ value, onValueChange, children }: any) => {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
<div className="relative" ref={containerRef}>{children}</div>
</SelectContext.Provider>
)
}
export const SelectTrigger = ({ className, children, leadingIcon, trailingIcon }: any) => {
const { open, setOpen } = React.useContext(SelectContext);
return (
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"flex h-12 w-full capitalize items-center gap-3 rounded-xl border border-input bg-background/50 px-4 py-3 text-sm transition-all duration-300 focus:outline-none focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-50 group",
className
)}
>
{leadingIcon && (
<div className="w-4 h-4 flex items-center justify-center text-muted-foreground/60 group-focus:text-primary transition-colors shrink-0">
{leadingIcon}
</div>
)}
<div className="flex-1 text-left overflow-hidden whitespace-nowrap">
{children}
</div>
<div className="flex items-center gap-2 shrink-0">
{trailingIcon && (
<div className="w-4 h-4 flex items-center justify-center text-muted-foreground/60 group-focus:text-primary transition-colors">
{trailingIcon}
</div>
)}
<ChevronDown className={cn("h-4 w-4 opacity-50 transition-transform duration-200", open && "rotate-180")} />
</div>
</button>
)
}
export const SelectValue = ({ placeholder }: any) => {
const { value } = React.useContext(SelectContext);
return <span className="block truncate">{value || placeholder}</span>
}
export const SelectContent = ({ children, className }: any) => {
const { open } = React.useContext(SelectContext);
if (!open) return null;
return (
<div className={cn("absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white text-popover-foreground animate-in fade-in-80 mt-1 w-full max-h-60 overflow-y-auto", className)}>
<div className="p-1">{children}</div>
</div>
)
}
export const SelectItem = ({ value, children, className }: any) => {
const { onValueChange, setOpen } = React.useContext(SelectContext);
return (
<div
onClick={() => { onValueChange(value); setOpen(false); }}
className={cn("relative capitalize flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-2 text-sm outline-none hover:bg-slate-100 cursor-pointer", className)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[120px] w-full rounded-xl border border-input bg-background/50 px-4 py-3 text-sm placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:border-primary focus-visible:bg-background transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

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

View File

@@ -9,6 +9,9 @@ import VendorDashboard from './features/dashboard/VendorDashboard';
import ProtectedRoute from './features/layouts/ProtectedRoute'; import ProtectedRoute from './features/layouts/ProtectedRoute';
import { RoleDashboardRedirect } from './features/dashboard/RoleDashboardRedirect'; import { RoleDashboardRedirect } from './features/dashboard/RoleDashboardRedirect';
import PublicLayout from './features/layouts/PublicLayout'; import PublicLayout from './features/layouts/PublicLayout';
import StaffList from './features/workforce/directory/StaffList';
import EditStaff from './features/workforce/directory/EditStaff';
import AddStaff from './features/workforce/directory/AddStaff';
/** /**
* AppRoutes Component * AppRoutes Component
@@ -74,6 +77,10 @@ const AppRoutes: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
{/* Workforce Routes */}
<Route path="/staff" element={<StaffList />} />
<Route path="/staff/add" element={<AddStaff />} />
<Route path="/staff/:id/edit" element={<EditStaff />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>

View File

@@ -0,0 +1,437 @@
import type { Staff, User, Event } from "../features/workforce/type";
/**
* Mock Workforce Service
* Provides placeholder data for UI development
* Replace with actual API calls when backend is ready
*/
const mockUsers: Record<string, User> = {
admin: {
id: "admin-001",
email: "admin@krow.com",
role: "admin",
user_role: "admin",
name: "Admin User",
company_name: "Krow Workforce",
},
vendor: {
id: "vendor-001",
email: "vendor@staffagency.com",
role: "vendor",
user_role: "vendor",
name: "Vendor User",
company_name: "Staff Agency Pro",
},
client: {
id: "client-001",
email: "client@eventco.com",
role: "client",
user_role: "client",
name: "Client User",
company_name: "Event Co.",
},
operator: {
id: "operator-001",
email: "operator@krow.com",
role: "operator",
user_role: "operator",
name: "Operator User",
company_name: "Krow Workforce",
},
};
const mockStaff = [
{
id: "staff-001",
employee_name: "Sarah Johnson",
position: "Event Coordinator",
photo: "https://i.pravatar.cc/150?u=staff-001",
photo_url: "https://i.pravatar.cc/150?u=staff-001",
profile_type: "Senior",
email: "sarah.johnson@example.com",
contact_number: "+1 (555) 123-4567",
status: "Active",
skills: ["Event Management", "Customer Service", "Coordination"],
department: "Events",
hub_location: "New York",
averageRating: 4.8,
reliability_score: 95,
shift_coverage_percentage: 98,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 1).toISOString(),
employment_type: "Contract",
manager: "John Smith",
cancellation_count: 0,
no_show_count: 0,
total_shifts: 145,
},
{
id: "staff-002",
employee_name: "Michael Chen",
position: "Logistics Manager",
photo: "https://i.pravatar.cc/150?u=staff-002",
photo_url: "https://i.pravatar.cc/150?u=staff-002",
profile_type: "Intermediate",
email: "michael.chen@example.com",
contact_number: "+1 (555) 234-5678",
status: "Active",
skills: ["Logistics", "Operations", "Planning"],
department: "Operations",
hub_location: "Los Angeles",
averageRating: 4.6,
reliability_score: 88,
shift_coverage_percentage: 85,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
employment_type: "Full-time",
manager: "Jane Williams",
cancellation_count: 2,
no_show_count: 1,
total_shifts: 156,
},
{
id: "staff-003",
employee_name: "Emma Rodriguez",
position: "Customer Service Rep",
photo: "https://i.pravatar.cc/150?u=staff-003",
photo_url: "https://i.pravatar.cc/150?u=staff-003",
profile_type: "Junior",
email: "emma.rodriguez@example.com",
contact_number: "+1 (555) 345-6789",
status: "Pending",
skills: ["Customer Service", "Communication"],
department: "Support",
hub_location: "Chicago",
averageRating: 4.3,
reliability_score: 72,
shift_coverage_percentage: 65,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10).toISOString(),
employment_type: "Part-time",
manager: "Robert Brown",
cancellation_count: 5,
no_show_count: 3,
total_shifts: 89,
},
{
id: "staff-004",
employee_name: "James Wilson",
position: "Security Officer",
photo: "https://i.pravatar.cc/150?u=staff-004",
photo_url: "https://i.pravatar.cc/150?u=staff-004",
profile_type: "Senior",
email: "james.wilson@example.com",
contact_number: "+1 (555) 456-7890",
status: "Active",
skills: ["Security", "Safety"],
department: "Security",
hub_location: "Miami",
averageRating: 4.9,
reliability_score: 99,
shift_coverage_percentage: 100,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 2).toISOString(),
employment_type: "Full-time",
manager: "Patricia Davis",
cancellation_count: 0,
no_show_count: 0,
total_shifts: 198,
},
{
id: "staff-005",
employee_name: "Lisa Anderson",
position: "HR Specialist",
photo: "https://i.pravatar.cc/150?u=staff-005",
photo_url: "https://i.pravatar.cc/150?u=staff-005",
profile_type: "Intermediate",
email: "lisa.anderson@example.com",
contact_number: "+1 (555) 567-8901",
status: "Suspended",
skills: ["HR", "Recruitment"],
department: "Human Resources",
hub_location: "New York",
averageRating: 4.5,
reliability_score: 91,
shift_coverage_percentage: 92,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
employment_type: "Full-time",
manager: "John Smith",
cancellation_count: 1,
no_show_count: 0,
total_shifts: 167,
},
{
id: "staff-006",
employee_name: "David Martinez",
position: "Data Analyst",
photo: "https://i.pravatar.cc/150?u=staff-006",
photo_url: "https://i.pravatar.cc/150?u=staff-006",
profile_type: "Senior",
email: "david.martinez@example.com",
contact_number: "+1 (555) 678-9012",
status: "Active",
skills: ["Data Analysis", "Reporting", "SQL"],
department: "Analytics",
hub_location: "San Francisco",
averageRating: 4.7,
reliability_score: 93,
shift_coverage_percentage: 87,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
employment_type: "Contract",
manager: "Michael Thompson",
cancellation_count: 1,
no_show_count: 1,
total_shifts: 134,
},
{
id: "staff-007",
employee_name: "Jessica Lee",
position: "Project Manager",
photo: "https://i.pravatar.cc/150?u=staff-007",
photo_url: "https://i.pravatar.cc/150?u=staff-007",
profile_type: "Senior",
email: "jessica.lee@example.com",
contact_number: "+1 (555) 789-0123",
status: "Active",
skills: ["Project Management", "Agile"],
department: "Projects",
hub_location: "Boston",
averageRating: 4.4,
reliability_score: 85,
shift_coverage_percentage: 79,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(),
employment_type: "Full-time",
manager: "Sarah Johnson",
cancellation_count: 3,
no_show_count: 1,
total_shifts: 142,
},
{
id: "staff-008",
employee_name: "Kevin Thompson",
position: "Business Analyst",
photo: "https://i.pravatar.cc/150?u=staff-008",
photo_url: "https://i.pravatar.cc/150?u=staff-008",
profile_type: "Intermediate",
email: "kevin.thompson@example.com",
contact_number: "+1 (555) 890-1234",
status: "Pending",
skills: ["Business Analysis", "Strategy"],
department: "Strategy",
hub_location: "Austin",
averageRating: 4.2,
reliability_score: 68,
shift_coverage_percentage: 72,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(),
employment_type: "Part-time",
manager: "Robert Brown",
cancellation_count: 6,
no_show_count: 2,
total_shifts: 95,
},
{
id: "staff-009",
employee_name: "Nicole White",
position: "Marketing Manager",
photo: "https://i.pravatar.cc/150?u=staff-009",
photo_url: "https://i.pravatar.cc/150?u=staff-009",
profile_type: "Senior",
email: "nicole.white@example.com",
contact_number: "+1 (555) 901-2345",
status: "Active",
skills: ["Marketing", "Branding"],
department: "Marketing",
hub_location: "Seattle",
averageRating: 4.6,
reliability_score: 89,
shift_coverage_percentage: 86,
vendor_id: "vendor-001",
vendor_name: "Staff Agency Pro",
created_by: "vendor@staffagency.com",
created_date: new Date().toISOString(),
last_active: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(),
employment_type: "Full-time",
manager: "Patricia Davis",
cancellation_count: 2,
no_show_count: 0,
total_shifts: 178,
},
] as unknown as Staff[];
const mockEvents: Event[] = [
{
id: "event-001",
business_name: "Event Co.",
client_email: "client@eventco.com",
created_by: "client@eventco.com",
assigned_staff: [
{ staff_id: "staff-001" },
{ staff_id: "staff-004" },
],
},
{
id: "event-002",
business_name: "Event Co.",
client_email: "client@eventco.com",
created_by: "client@eventco.com",
assigned_staff: [
{ staff_id: "staff-002" },
{ staff_id: "staff-009" },
],
},
];
/**
* Simulates API delay for realistic behavior
*/
const delay = (ms: number = 500) =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Workforce Service - Mock implementation
*/
export const workforceService = {
auth: {
/**
* Get current user (mocked)
* In production, this would verify Firebase auth session
*/
me: async (): Promise<User> => {
await delay(800);
// Return a random user for demonstration
const users = Object.values(mockUsers);
return users[Math.floor(Math.random() * users.length)];
},
/**
* Sign out user (mocked)
*/
logout: async (): Promise<void> => {
await delay(300);
console.log("User logged out (mock)");
},
},
entities: {
Staff: {
/**
* List all staff members
* @param sortBy - Sort field (e.g., '-created_date' for descending)
*/
list: async (sortBy?: string): Promise<Staff[]> => {
await delay(1200);
const staffList = [...mockStaff];
// Simple sorting logic
if (sortBy === "-created_date") {
staffList.sort(
(a, b) =>
new Date(b.created_date || 0).getTime() -
new Date(a.created_date || 0).getTime()
);
} else if (sortBy === "created_date") {
staffList.sort(
(a, b) =>
new Date(a.created_date || 0).getTime() -
new Date(b.created_date || 0).getTime()
);
}
return staffList;
},
/**
* Get single staff member by ID
*/
get: async (id: string): Promise<Staff | null> => {
await delay(600);
return mockStaff.find((s) => s.id === id) || null;
},
/**
* Create new staff member
*/
create: async (staff: Partial<Staff>): Promise<Staff> => {
await delay(1000);
const newStaff: Staff = {
...staff,
id: `staff-${Date.now()}`,
created_date: new Date().toISOString(),
} as Staff;
mockStaff.push(newStaff);
return newStaff;
},
/**
* Update staff member
*/
update: async (id: string, updates: Partial<Staff>): Promise<Staff> => {
await delay(800);
const index = mockStaff.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Staff not found");
mockStaff[index] = { ...mockStaff[index], ...updates };
return mockStaff[index];
},
/**
* Delete staff member
*/
delete: async (id: string): Promise<void> => {
await delay(600);
const index = mockStaff.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Staff not found");
mockStaff.splice(index, 1);
},
},
Event: {
/**
* List all events
*/
list: async (): Promise<Event[]> => {
await delay(1000);
return [...mockEvents];
},
/**
* Get single event by ID
*/
get: async (id: string): Promise<Event | null> => {
await delay(600);
return mockEvents.find((e) => e.id === id) || null;
},
},
},
};
export default workforceService;

101
apps/web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { Config } from 'tailwindcss';
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
fontFamily: {
body: ['Instrument Sans', 'sans-serif'],
headline: ['Instrument Sans', 'sans-serif'],
code: ['monospace'],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
'primary-text': 'hsl(var(--text-primary))',
'secondary-text': 'hsl(var(--text-secondary))',
'muted-text': 'hsl(var(--text-muted))',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;

View File

@@ -16,6 +16,12 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,

View File

@@ -1,8 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}) })