feat: implement staff directory list view with search and filters
This commit is contained in:
@@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>web</title>
|
||||
<title>Krow-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
35
apps/web/src/common/components/ui/badge.tsx
Normal file
35
apps/web/src/common/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -1,40 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"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]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:opacity-90",
|
||||
destructive:
|
||||
"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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-6",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-lg px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
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 = "default", size = "default", ...props }, ref) => {
|
||||
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"
|
||||
|
||||
const variants = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
}
|
||||
({ className, variant, size, asChild = false, leadingIcon, trailingIcon, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
const sizes = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
// 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 (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className || ''}`}
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
export { Button }
|
||||
80
apps/web/src/common/components/ui/card.tsx
Normal file
80
apps/web/src/common/components/ui/card.tsx
Normal 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 }
|
||||
21
apps/web/src/common/components/ui/checkbox.tsx
Normal file
21
apps/web/src/common/components/ui/checkbox.tsx
Normal 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 }
|
||||
@@ -1,18 +1,49 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 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>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
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 || ''}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
({ 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
|
||||
type={type}
|
||||
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}
|
||||
{...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"
|
||||
|
||||
export { Input }
|
||||
export { Input }
|
||||
86
apps/web/src/common/components/ui/select.tsx
Normal file
86
apps/web/src/common/components/ui/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/web/src/common/components/ui/textarea.tsx
Normal file
20
apps/web/src/common/components/ui/textarea.tsx
Normal 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 }
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import VendorDashboard from './features/dashboard/VendorDashboard';
|
||||
import ProtectedRoute from './features/layouts/ProtectedRoute';
|
||||
import { RoleDashboardRedirect } from './features/dashboard/RoleDashboardRedirect';
|
||||
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
|
||||
@@ -74,6 +77,10 @@ const AppRoutes: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Workforce Routes */}
|
||||
<Route path="/staff" element={<StaffList />} />
|
||||
<Route path="/staff/add" element={<AddStaff />} />
|
||||
<Route path="/staff/:id/edit" element={<EditStaff />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
437
apps/web/src/services/workforceService.ts
Normal file
437
apps/web/src/services/workforceService.ts
Normal 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
101
apps/web/tailwind.config.ts
Normal 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;
|
||||
@@ -16,6 +16,12 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user