Merge pull request #338 from Oloodi/authentication-web

Authentication web
This commit is contained in:
Achintha Isuru
2026-02-04 23:28:39 -05:00
committed by GitHub
59 changed files with 10812 additions and 21 deletions

View File

@@ -21,12 +21,21 @@ help:
@echo " 🚀 KROW Workforce - Available Makefile Commands"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " 📦 WEB FRONTEND (internal/api-harness)"
@echo " 📦 WEB FRONTEND (apps/web)"
@echo " ────────────────────────────────────────────────────────────────────"
@echo " make install Install web frontend dependencies"
@echo " make dev Start local web frontend dev server"
@echo " make build Build web frontend for production"
@echo " make deploy-app [ENV=dev] Build and deploy web app (dev/staging/prod)"
@echo " make web-install Install web frontend dependencies"
@echo " make web-info List web development commands"
@echo " make web-dev Start local web frontend dev server"
@echo " make web-build [ENV=dev] Build web frontend for production (dev/staging)"
@echo " make web-lint Run linter for web frontend"
@echo " make web-preview Preview web frontend build"
@echo " make web-deploy [ENV=dev] Build and deploy web app (dev/staging)"
@echo ""
@echo " Aliases:"
@echo " make install → web-install"
@echo " make dev → web-dev"
@echo " make build → web-build"
@echo " make deploy-app → web-deploy"
@echo ""
@echo " 🏠 LAUNCHPAD (internal/launchpad)"
@echo " ────────────────────────────────────────────────────────────────────"

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Krow-web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

60
apps/web/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dataconnect/generated": "link:src/dataconnect-generated",
"@firebase/analytics": "^0.10.19",
"@firebase/data-connect": "^0.3.12",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/themes": "^3.2.1",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack-query-firebase/react": "^2.0.0",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dataconnect-generated": "link:C:/Users/dell/AppData/Local/pnpm/store/5/node_modules/src/dataconnect-generated",
"date-fns": "^4.1.0",
"firebase": "^12.8.0",
"framer-motion": "^12.29.2",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

5744
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
overrides:
'@dataconnect/generated': link:src/dataconnect-generated
'@firebasegen/example-connector': link:src/dataconnect-generated
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated

32
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AppRoutes from './routes';
import { store } from './store/store';
import { initializeAuthPersistence } from './services/authService';
import AuthInitializer from './features/auth/AuthInitializer';
// Initialize the QueryClient
const queryClient = new QueryClient();
// Initialize Firebase Auth persistence
initializeAuthPersistence();
/**
* Root Application Component.
* Wraps the app with Redux Provider, React Query Provider, and AuthInitializer.
* AuthInitializer ensures auth state is restored from persistence before routes are rendered.
*/
const App: React.FC = () => {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthInitializer>
<AppRoutes />
</AuthInitializer>
</QueryClientProvider>
</Provider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "./ui/button";
interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: React.ReactNode;
backTo?: string | null;
backButtonLabel?: string;
}
export default function PageHeader({
title,
subtitle,
actions = null,
backTo = null,
backButtonLabel = "Back"
}: PageHeaderProps) {
return (
<div className="mb-8">
{/* Back Button */}
{backTo && (
<Link to={backTo} className="inline-block mb-4">
<Button variant="ghost" className="hover:bg-muted">
<ArrowLeft className="w-4 h-4 mr-2" />
{backButtonLabel}
</Button>
</Link>
)}
{/* Main Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-2">
{title}
</h1>
{subtitle && (
<p className="text-lg text-muted-foreground">{subtitle}</p>
)}
</div>
{/* Custom Actions (if provided) */}
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +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>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
/**
* Button component based on Shadcn UI.
* Supports variants (default, destructive, outline, secondary, ghost, link)
* and sizes (default, sm, lg, icon).
* Now supports leadingIcon and trailingIcon props.
*/
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, leadingIcon, trailingIcon, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
// If asChild is true, we just render children as per standard Slot behavior
if (asChild) {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{children}
</Comp>
)
}
return (
<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 }

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import * as React from "react"
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, 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 }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className || ''}`}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
import {
LayoutDashboard,
Briefcase,
Calendar,
Users,
ShoppingBag,
FileText,
PiggyBank,
BarChart2,
Clock,
ClipboardList,
Scale,
UserPlus,
Users2,
ShieldCheck,
Receipt,
Building2,
DollarSign,
PieChart,
History,
MessageSquare,
BookOpen,
HelpCircle,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
export type Role = 'Admin' | 'Client' | 'Vendor';
export interface NavItem {
label: string;
path: string;
icon: LucideIcon;
allowedRoles: Role[];
}
export interface NavGroup {
title: string;
items: NavItem[];
}
export const ALL_ROLES: Role[] = ['Admin', 'Client', 'Vendor'];
export const NAV_CONFIG: NavGroup[] = [
{
title: 'Overview',
items: [
{
label: 'Dashboard',
path: '/dashboard/admin',
icon: LayoutDashboard,
allowedRoles: ['Admin'],
},
{
label: 'Dashboard',
path: '/dashboard/client',
icon: LayoutDashboard,
allowedRoles: ['Client'],
},
{
label: 'Dashboard',
path: '/dashboard/vendor',
icon: LayoutDashboard,
allowedRoles: ['Vendor'],
},
{
label: 'Savings Engine',
path: '/savings',
icon: PiggyBank,
allowedRoles: ALL_ROLES,
},
{
label: 'Vendor Performance',
path: '/performance',
icon: BarChart2,
allowedRoles: ['Vendor', 'Admin'],
},
],
},
{
title: 'Operations',
items: [
{
label: 'Orders',
path: '/orders/client',
icon: Briefcase,
allowedRoles: ['Client'],
},
{
label: 'Orders',
path: '/orders/vendor',
icon: Briefcase,
allowedRoles: ['Vendor'],
},
{
label: 'Orders',
path: '/orders',
icon: Briefcase,
allowedRoles: ['Admin'],
},
{
label: 'Schedule',
path: '/schedule',
icon: Calendar,
allowedRoles: ALL_ROLES,
},
{
label: 'Staff Availability',
path: '/availability',
icon: Clock,
allowedRoles: ALL_ROLES,
},
{
label: 'Task Board',
path: '/tasks',
icon: ClipboardList,
allowedRoles: ALL_ROLES,
},
],
},
{
title: 'Marketplace',
items: [
{
label: 'Discovery',
path: '/marketplace',
icon: ShoppingBag,
allowedRoles: ['Client', 'Admin'],
},
{
label: 'Compare Rates',
path: '/marketplace/compare',
icon: Scale,
allowedRoles: ['Client', 'Admin'],
},
],
},
{
title: 'Workforce',
items: [
{
label: 'Staff Directory',
path: '/staff',
icon: Users,
allowedRoles: ALL_ROLES,
},
{
label: 'Onboarding',
path: '/onboarding',
icon: UserPlus,
allowedRoles: ALL_ROLES,
},
{
label: 'Teams',
path: '/teams',
icon: Users2,
allowedRoles: ALL_ROLES,
},
{
label: 'Compliance',
path: '/compliance',
icon: ShieldCheck,
allowedRoles: ['Vendor', 'Admin'],
},
{
label: 'Documents',
path: '/documents',
icon: FileText,
allowedRoles: ['Vendor', 'Admin'],
},
],
},
{
title: 'Finance',
items: [
{
label: 'Invoices',
path: '/invoices',
icon: Receipt,
allowedRoles: ALL_ROLES,
},
],
},
{
title: 'Business',
items: [
{
label: 'Clients',
path: '/clients',
icon: Building2,
allowedRoles: ['Vendor', 'Admin'],
},
{
label: 'Service Rates',
path: '/rates',
icon: DollarSign,
allowedRoles: ['Vendor', 'Admin'],
},
],
},
{
title: 'Analytics & Comm',
items: [
{
label: 'Reports',
path: '/reports',
icon: PieChart,
allowedRoles: ALL_ROLES,
},
{
label: 'Activity Log',
path: '/activity',
icon: History,
allowedRoles: ['Vendor', 'Admin'],
},
{
label: 'Messages',
path: '/messages',
icon: MessageSquare,
allowedRoles: ALL_ROLES,
},
{
label: 'Tutorials',
path: '/tutorials',
icon: BookOpen,
allowedRoles: ['Client', 'Admin'],
},
],
},
{
title: 'Support',
items: [
{
label: 'Help Center',
path: '/support',
icon: HelpCircle,
allowedRoles: ['Client', 'Admin'],
},
],
},
];

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { checkAuthStatus } from './authSlice';
import type { RootState, AppDispatch } from '../../store/store';
interface AuthInitializerProps {
children: React.ReactNode;
}
/**
* AuthInitializer Component
* Initializes authentication state from Firebase persistence on app load
* Shows a loading screen until the initial auth check is complete
* This prevents premature redirect to login before the persisted session is restored
*/
const AuthInitializer: React.FC<AuthInitializerProps> = ({ children }) => {
const dispatch = useDispatch<AppDispatch>();
const { isInitialized } = useSelector((state: RootState) => state.auth);
useEffect(() => {
// Perform initial auth check when component mounts
// This restores the persisted session from Firebase
dispatch(checkAuthStatus());
}, [dispatch]);
// Show loading state while initializing auth
if (!isInitialized) {
return (
<div className="flex items-center justify-center h-screen bg-slate-50">
<div className="text-center">
<div className="inline-block">
<div className="w-8 h-8 border-4 border-slate-200 border-t-primary rounded-full animate-spin"></div>
</div>
<p className="mt-4 text-slate-600">Loading application...</p>
</div>
</div>
);
}
return <>{children}</>;
};
export default AuthInitializer;

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Loader2, AlertCircle, CheckCircle, ArrowLeft } from "lucide-react";
import loginHero from "../../assets/login-hero.png";
import logo from "../../assets/logo.png";
import { Label } from "@radix-ui/react-label";
import { Input } from "../../common/components/ui/input";
import { Button } from "../../common/components/ui/button";
import { sendPasswordReset } from "../../services/authService";
import { FirebaseError } from "firebase/app";
/**
* ForgotPassword Component
* Allows users to request a password reset by entering their email address.
* Firebase will send a reset link to the provided email.
*/
const ForgotPassword: React.FC = () => {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [isFormValid, setIsFormValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const navigate = useNavigate();
useEffect(() => {
setIsFormValid(validateEmail(email));
}, [email]);
// Validate email format
const validateEmail = (value: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
};
// Handle email input change
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
if (value.trim() && !validateEmail(value)) {
setEmailError("Please enter a valid email address");
} else {
setEmailError("");
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validate before submission
if (!isFormValid) {
if (!validateEmail(email)) {
setEmailError("Please enter a valid email address");
}
return;
}
setIsLoading(true);
try {
const result = await sendPasswordReset(email);
if (result.success) {
setSuccess(true);
setEmail("");
// Automatically redirect after 5 seconds
setTimeout(() => {
navigate("/login");
}, 5000);
} else {
setError(result.error || "Failed to send reset email. Please try again.");
}
} catch (err: unknown) {
let message = "Failed to send reset email. Please try again.";
if (err instanceof FirebaseError) {
message = err.message;
} else if (err instanceof Error) {
message = err.message;
}
setError(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen bg-slate-50/50">
{/* Left Side: Hero Image (Hidden on Mobile) */}
<div className="hidden lg:flex lg:w-3/5 xl:w-[65%] relative overflow-hidden">
<img
src={loginHero}
alt="Modern workspace"
className="absolute inset-0 w-full h-full object-cover transform scale-105 hover:scale-100 transition-transform duration-10000 ease-in-out"
/>
{/* Cinematic Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-black/20 to-transparent z-10" />
<div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
{/* Top Left Logo */}
<div className="absolute top-12 left-12 z-20">
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto brightness-0 invert"
/>
</div>
<div className="absolute bottom-12 left-12 right-12 text-white z-10">
<h1 className="text-5xl font-bold mb-4 tracking-tight leading-tight">
Streamline your workforce <br /> with{" "}
<span className="text-secondary underline decoration-4 underline-offset-8">
KROW
</span>
</h1>
<p className="text-lg text-slate-100/90 max-w-lg leading-relaxed">
The all-in-one platform for managing staff, orders, and professional
relationships with precision and ease.
</p>
</div>
</div>
{/* Right Side: Forgot Password Form */}
<div className="w-full lg:w-2/5 xl:w-[35%] flex flex-col justify-center items-center p-8 sm:p-12 md:p-16 lg:p-20 bg-white border-l border-slate-200 z-10">
<div className="w-full max-w-md space-y-8">
{/* Header with back button */}
<div className="flex flex-col items-center lg:items-start space-y-4">
<button
onClick={() => navigate("/login")}
className="flex items-center text-sm font-semibold text-primary hover:text-primary/80 transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Login
</button>
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto lg:hidden mb-2"
/>
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight text-primary-text">
Reset Password
</h2>
<p className="text-secondary-text font-medium">
Enter your email to receive a password reset link
</p>
</div>
</div>
{/* Success Message */}
{success && (
<div className="flex flex-col items-center p-6 text-sm text-green-700 bg-green-50 border border-green-200 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
<CheckCircle className="w-5 h-5 mb-2 flex-shrink-0" />
<span className="font-semibold mb-1">Check your email</span>
<span className="text-xs text-center text-green-600">
We've sent you a link to reset your password. Please check your email and follow the link.
You'll be redirected to login in a moment...
</span>
</div>
)}
{/* Error Message */}
{error && !success && (
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-4 h-4 mr-2 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Form */}
{!success && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-semibold">
Work Email
</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={handleEmailChange}
disabled={isLoading}
required
aria-describedby={emailError ? "email-error" : undefined}
className={
emailError
? "border-destructive focus-visible:ring-destructive"
: "mt-2"
}
/>
{emailError && (
<p
id="email-error"
className="text-xs text-destructive font-medium"
>
{emailError}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isFormValid}
size="default"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
Sending Reset Link...
</>
) : (
"Send Reset Link"
)}
</Button>
</form>
)}
{/* Helper Text */}
<div className="p-4 bg-slate-50/80 rounded-xl border border-slate-100 text-xs text-secondary-text">
<p className="font-semibold mb-2">Didn't receive the email?</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>Check your spam/junk folder</li>
<li>Make sure the email is correct</li>
<li>Reset links expire after 1 hour</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Loader2, AlertCircle } from "lucide-react";
import loginHero from "../../assets/login-hero.png";
import logo from "../../assets/logo.png";
import { Label } from "@radix-ui/react-label";
import { Input } from "../../common/components/ui/input";
import { Button } from "../../common/components/ui/button";
import { loginUser } from "../auth/authSlice";
import { getDashboardPath } from "../../services/firestoreService";
import type { RootState, AppDispatch } from "../../store/store";
/**
* Login Page Component.
* Features a modern split-screen layout with an inspirational hero image.
* Handles user authentication via email/password with client-side validation.
* Uses Redux for state management and handles role-based redirection.
*/
type LocationState = {
from?: {
pathname: string;
};
message?: string;
};
const Login: React.FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [sessionMessage, setSessionMessage] = useState<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch<AppDispatch>();
const { status, error: reduxError, user, isAuthenticated } = useSelector((state: RootState) => state.auth);
// Validate email format
const validateEmail = (value: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
};
// Validate password length
const validatePassword = (value: string): boolean => {
return value.length >= 8;
};
// Validate entire form
const validateForm = (emailValue: string, passwordValue: string): boolean => {
return validateEmail(emailValue) && validatePassword(passwordValue);
};
const isFormValid = validateForm(email, password);
// Handle email input change
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
if (value.trim() && !validateEmail(value)) {
setEmailError("Please enter a valid email address");
} else {
setEmailError("");
}
};
// Handle password input change
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setPassword(value);
if (value && !validatePassword(value)) {
setPasswordError("Password must be at least 8 characters");
} else {
setPasswordError("");
}
};
// Navigate user after successful login
useEffect(() => {
if (!isAuthenticated || !user?.userRole) return;
const state = location.state as LocationState | null;
const from = state?.from?.pathname;
const dashboardPath = getDashboardPath(user.userRole);
navigate(from ?? dashboardPath, { replace: true });
}, [isAuthenticated, user?.userRole, navigate, location.state]);
// Check for session expiration message from redirect
useEffect(() => {
const state = location.state as LocationState | null;
if (state?.message) {
// Auto-clear session message after 5 seconds
const timer = setTimeout(() => setSessionMessage(null), 5000);
// Use a microtask to avoid cascading renders
Promise.resolve().then(() => setSessionMessage(state.message || null));
return () => clearTimeout(timer);
}
}, [location.state]);
// Clear error message when component unmounts
useEffect(() => {
return () => {
// Error will be cleared from Redux state when user navigates away
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate before submission
if (!isFormValid) {
if (!validateEmail(email)) {
setEmailError("Please enter a valid email address");
}
if (!validatePassword(password)) {
setPasswordError("Password must be at least 8 characters");
}
return;
}
// Dispatch Redux action to handle login
dispatch(loginUser({ email, password }));
};
return (
<div className="flex min-h-screen bg-slate-50/50">
{/* Left Side: Hero Image (Hidden on Mobile) */}
<div className="hidden lg:flex lg:w-3/5 xl:w-[65%] relative overflow-hidden">
<img
src={loginHero}
alt="Modern workspace"
className="absolute inset-0 w-full h-full object-cover transform scale-105 hover:scale-100 transition-transform duration-10000 ease-in-out"
/>
{/* Cinematic Overlay */}
<div className="absolute inset-0 bg-linear-to-r from-black/60 via-black/20 to-transparent z-10" />
<div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
{/* Top Left Logo */}
<div className="absolute top-12 left-12 z-20">
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto brightness-0 invert"
/>
</div>
<div className="absolute bottom-12 left-12 right-12 text-white z-10">
<h1 className="text-5xl font-bold mb-4 tracking-tight leading-tight">
Streamline your workforce <br /> with{" "}
<span className="text-secondary underline decoration-4 underline-offset-8">
KROW
</span>
</h1>
<p className="text-lg text-slate-100/90 max-w-lg leading-relaxed">
The all-in-one platform for managing staff, orders, and professional
relationships with precision and ease.
</p>
</div>
</div>
{/* Right Side: Login Form */}
<div className="w-full lg:w-2/5 xl:w-[35%] flex flex-col justify-center items-center p-8 sm:p-12 md:p-16 lg:p-20 bg-white border-l border-slate-200 z-10">
<div className="w-full max-w-md space-y-8">
{/* Logo / Branding */}
<div className="flex flex-col items-center lg:items-start space-y-4">
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto lg:hidden mb-2"
/>
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight text-primary-text">
Welcome Back
</h2>
<p className="text-secondary-text font-medium">
Please enter your details to sign in
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{sessionMessage && (
<div className="flex items-center p-4 text-sm text-blue-700 bg-blue-50 border border-blue-200 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
<span>{sessionMessage}</span>
</div>
)}
{reduxError && (
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
<span>{reduxError}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-semibold">
Work Email
</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={handleEmailChange}
disabled={status === "loading"}
required
aria-describedby={emailError ? "email-error" : undefined}
className={
emailError
? "border-destructive focus-visible:ring-destructive"
: ""
}
/>
{emailError && (
<p
id="email-error"
className="text-xs text-destructive font-medium"
>
{emailError}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-1">
<Label htmlFor="password" className="text-sm font-semibold">
Password
</Label>
<button
type="button"
onClick={() => navigate("/forgot-password")}
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
>
Forgot password?
</button>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={handlePasswordChange}
disabled={status === "loading"}
required
aria-describedby={passwordError ? "password-error" : undefined}
className={
passwordError
? "border-destructive focus-visible:ring-destructive"
: ""
}
/>
{passwordError && (
<p
id="password-error"
className="text-xs text-destructive font-medium"
>
{passwordError}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={status === "loading" || !isFormValid}
size="default"
>
{status === "loading" ? (
<>
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
Authenticating...
</>
) : (
"Sign In to Workspace"
)}
</Button>
</form>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,185 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { loginWithEmail, logout, getCurrentUser } from "../../services/authService";
import { fetchUserData } from "../../services/firestoreService";
import type { User } from "firebase/auth";
export interface AuthUser {
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
userRole?: "admin" | "client" | "vendor" | "ADMIN" | "CLIENT" | "VENDOR";
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
isInitialized: boolean; // Track whether initial auth check has completed
}
const initialState: AuthState = {
user: null,
isAuthenticated: false,
status: "idle",
error: null,
isInitialized: false, // Start as false until initial auth check completes
};
/**
* Async thunk for user login
* Fetches user data including role from Firestore after authentication
*/
export const loginUser = createAsyncThunk(
"auth/loginUser",
async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => {
const result = await loginWithEmail(email, password);
if (!result.success) {
return rejectWithValue(result.error);
}
const firebaseUser = result.user as User;
// Fetch user role from backend (DataConnect) or fallback (Firestore)
let userRole: AuthUser['userRole'] = undefined;
if(userRole === undefined){
userRole = "client"; // Default to 'client' if role is missing
}
try {
const userData = await fetchUserData(firebaseUser.uid);
if (userData && userData.userRole) {
userRole = userData.userRole as AuthUser['userRole'];
}
} catch (error) {
console.error("Failed to fetch user role:", error);
// Do not assign a frontend default — treat missing role as undefined
}
return {
uid: firebaseUser.uid,
email: firebaseUser.email,
displayName: firebaseUser.displayName,
photoURL: firebaseUser.photoURL,
userRole,
};
}
);
/**
* Async thunk for user logout
*/
export const logoutUser = createAsyncThunk("auth/logoutUser", async (_, { rejectWithValue }) => {
const result = await logout();
if (!result.success) {
return rejectWithValue(result.error);
}
return null;
});
/**
* Async thunk to check if user is already logged in
* Fetches user role from Firestore on app initialization
*/
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async () => {
const currentUser = getCurrentUser();
if (currentUser) {
// Fetch user role from backend (DataConnect) or fallback (Firestore)
let userRole: AuthUser['userRole'] = undefined;
try {
const userData = await fetchUserData(currentUser.uid);
if (userData && userData.userRole) {
console.log("User data fetched during auth check:", userData);
userRole = userData.userRole as AuthUser['userRole'];
console.log("Fetched user role:", userRole);
}
} catch (error) {
console.error("Failed to fetch user role:", error);
// Do not apply a frontend default role
}
return {
uid: currentUser.uid,
email: currentUser.email,
displayName: currentUser.displayName,
photoURL: currentUser.photoURL,
userRole,
};
}
return null;
});
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
setUserRole: (state, action: PayloadAction<"admin" | "client" | "vendor">) => {
if (state.user) {
state.user.userRole = action.payload;
}
},
},
extraReducers: (builder) => {
// Login thunk
builder
.addCase(loginUser.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.status = "succeeded";
state.isAuthenticated = true;
state.user = action.payload;
state.error = null;
})
.addCase(loginUser.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.isAuthenticated = false;
});
// Logout thunk
builder
.addCase(logoutUser.fulfilled, (state) => {
state.user = null;
state.isAuthenticated = false;
state.status = "idle";
state.error = null;
})
.addCase(logoutUser.rejected, (state, action) => {
state.error = action.payload as string;
});
// Check auth status thunk
builder
.addCase(checkAuthStatus.pending, (state) => {
state.status = "loading";
})
.addCase(checkAuthStatus.fulfilled, (state, action) => {
if (action.payload) {
state.user = action.payload;
state.isAuthenticated = true;
} else {
state.user = null;
state.isAuthenticated = false;
}
state.status = "idle";
state.isInitialized = true; // Mark initialization as complete
})
.addCase(checkAuthStatus.rejected, (state) => {
state.isInitialized = true; // Mark initialization as complete even on error
state.isAuthenticated = false;
});
},
});
export const { clearError, setUserRole } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,26 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
import { getAuth } from "firebase/auth";
import { getDataConnect } from "firebase/data-connect";
import { connectorConfig } from "@/dataconnect-generated";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
// Initialize Firebase
export const app = initializeApp(firebaseConfig);
export const analytics = getAnalytics(app);
export const dataConnect = getDataConnect(app, connectorConfig);
export const auth = getAuth(app);

View File

@@ -0,0 +1,8 @@
const AdminDashboard = () => {
return (
<div> Admin Dashboard</div>
)
}
export default AdminDashboard

View File

@@ -0,0 +1,9 @@
const ClientDashboard = () => {
return (
<div>ClientDashboard</div>
)
}
export default ClientDashboard

View File

@@ -0,0 +1,27 @@
import React, {useEffect} from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { getDashboardPath } from '../../services/firestoreService'
import type { RootState } from '../../store/store';
/**
* RoleDashboardRedirect Component
* Dynamically redirects users to their appropriate dashboard based on their role
*/
export const RoleDashboardRedirect: React.FC = () => {
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
const navigate = useNavigate();
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', { replace: true });
return;
}
if (user?.userRole) {
const dashboardPath = getDashboardPath(user.userRole);
navigate(dashboardPath, { replace: true });
}
}, [isAuthenticated, user?.userRole, navigate]);
return null;
};

View File

@@ -0,0 +1,9 @@
const VendorDashboard = () => {
return (
<div>VendorDashboard</div>
)
}
export default VendorDashboard

View File

@@ -0,0 +1,106 @@
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { logoutUser } from '../auth/authSlice';
import { Button } from '../../common/components/ui/button';
import type { RootState, AppDispatch } from '../../store/store';
import {
Bell,
Search
} from 'lucide-react';
import Sidebar from './Sidebar';
import { getDashboardPath } from '../../services/firestoreService';
import { useSessionPersistence } from '../../hooks/useSessionPersistence';
/**
* Main Application Layout for Authenticated Users.
* Includes Sidebar, Header, and Content Area.
* Handles role-based navigation rendering and validates user access to dashboard routes.
*/
const AppLayout: React.FC = () => {
// Initialize session persistence and token refresh
useSessionPersistence();
// Typed selectors
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
const dispatch = useDispatch<AppDispatch>();
const location = useLocation();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = React.useState(true);
// Validate dashboard access based on user role
useEffect(() => {
if (!user?.userRole) return;
const currentPath = location.pathname;
const correctDashboard = getDashboardPath(user.userRole);
// If user is trying to access a dashboard route
if (currentPath.startsWith('/dashboard/')) {
// Check if the current dashboard doesn't match their role
if (currentPath !== correctDashboard) {
// Redirect to their correct dashboard
navigate(correctDashboard, { replace: true });
}
}
}, [location.pathname, user?.userRole, navigate]);
// Auth Guard: Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
const handleLogout = () => {
dispatch(logoutUser()).then(() => {
// Navigate to login page after logout is complete
navigate('/login', { replace: true });
});
};
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
<Sidebar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
user={user ? { name: user.displayName || undefined, role: user.userRole } : null}
onLogout={handleLogout}
/>
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden relative">
{/* Top Header */}
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 z-10 shrink-0">
<div className="flex items-center text-sm text-secondary-text">
<span className="font-medium text-primary-text">Workspace</span>
<span className="mx-2">/</span>
<span className="capitalize">{location.pathname.split('/')[1] || 'Dashboard'}</span>
</div>
<div className="flex items-center space-x-4">
<div className="relative hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-text" size={16} />
<input
type="text"
placeholder="Search..."
className="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:ring-2 focus:ring-primary/20 w-64"
/>
</div>
<Button variant="ghost" size="icon" className="relative">
<Bell size={20} className="text-secondary-text" />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border border-white"></span>
</Button>
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</main>
</div>
);
};
export default AppLayout;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PageHeader from '../../common/components/PageHeader';
import { motion } from 'framer-motion';
interface DashboardLayoutProps {
title: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
maxWidth?: string;
backAction?: React.ReactNode;
}
const DashboardLayout: React.FC<DashboardLayoutProps> = ({
title,
subtitle,
actions,
children,
maxWidth = 'max-w-7xl',
backAction
}) => {
return (
<div className="p-4 md:p-8">
<div className={`${maxWidth} mx-auto`}>
{backAction && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mb-4"
>
{backAction}
</motion.div>
)}
<PageHeader
title={title}
subtitle={subtitle}
actions={actions}
/>
{children}
</div>
</div>
);
};
export default DashboardLayout;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import type { RootState } from '../../store/store';
interface ProtectedRouteProps {
children: React.ReactNode;
allowedRoles: Array<'admin' | 'client' | 'vendor' | 'ADMIN' | 'CLIENT' | 'VENDOR'>;
redirectTo?: string;
}
/**
* ProtectedRoute Component
* Ensures only authenticated users with specific roles can access certain routes
* Redirects to appropriate dashboard if user role doesn't match allowed roles
*/
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
allowedRoles,
redirectTo = '/dashboard/client',
}) => {
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
// If user is not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// If user is authenticated but role is not allowed, redirect to specified path
// Compare roles case-insensitively to handle backend casing (e.g., "ADMIN" vs "admin")
if (
user?.userRole &&
!allowedRoles.some((r) => r.toLowerCase() === user.userRole!.toLowerCase())
) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface PublicLayoutProps {
children: React.ReactNode;
}
/**
* Layout for public, unauthenticated pages (e.g. Login, Forgot Password).
* Provides a centered container with a gradient background.
*/
const PublicLayout: React.FC<PublicLayoutProps> = ({ children }) => {
return (
<div className="min-h-screen w-full bg-slate-50 flex items-center justify-center">
{/* Background decoration */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-50 -z-10" />
<div className="w-full">
{children}
</div>
</div>
);
};
export default PublicLayout;

View File

@@ -0,0 +1,135 @@
import React, { useMemo } from 'react';
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";
interface SidebarProps {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
user: {
name?: string;
role?: string;
} | null;
onLogout: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({
sidebarOpen,
setSidebarOpen,
user,
onLogout
}) => {
const location = useLocation();
const navigate = useNavigate();
/**
* Handle logout with navigation
* Ensures user is redirected to login page after logout
*/
const handleLogoutClick = async () => {
onLogout();
// Small delay to allow logout to complete before navigation
setTimeout(() => {
navigate('/login', { replace: true });
}, 100);
};
// Filter navigation based on user role
const filteredNav = useMemo(() => {
const userRoleRaw = (user?.role || 'Client') as string;
const userRole = userRoleRaw.toLowerCase();
return NAV_CONFIG.map(group => {
const visibleItems = group.items.filter(item =>
item.allowedRoles.some(r => r.toLowerCase() === userRole)
);
return {
...group,
items: visibleItems
};
}).filter(group => group.items.length > 0);
}, [user?.role]);
return (
<aside
className={`${sidebarOpen ? 'w-64' : 'w-20'
} bg-white border-r border-slate-200 transition-all duration-300 flex flex-col z-20`}
>
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-100 flex-shrink-0">
{sidebarOpen ? (
<span className="text-xl font-bold text-primary">KROW</span>
) : (
<span className="text-xl font-bold text-primary mx-auto">K</span>
)}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-1 rounded-md hover:bg-slate-100 text-secondary-text"
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="flex-1 py-6 px-3 space-y-6 overflow-y-auto">
{filteredNav.map((group) => (
<div key={group.title}>
{sidebarOpen && (
<h3 className="px-3 mb-2 text-xs font-semibold text-muted-text uppercase tracking-wider">
{group.title}
</h3>
)}
<div className="space-y-1">
{group.items.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center px-3 py-2.5 rounded-lg transition-colors group ${location.pathname.startsWith(item.path)
? 'bg-primary/10 text-primary font-medium'
: 'text-secondary-text hover:bg-slate-50 hover:text-primary-text'
}`}
title={!sidebarOpen ? item.label : undefined}
>
<item.icon
size={20}
className={`flex-shrink-0 ${location.pathname.startsWith(item.path)
? 'text-primary'
: 'text-muted-text group-hover:text-secondary-text'
}`}
/>
{sidebarOpen && <span className="ml-3 truncate">{item.label}</span>}
</Link>
))}
</div>
</div>
))}
</nav>
<div className="p-4 border-t border-slate-100 flex-shrink-0">
<div className={`flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'}`}>
{sidebarOpen && (
<div className="flex items-center overflow-hidden">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs flex-shrink-0">
{user?.name?.charAt(0) || 'U'}
</div>
<div className="ml-3 overflow-hidden">
<p className="text-sm font-medium text-primary-text truncate w-32">{user?.name}</p>
<p className="text-xs text-secondary-text truncate">{user?.role}</p>
</div>
</div>
)}
<Button
variant="ghost"
size="icon"
onClick={handleLogoutClick}
title="Logout"
className="text-secondary-text hover:text-destructive hover:bg-destructive/10"
>
<LogOut size={18} />
</Button>
</div>
</div>
</aside>
);
};
export default Sidebar;

View File

@@ -0,0 +1,210 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/common/components/ui/button";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import { ArrowLeft, Loader2, Save, Mail, Phone, User, Award } from "lucide-react";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useCreateStaff } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { useForm, Controller } from "react-hook-form";
import { Checkbox } from "@/common/components/ui/checkbox";
import { BackgroundCheckStatus } from "@/dataconnect-generated";
const COMMON_SKILLS = [
"Barista",
"Server",
"Cook",
"Dishwasher",
"Bartender",
"Manager"
];
interface AddStaffFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
skills: string[];
}
export default function AddStaff() {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createStaff } = useCreateStaff(dataConnect);
const { register, handleSubmit, control, formState: { errors } } = useForm<AddStaffFormData>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
skills: []
}
});
const onSubmit = async (data: AddStaffFormData) => {
setIsSubmitting(true);
try {
await createStaff({
userId: `user_${Math.random().toString(36).substring(2, 11)}`,
fullName: `${data.firstName} ${data.lastName}`,
email: data.email,
phone: data.phone,
skills: data.skills,
backgroundCheckStatus: BackgroundCheckStatus.PENDING,
initial: `${data.firstName.charAt(0)}${data.lastName.charAt(0)}`.toUpperCase(),
});
navigate("/staff");
} catch (error) {
console.error("Failed to create staff", error);
} finally {
setIsSubmitting(false);
}
};
return (
<DashboardLayout
title="Add New Staff"
subtitle="Invite a new team member to join the platform"
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>
}
>
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 animate-in-slide-up">
<div className="bg-card/60 backdrop-blur-md border border-border p-8 rounded-3xl shadow-xl space-y-8">
{/* Identity Section */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-6 flex items-center gap-2 uppercase tracking-wider">
<User className="w-4 h-4 text-primary" />
Staff Identity
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">First Name <span className="text-rose-500">*</span></Label>
<Input
{...register("firstName", { required: "First name is required" })}
placeholder="John"
className="rounded-xl"
/>
{errors.firstName && <p className="text-xs text-rose-500 font-bold mt-1">{errors.firstName.message}</p>}
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Last Name <span className="text-rose-500">*</span></Label>
<Input
{...register("lastName", { required: "Last name is required" })}
placeholder="Doe"
className="rounded-xl"
/>
{errors.lastName && <p className="text-xs text-rose-500 font-bold mt-1">{errors.lastName.message}</p>}
</div>
</div>
</div>
{/* Contact Section */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-6 flex items-center gap-2 uppercase tracking-wider">
<Mail className="w-4 h-4 text-primary" />
Contact Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Email Address <span className="text-rose-500">*</span></Label>
<Input
{...register("email", {
required: "Email is required",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Invalid email address"
}
})}
type="email"
leadingIcon={<Mail className="w-4 h-4" />}
placeholder="john.doe@example.com"
className="rounded-xl"
/>
{errors.email && <p className="text-xs text-rose-500 font-bold mt-1">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Phone Number <span className="text-rose-500">*</span></Label>
<Input
{...register("phone", { required: "Phone number is required" })}
type="tel"
leadingIcon={<Phone className="w-4 h-4" />}
placeholder="+1 (555) 000-0000"
className="rounded-xl"
/>
{errors.phone && <p className="text-xs text-rose-500 font-bold mt-1">{errors.phone.message}</p>}
</div>
</div>
</div>
{/* Skills Section */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-6 flex items-center gap-2 uppercase tracking-wider">
<Award className="w-4 h-4 text-primary" />
Selection of Skills
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{COMMON_SKILLS.map((skill) => (
<Controller
key={skill}
name="skills"
control={control}
render={({ field }) => (
<div className="flex items-center space-x-3 p-3 rounded-xl hover:bg-primary/5 transition-premium border border-transparent hover:border-primary/10">
<Checkbox
id={skill}
checked={field.value?.includes(skill)}
onCheckedChange={(checked) => {
const updatedSkills = checked
? [...(field.value || []), skill]
: field.value?.filter((s: string) => s !== skill);
field.onChange(updatedSkills);
}}
/>
<label
htmlFor={skill}
className="text-sm font-bold leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{skill}
</label>
</div>
)}
/>
))}
</div>
</div>
{/* Action Buttons */}
<div className="pt-6 flex gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/staff")}
className="flex-1 rounded-2xl font-bold py-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="flex-1 rounded-2xl font-bold py-6 shadow-lg shadow-primary/20"
leadingIcon={isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
>
{isSubmitting ? "Inviting..." : "Send Invitation"}
</Button>
</div>
</div>
</form>
</div>= </DashboardLayout>
);
}

View File

@@ -0,0 +1,226 @@
import { useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { Button } from "@/common/components/ui/button";
import { ArrowLeft, Loader2, Edit, Save, X, AlertCircle } from "lucide-react";
import StaffForm from "./components/StaffForm";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useGetStaffById, useUpdateStaff, useCreateActivityLog } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import type { Staff } from "../type";
import { Badge } from "@/common/components/ui/badge";
import { ActivityIconType, ActivityType } from "@/dataconnect-generated";
export default function EditStaff() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
const { user } = useSelector((state: RootState) => state.auth);
const { data: staffData, isLoading, refetch } = useGetStaffById(dataConnect, { id: id || "" });
const { mutateAsync: updateStaff } = useUpdateStaff(dataConnect);
const { mutateAsync: createActivityLog } = useCreateActivityLog(dataConnect);
const [isSubmitting, setIsSubmitting] = useState(false);
const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN';
const staff = useMemo(() => {
if (!staffData?.staff) return null;
const s = staffData.staff;
return {
id: s.id,
employee_name: s.fullName,
initial: s.initial || "",
position: s.role || "",
position_2: "",
profile_type: s.level || "",
employment_type: s.employmentType || "",
manager: s.manager || "",
email: s.email || "",
contact_number: s.phone || "",
phone: s.phone || "",
photo: s.photoUrl || "",
status: "Active", // Default for now
skills: (s.skills as string[]) || [],
averageRating: s.averageRating || 0,
reliability_score: s.reliabilityScore || 0,
shift_coverage_percentage: s.onTimeRate || 0,
total_shifts: s.totalShifts || 0,
department: s.department || "",
city: s.city || "",
address: s.addres || "", // Using misspelled field from schema
notes: s.bio || "",
} as Staff;
}, [staffData]);
const handleSubmit = async (staffData: Staff) => {
if (!id || !isAdmin) return;
setIsSubmitting(true);
try {
await updateStaff({
id: id,
fullName: staffData.employee_name,
role: staffData.position,
level: staffData.profile_type,
email: staffData.email,
phone: staffData.contact_number,
photoUrl: staffData.photo,
initial: staffData.initial,
bio: staffData.notes,
skills: staffData.skills,
averageRating: staffData.averageRating,
reliabilityScore: staffData.reliability_score,
onTimeRate: staffData.shift_coverage_percentage,
totalShifts: staffData.total_shifts,
city: staffData.city,
addres: staffData.address,
});
// Audit Log
await createActivityLog({
userId: user?.uid || "system",
date: new Date().toISOString(),
title: "Staff Profile Updated",
description: `Administrator ${user?.displayName || user?.email} updated professional records for ${staffData.employee_name}`,
activityType: ActivityType.SYSTEM_UPDATE,
iconType: ActivityIconType.CHECK,
iconColor: "emerald",
});
await refetch();
setIsEditing(false);
} catch (error) {
console.error("Failed to update staff", error);
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
setIsEditing(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>
);
}
if (!isAdmin) {
return (
<DashboardLayout title="Access Denied" subtitle="Unauthorized Access">
<div className="flex flex-col items-center justify-center min-h-[40vh] gap-6 bg-card/60 backdrop-blur-md border border-border rounded-3xl p-12 text-center">
<div className="w-20 h-20 bg-rose-500/10 rounded-full flex items-center justify-center text-rose-500 border border-rose-500/20 shadow-lg">
<AlertCircle className="w-10 h-10" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-black text-foreground">Restricted Access</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Only administrators are authorized to modify staff professional records. Please contact your system administrator if you believe this is an error.
</p>
</div>
<Button onClick={() => navigate("/staff")} variant="outline" leadingIcon={<ArrowLeft />} className="rounded-xl font-bold">
Return to Directory
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title={isEditing ? `Edit: ${staff?.employee_name || 'Staff Member'}` : staff?.employee_name || 'Staff Member'}
subtitle={isEditing ? `Modifying ${staff?.employee_name}'s professional records` : `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 && (
<div className="space-y-6">
{/* Profile Header */}
<div className="bg-card/60 backdrop-blur-md border border-border p-6 rounded-3xl flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex flex-col md:flex-row items-center gap-6 text-center md:text-left">
<div className="w-24 h-24 bg-primary/10 rounded-2xl flex items-center justify-center text-primary font-black text-3xl border-2 border-primary/20 shadow-xl overflow-hidden group">
{staff.photo ? (
<img src={staff.photo} alt={staff.employee_name} className="w-full h-full object-cover" />
) : (
staff.employee_name.charAt(0)
)}
</div>
<div>
<h2 className="text-3xl font-black text-foreground mb-1">{staff.employee_name}</h2>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2">
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 font-black text-xs uppercase px-3 py-1">
{staff.status || 'Active'}
</Badge>
<span className="text-muted-foreground font-bold text-sm"></span>
<span className="text-muted-foreground font-bold text-sm">{staff.position || 'No Role Assigned'}</span>
{staff.profile_type && (
<>
<span className="text-muted-foreground font-bold text-sm"></span>
<span className="text-primary font-bold text-sm">{staff.profile_type}</span>
</>
)}
</div>
</div>
</div>
<div className="flex gap-3">
{!isEditing ? (
<Button
onClick={() => setIsEditing(true)}
variant="secondary"
leadingIcon={<Edit className="w-4 h-4" />}
className="rounded-xl px-6 font-bold"
>
Edit Profile
</Button>
) : (
<>
<Button
onClick={handleCancel}
variant="outline"
disabled={isSubmitting}
leadingIcon={<X className="w-4 h-4" />}
className="rounded-xl font-bold"
>
Cancel
</Button>
<Button
onClick={() => {
const form = document.querySelector('form');
if (form) form.requestSubmit();
}}
disabled={isSubmitting}
leadingIcon={isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
className="rounded-xl px-6 font-bold"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</>
)}
</div>
</div>
<StaffForm
staff={staff}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
disabled={!isEditing}
/>
</div>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,419 @@
import { useState, 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 { useListStaff, useGetStaffById } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { formatDistanceToNow } from "date-fns";
const ITEMS_PER_PAGE = 10;
function StaffActiveStatus({ staffId }: { staffId: string }) {
const { data: staffDetail, isLoading } = useGetStaffById(dataConnect, { id: staffId });
const getLastActiveText = (lastActive?: string) => {
if (!lastActive) return 'Never';
try {
const date = new Date(lastActive);
return formatDistanceToNow(date, { addSuffix: true });
} catch (e) {
return 'Invalid date';
}
};
if (isLoading) return <span className="animate-pulse">Loading...</span>;
return <span>{getLastActiveText(staffDetail?.staff?.updatedAt)}</span>;
}
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 { data: staffData, isLoading } = useListStaff(dataConnect);
const staff = useMemo(() => {
return staffData?.staffs || [];
}, [staffData]);
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.fullName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.email?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all"; // status field is missing in current schema
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';
}
};
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 mt-1">
<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>
</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.fullName || '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.photoUrl ? (
<img src={member.photoUrl} alt={member.fullName} className="w-full h-full rounded-xl object-cover" />
) : (
member.fullName?.charAt(0) || '?'
)}
</div>
</td>
<td className="py-4 px-6 text-center">
<Badge className={`${getStatusColor('Active')} font-black text-xs border`}>
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">
<StaffActiveStatus staffId={member.id} />
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="flex items-center justify-center gap-3 mt-8"
>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
leadingIcon={<ChevronLeft className="w-4 h-4" />}
>
Previous
</Button>
<div className="flex items-center gap-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-10 h-10 rounded-lg font-bold transition-all ${
currentPage === page
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/70 text-foreground'
}`}
>
{page}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
leadingIcon={<ChevronRight className="w-4 h-4" />}
>
Next
</Button>
<span className="text-sm text-muted-foreground ml-4">
Page {currentPage} of {totalPages}
</span>
</motion.div>
)}
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-24 bg-card/60 backdrop-blur-sm rounded-3xl border border-dashed border-border"
>
<div className="w-20 h-20 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-2xl font-black text-foreground mb-3">No Staff Members Found</h3>
<p className="text-muted-foreground mb-8 max-w-sm mx-auto font-medium">
{staff.length === 0
? "Your directory is currently empty. Start by adding your first team member."
: "We couldn't find any staff members matching your current filters."}
</p>
<Link to="/staff/add">
<Button leadingIcon={<UserPlus />}>
Add First Staff Member
</Button>
</Link>
</motion.div>
)}
</DashboardLayout>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,811 @@
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, FileText, Briefcase,
Calendar, ChevronRight, Shield, Star, Info,
Mail, Phone, MapPin, Award, Clock, AlertCircle
} 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;
disabled?: boolean;
}
type TabType = "overview" | "documents" | "work_history" | "compliance";
export default function StaffForm({ staff, onSubmit, isSubmitting, disabled = false }: StaffFormProps) {
const [activeTab, setActiveTab] = useState<TabType>("overview");
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: "",
averageRating: 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: "overview", label: "Overview", icon: <User className="w-4 h-4" /> },
{ id: "documents", label: "Documents", icon: <FileText className="w-4 h-4" /> },
{ id: "work_history", label: "Work History", icon: <Briefcase className="w-4 h-4" /> },
{ id: "compliance", label: "Compliance", icon: <Shield 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 === "overview" && (
<TabContent
id="overview"
title="Staff Overview"
icon={<User className="w-5 h-5" />}
className="p-8 space-y-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: Documents</h4>
<p className="text-sm text-muted-foreground">Upload and manage staff documentation.</p>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setActiveTab("documents")}
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>
}
>
{/* Basic Information */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<User className="w-4 h-4" />
BASIC INFORMATION
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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 })}
disabled={disabled}
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")}
disabled={disabled}
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">Email</Label>
<Input
{...register("email")}
disabled={disabled}
type="email"
leadingIcon={<Mail />}
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")}
disabled={disabled}
leadingIcon={<Phone />}
className="font-medium"
placeholder="+1 (555) 000-0000"
/>
</div>
</div>
</div>
{/* Skills & Position */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Award className="w-4 h-4" />
SKILLS & POSITION
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger className="font-medium" disabled={disabled}>
<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">Secondary Skill</Label>
<Input
{...register("position_2")}
disabled={disabled}
className="font-medium"
placeholder="Additional skills"
/>
</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={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger className="font-medium" disabled={disabled}>
<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">Employment Type</Label>
<Controller
name="employment_type"
control={control}
render={({ field }) => (
<Select onValueChange={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger className="font-medium" disabled={disabled}>
<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>
</div>
{/* Rating & Performance Overview */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Star className="w-4 h-4" />
RATING & PERFORMANCE
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-6 bg-amber-50 dark:bg-amber-950/20 rounded-2xl border border-amber-200 dark:border-amber-900">
<div className="flex items-center gap-2 mb-2">
<Star className="w-4 h-4 text-amber-500" />
<Label className="text-xs font-black text-amber-700 dark:text-amber-400">Average Rating</Label>
</div>
<Input
{...register("averageRating", { valueAsNumber: true })}
disabled={disabled}
type="number"
step="0.1"
min="0"
max="5"
className="h-12 rounded-xl text-xl font-black text-center bg-white dark:bg-amber-950/40"
/>
</div>
<div className="p-6 bg-emerald-50 dark:bg-emerald-950/20 rounded-2xl border border-emerald-200 dark:border-emerald-900">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-emerald-500" />
<Label className="text-xs font-black text-emerald-700 dark:text-emerald-400">Reliability Score</Label>
</div>
<Input
{...register("reliability_score", { valueAsNumber: true })}
disabled={disabled}
type="number"
min="0"
max="100"
className="h-12 rounded-xl text-xl font-black text-center bg-white dark:bg-emerald-950/40"
/>
</div>
<div className="p-6 bg-blue-50 dark:bg-blue-950/20 rounded-2xl border border-blue-200 dark:border-blue-900">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-blue-500" />
<Label className="text-xs font-black text-blue-700 dark:text-blue-400">Coverage %</Label>
</div>
<Input
{...register("shift_coverage_percentage", { valueAsNumber: true })}
disabled={disabled}
type="number"
min="0"
max="100"
className="h-12 rounded-xl text-xl font-black text-center bg-white dark:bg-blue-950/40"
/>
</div>
</div>
</div>
{/* Location & Department */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<MapPin className="w-4 h-4" />
LOCATION & DEPARTMENT
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Department</Label>
<Controller
name="department"
control={control}
render={({ field }) => (
<Select onValueChange={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger disabled={disabled}>
<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 pl-1">Primary City</Label>
<Input
{...register("city")}
disabled={disabled}
className="font-medium"
placeholder="e.g., San Francisco"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Hub Location</Label>
<Input
{...register("hub_location")}
disabled={disabled}
className="font-medium"
placeholder="Primary hub site"
/>
</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={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger className="font-medium" disabled={disabled}>
<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>
</div>
</div>
</TabContent>
)}
{activeTab === "documents" && (
<TabContent
id="documents"
title="Staff Documents"
icon={<FileText className="w-5 h-5" />}
className="p-8 space-y-8"
>
{/* Document Upload Section */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<FileText className="w-4 h-4" />
REQUIRED DOCUMENTS
</h3>
<div className="space-y-4">
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<div className="flex items-center justify-between mb-2">
<Label className="font-bold text-foreground">I-9 Form</Label>
<span className="text-xs px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-lg font-bold">
Pending
</span>
</div>
<p className="text-xs text-muted-foreground">Employment eligibility verification</p>
</div>
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<div className="flex items-center justify-between mb-2">
<Label className="font-bold text-foreground">W-4 Form</Label>
<span className="text-xs px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-lg font-bold">
Pending
</span>
</div>
<p className="text-xs text-muted-foreground">Tax withholding information</p>
</div>
</div>
</div>
{/* Certifications */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Award className="w-4 h-4" />
CERTIFICATIONS
</h3>
<div className="space-y-4">
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<div className="flex items-center justify-between mb-2">
<Label className="font-bold text-foreground">Food Handler Certificate</Label>
<span className="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-lg font-bold">
Valid
</span>
</div>
<p className="text-xs text-muted-foreground">Expires: 12/31/2026</p>
</div>
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<div className="flex items-center justify-between mb-2">
<Label className="font-bold text-foreground">Additional Certifications</Label>
<Button type="button" variant="outline" size="sm" disabled={disabled}>
Upload
</Button>
</div>
<p className="text-xs text-muted-foreground">Add any additional certifications or licenses</p>
</div>
</div>
</div>
{/* Notes */}
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Document Notes</Label>
<Textarea
{...register("notes")}
disabled={disabled}
placeholder="Notes about uploaded documents, missing items, or follow-ups needed..."
className="min-h-[100px]"
/>
</div>
</TabContent>
)}
{activeTab === "work_history" && (
<TabContent
id="work_history"
title="Work History"
icon={<Briefcase className="w-5 h-5" />}
className="p-8 space-y-8"
>
{/* Shift Statistics */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Clock className="w-4 h-4" />
SHIFT STATISTICS
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<Label className="text-xs font-black text-muted-foreground/80 mb-2 block">Total Shifts</Label>
<Input
{...register("total_shifts", { valueAsNumber: true })}
disabled={disabled}
type="number"
className="h-12 rounded-xl text-xl font-black text-center"
/>
</div>
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<Label className="text-xs font-black text-muted-foreground/80 mb-2 block">Cancellations</Label>
<Input
{...register("cancellation_count", { valueAsNumber: true })}
disabled={disabled}
type="number"
className="h-12 rounded-xl text-xl font-black text-center"
/>
</div>
<div className="p-6 bg-secondary/20 rounded-2xl border border-border/40">
<Label className="text-xs font-black text-muted-foreground/80 mb-2 block">No Shows</Label>
<Input
{...register("no_show_count", { valueAsNumber: true })}
disabled={disabled}
type="number"
className="h-12 rounded-xl text-xl font-black text-center"
/>
</div>
</div>
</div>
{/* Schedule Information */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Calendar className="w-4 h-4" />
SCHEDULE INFORMATION
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Last Check-in</Label>
<Input
{...register("check_in")}
disabled={disabled}
type="date"
leadingIcon={<Calendar />}
className="font-medium"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Schedule Days</Label>
<Input
{...register("schedule_days")}
disabled={disabled}
className="font-medium"
placeholder="e.g., M/W/F Only"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Event Location</Label>
<Input
{...register("event_location")}
disabled={disabled}
className="font-medium"
placeholder="Primary event location"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Track</Label>
<Input
{...register("track")}
disabled={disabled}
className="font-medium"
placeholder="Assignment track"
/>
</div>
</div>
</div>
{/* Assignment Details */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Briefcase className="w-4 h-4" />
ASSIGNMENT DETAILS
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Replaced By</Label>
<Input
{...register("replaced_by")}
disabled={disabled}
className="font-medium"
placeholder="Staff member name"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Action Status</Label>
<Input
{...register("action")}
disabled={disabled}
className="font-medium"
placeholder="Current action status"
/>
</div>
</div>
</div>
{/* Work History Notes */}
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Work History Notes</Label>
<Textarea
{...register("accounting_comments")}
disabled={disabled}
placeholder="Notes about work history, patterns, or observations..."
className="min-h-[100px]"
/>
</div>
</TabContent>
)}
{activeTab === "compliance" && (
<TabContent
id="compliance"
title="Compliance & Verification"
icon={<Shield className="w-5 h-5" />}
className="p-8 space-y-8"
>
{/* Verification Status */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Shield className="w-4 h-4" />
VERIFICATION STATUS
</h3>
<div className="space-y-4">
<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"
disabled={disabled}
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 for Invoicing</Label>
<p className="text-xs text-muted-foreground font-medium">Staff member approved for automatic invoicing</p>
</div>
</div>
</div>
<div className="p-6 bg-green-50 dark:bg-green-950/20 rounded-2xl border border-green-200 dark:border-green-900">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<Label className="font-bold text-green-900 dark:text-green-100">Background Check</Label>
</div>
<p className="text-xs text-green-700 dark:text-green-400">Completed & Approved</p>
</div>
<div className="p-6 bg-amber-50 dark:bg-amber-950/20 rounded-2xl border border-amber-200 dark:border-amber-900">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-amber-500 flex items-center justify-center">
<AlertCircle className="w-4 h-4 text-white" />
</div>
<Label className="font-bold text-amber-900 dark:text-amber-100">Drug Screening</Label>
</div>
<p className="text-xs text-amber-700 dark:text-amber-400">Pending Review</p>
</div>
</div>
</div>
{/* Language Requirements */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<Info className="w-4 h-4" />
LANGUAGE REQUIREMENTS
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">English Proficiency</Label>
<Controller
name="english"
control={control}
render={({ field }) => (
<Select onValueChange={disabled ? undefined : field.onChange} value={field.value} disabled={disabled}>
<SelectTrigger disabled={disabled}>
<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-8">
<Controller
name="english_required"
control={control}
render={({ field }) => (
<Checkbox
id="english_required"
disabled={disabled}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="w-5 h-5"
/>
)}
/>
<Label htmlFor="english_required" className="text-sm font-bold text-foreground cursor-pointer">
English Required for Role
</Label>
</div>
</div>
</div>
{/* Additional Compliance Fields */}
<div>
<h3 className="text-sm font-black text-muted-foreground/80 mb-4 flex items-center gap-2">
<FileText className="w-4 h-4" />
ADDITIONAL INFORMATION
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">RO Number</Label>
<Input
{...register("ro")}
disabled={disabled}
className="font-medium"
placeholder="RO reference number"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">MON Reference</Label>
<Input
{...register("mon")}
disabled={disabled}
className="font-medium"
placeholder="Monitoring reference"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Phone (Alt)</Label>
<Input
{...register("phone")}
disabled={disabled}
leadingIcon={<Phone />}
className="font-medium"
placeholder="Alternative contact"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80 pl-1">Address</Label>
<Input
{...register("address")}
disabled={disabled}
leadingIcon={<MapPin />}
className="font-medium"
placeholder="Street address"
/>
</div>
</div>
</div>
{/* Compliance Notes */}
<div className="space-y-2">
<Label className="text-xs font-black text-muted-foreground/80">Compliance Notes</Label>
<Textarea
{...register("notes")}
disabled={disabled}
placeholder="Compliance-related notes, verification details, or follow-up items..."
className="min-h-[100px]"
/>
</div>
</TabContent>
)}
</AnimatePresence>
</div>
</div>
</form>
);
}
const TrendingUp = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline><polyline points="16 7 22 7 22 13"></polyline></svg>
);

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
import { useEffect, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import {
subscribeToAuthState,
refreshUserToken,
stopTokenRefreshTimer
} from '../services/authService';
import { checkAuthStatus, logoutUser } from '../features/auth/authSlice';
import type { RootState, AppDispatch } from '../store/store';
/**
* Custom hook for managing session persistence and token refresh
*
* Responsibilities:
* 1. Initialize auth state on app load and restore persisted sessions
* 2. Manage automatic token refresh to prevent session expiry
* 3. Detect and handle token expiration
* 4. Set up activity monitoring (optional, can extend for activity-based timeouts)
*
* Usage: Call this hook in AppLayout or a root component that wraps authenticated routes
*/
export const useSessionPersistence = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
/**
* Handle token expiration by logging out user and redirecting to login
*/
const handleTokenExpiration = useCallback(async () => {
console.warn('Token expired, logging out user');
await dispatch(logoutUser());
navigate('/login', { replace: true, state: { message: 'Your session has expired. Please log in again.' } });
}, [dispatch, navigate]);
/**
* Initialize session on component mount
* Restores persisted session from Firebase and sets up auth listeners
*/
useEffect(() => {
let unsubscribe: (() => void) | null = null;
const initializeSession = async () => {
try {
// Check if user is already logged in (from Firebase persistence)
await dispatch(checkAuthStatus());
// Set up real-time auth state listener
unsubscribe = subscribeToAuthState(async (firebaseUser) => {
if (firebaseUser) {
// User is authenticated - token refresh is started by subscribeToAuthState
console.log('User session restored:', firebaseUser.email);
} else {
// User is not authenticated
stopTokenRefreshTimer();
}
});
} catch (error) {
console.error('Error initializing session:', error);
}
};
initializeSession();
// Cleanup on unmount
return () => {
if (unsubscribe) {
unsubscribe();
}
stopTokenRefreshTimer();
};
}, [dispatch]);
/**
* Monitor token validity and handle expiration
* Periodically checks if token is still valid
*/
useEffect(() => {
if (!isAuthenticated || !user) return;
// Set up interval to check token validity every 5 minutes
const tokenCheckInterval = window.setInterval(async () => {
try {
// Attempt to get fresh token - this will throw if token is invalid/expired
const success = await refreshUserToken();
if (!success) {
// Token refresh failed
handleTokenExpiration();
}
} catch (error) {
console.error('Token validation failed:', error);
handleTokenExpiration();
}
}, 5 * 60 * 1000); // Check every 5 minutes
// Cleanup interval
return () => clearInterval(tokenCheckInterval);
}, [isAuthenticated, user, handleTokenExpiration]);
/**
* Update last activity timestamp on user interaction
* This can be used to implement idle timeout if needed in the future
*/
useEffect(() => {
if (!isAuthenticated) return;
const updateActivity = () => {
localStorage.setItem('lastActivityTime', Date.now().toString());
};
// Track user activity
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'];
events.forEach(event => {
window.addEventListener(event, updateActivity);
});
// Cleanup event listeners
return () => {
events.forEach(event => {
window.removeEventListener(event, updateActivity);
});
};
}, [isAuthenticated]);
};

162
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,162 @@
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-primary-text: hsl(var(--text-primary));
--color-secondary-text: hsl(var(--text-secondary));
--color-muted-text: hsl(var(--text-muted));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
}
@layer base {
:root {
--background: 220 20% 98%;
--foreground: 226 30% 12%;
--card: 220 20% 98%;
--card-foreground: 226 30% 10%;
--popover: 220 20% 98%;
--popover-foreground: 226 30% 10%;
--primary: 226 91% 45%;
--primary-foreground: 210 40% 98%;
--secondary: 220 15% 95%;
--secondary-foreground: 226 30% 25%;
--muted: 220 15% 95%;
--muted-foreground: 220 9% 45%;
--accent: 53 94% 62%;
--accent-foreground: 53 94% 15%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 220 15% 90%;
--input: 220 15% 90%;
--ring: 226 91% 45%;
--radius: 0.75rem;
--text-primary: 226 30% 12%;
--text-secondary: 226 20% 35%;
--text-muted: 226 15% 55%;
}
.dark {
--background: 226 30% 5%;
--foreground: 210 40% 98%;
--card: 226 30% 7%;
--card-foreground: 210 40% 98%;
--popover: 226 30% 5%;
--popover-foreground: 210 40% 98%;
--primary: 226 91% 55%;
--primary-foreground: 220 20% 10%;
--secondary: 226 30% 15%;
--secondary-foreground: 210 40% 98%;
--muted: 226 30% 15%;
--muted-foreground: 220 9% 65%;
--accent: 53 94% 62%;
--accent-foreground: 53 94% 15%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 226 30% 15%;
--input: 226 30% 15%;
--ring: 226 91% 55%;
--text-primary: 210 40% 98%;
--text-secondary: 220 9% 80%;
--text-muted: 220 9% 65%;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Instrument Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.glass {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.animate-in {
animation: fade-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-in-slide-up {
animation: slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
.transition-premium {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}

13
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
/**
* Utility function to merge Tailwind CSS classes conditionally.
* Combines clsx for conditional logic and tailwind-merge to handle conflicts.
*
* @param inputs - List of class values (strings, objects, arrays)
* @returns Merged class string
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

92
apps/web/src/routes.tsx Normal file
View File

@@ -0,0 +1,92 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './features/auth/Login';
import ForgotPassword from './features/auth/ForgotPassword';
import AppLayout from './features/layouts/AppLayout';
import AdminDashboard from './features/dashboard/AdminDashboard';
import ClientDashboard from './features/dashboard/ClientDashboard';
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
* Defines the main routing structure of the application.
* Groups routes by Layout (Public vs App).
* Implements role-based redirection after login.
*/
const AppRoutes: React.FC = () => {
return (
<Router>
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
<PublicLayout>
<Login />
</PublicLayout>
}
/>
<Route
path="/forgot-password"
element={
<ForgotPassword />
}
/>
{/* Authenticated Routes */}
<Route element={<AppLayout />}>
{/* Dashboard Redirect Logic - redirects to user's correct dashboard based on role */}
<Route path="/" element={<RoleDashboardRedirect />} />
{/* Protected Dashboard Routes */}
<Route
path="/dashboard/admin"
element={
<ProtectedRoute
allowedRoles={['admin']}
redirectTo="/dashboard/admin"
>
<AdminDashboard />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/client"
element={
<ProtectedRoute
allowedRoles={['client']}
redirectTo="/dashboard/client"
>
<ClientDashboard />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/vendor"
element={
<ProtectedRoute
allowedRoles={['vendor']}
redirectTo="/dashboard/vendor"
>
<VendorDashboard />
</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>
</Router>
);
};
export default AppRoutes;

View File

@@ -0,0 +1,211 @@
import {
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
setPersistence,
browserLocalPersistence,
sendPasswordResetEmail,
confirmPasswordReset,
} from "firebase/auth";
import type { User, AuthError } from "firebase/auth";
import { app} from "../features/auth/firebase"
import { getAuth } from "firebase/auth";
const auth = getAuth(app);
// Token refresh interval tracking
let tokenRefreshInterval: number | null = null;
// Constants for session management
const TOKEN_REFRESH_INTERVAL = 40 * 60 * 1000; // Refresh token every 40 minutes (Firebase ID tokens expire in 1 hour)
/**
* Initialize Firebase Auth persistence
* Ensures user session persists across page refreshes
*/
export const initializeAuthPersistence = async () => {
try {
await setPersistence(auth, browserLocalPersistence);
console.log("Auth persistence initialized with localStorage");
} catch (error) {
console.error("Error setting auth persistence:", error);
}
};
/**
* Refresh the current user's ID token to maintain session validity
* Firebase automatically refreshes tokens, but we can force a refresh to ensure validity
* @returns Promise<boolean> - true if refresh successful, false otherwise
*/
export const refreshUserToken = async (): Promise<boolean> => {
try {
const currentUser = auth.currentUser;
if (currentUser) {
await currentUser.getIdTokenResult(true); // Force refresh
console.log("Token refreshed successfully");
return true;
}
return false;
} catch (error) {
console.error("Error refreshing token:", error);
return false;
}
};
/**
* Start automatic token refresh mechanism
* Refreshes token periodically to prevent unexpected logouts
*/
export const startTokenRefreshTimer = () => {
// Clear any existing interval
if (tokenRefreshInterval) {
clearInterval(tokenRefreshInterval);
}
// Set up auto-refresh interval
tokenRefreshInterval = window.setInterval(async () => {
const currentUser = auth.currentUser;
if (currentUser) {
await refreshUserToken();
} else {
// If no user, stop the refresh timer
stopTokenRefreshTimer();
}
}, TOKEN_REFRESH_INTERVAL);
};
/**
* Stop the automatic token refresh timer
*/
export const stopTokenRefreshTimer = () => {
if (tokenRefreshInterval) {
clearInterval(tokenRefreshInterval);
tokenRefreshInterval = null;
}
};
/**
* Login user with email and password
*/
export const loginWithEmail = async (email: string, password: string) => {
try {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
return {
success: true,
user: userCredential.user,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
user: null,
error: getAuthErrorMessage(authError.code),
};
}
};
/**
* Sign out the current user
* Clears session data, local storage, and stops token refresh
*/
export const logout = async () => {
try {
// Stop token refresh interval
stopTokenRefreshTimer();
// Clear any session-related data from localStorage
localStorage.removeItem('lastActivityTime');
localStorage.removeItem('sessionStartTime');
// Sign out from Firebase
await signOut(auth);
// Clear any other app-specific session data if needed
sessionStorage.clear();
console.log("User logged out successfully");
return { success: true };
} catch (error) {
console.error("Error during logout:", error);
return { success: false, error: (error as Error).message };
}
};
/**
* Send password reset email
*/
export const sendPasswordReset = async (email: string) => {
try {
await sendPasswordResetEmail(auth, email);
return { success: true };
} catch (error) {
const authError = error as AuthError;
return { success: false, error: getAuthErrorMessage(authError.code) };
}
};
/**
* Reset password with code and new password
* Used after user clicks the link in the reset email
*/
export const resetPassword = async (code: string, newPassword: string) => {
try {
await confirmPasswordReset(auth, code, newPassword);
return { success: true };
} catch (error) {
const authError = error as AuthError;
return { success: false, error: getAuthErrorMessage(authError.code) };
}
};
/**
* Subscribe to auth state changes
* Sets up token refresh timer when user logs in, stops it when logs out
* Returns unsubscribe function
*/
export const subscribeToAuthState = (callback: (user: User | null) => void) => {
return onAuthStateChanged(auth, (user) => {
if (user) {
// User logged in - start token refresh
startTokenRefreshTimer();
// Update last activity time
localStorage.setItem('lastActivityTime', Date.now().toString());
localStorage.setItem('sessionStartTime', Date.now().toString());
} else {
// User logged out - stop token refresh
stopTokenRefreshTimer();
}
callback(user);
});
};
/**
* Get current user synchronously
*/
export const getCurrentUser = () => {
return auth.currentUser;
};
/**
* Convert Firebase error codes to user-friendly messages
*/
const getAuthErrorMessage = (errorCode: string): string => {
const errorMessages: Record<string, string> = {
"auth/invalid-email": "Invalid email address format.",
"auth/user-disabled": "This user account has been disabled.",
"auth/user-not-found": "No account found with this email address.",
"auth/wrong-password": "Invalid email or password.",
"auth/invalid-credential": "Invalid email or password.",
"auth/too-many-requests": "Too many login attempts. Please try again later.",
"auth/operation-not-allowed": "Login is currently disabled. Please try again later.",
"auth/network-request-failed": "Network error. Please check your connection.",
"auth/invalid-action-code": "This password reset link is invalid or has expired.",
"auth/expired-action-code": "This password reset link has expired. Please request a new one.",
"auth/weak-password": "Password is too weak. Please choose a stronger password.",
};
return errorMessages[errorCode] || "An error occurred. Please try again.";
};
export { app };

View File

@@ -0,0 +1,78 @@
import { getFirestore, doc, getDoc } from "firebase/firestore";
import { app } from "../features/auth/firebase";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - generated dataconnect types may not be resolvable in this context
import { getUserById } from "@/dataconnect-generated";
export interface UserData {
id: string;
email: string;
fullName?: string;
// role may come back uppercase or lowercase from the backend; treat as optional
userRole?: "admin" | "client" | "vendor" | "ADMIN" | "CLIENT" | "VENDOR";
photoURL?: string;
}
const db = getFirestore(app);
/**
* Fetch user data from DataConnect (fallback to Firestore if needed)
* @param uid - Firebase User UID
* @returns UserData object with role information
*/
export const fetchUserData = async (uid: string): Promise<UserData | null> => {
try {
// Prefer backend dataconnect query for authoritative user role
const { data } = await getUserById({ id: uid });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dataAny = data as any;
if (dataAny && dataAny.user) {
const user = dataAny.user;
return {
id: uid,
email: user.email || "",
fullName: user.fullName,
userRole: user.userRole,
photoURL: user.photoUrl || user.photoURL,
};
}
// Fallback: attempt Firestore lookup if dataconnect didn't return a user
const userDocRef = doc(db, "users", uid);
const userDocSnap = await getDoc(userDocRef);
if (userDocSnap.exists()) {
const data = userDocSnap.data();
return {
id: uid,
email: data.email || "",
fullName: data.fullName,
userRole: data.userRole, // no frontend defaulting
photoURL: data.photoURL,
};
}
return null;
} catch (error) {
console.error("Error fetching user data from DataConnect/Firestore:", error);
throw error;
}
};
/**
* Get the dashboard path for a given user role
* @param userRole - The user's role
* @returns The appropriate dashboard path
*/
export const getDashboardPath = (userRole: string): string => {
const roleMap: Record<string, string> = {
admin: "/dashboard/admin",
client: "/dashboard/client",
vendor: "/dashboard/vendor",
};
return roleMap[userRole.toLowerCase()] || "/dashboard/client";
};

View File

@@ -0,0 +1,15 @@
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "../features/auth/authSlice";
/**
* Redux Store Configuration
* Centralizes all state management for the application
*/
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,40 @@
/**
* Authentication Type Definitions
* Defines types for auth-related operations and state management
*/
export interface LoginCredentials {
email: string;
password: string;
}
export interface AuthResponse {
success: boolean;
user: AuthUser | null;
error: string | null;
}
export interface AuthUser {
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
role?: string;
}
export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
export interface PasswordResetResponse {
success: boolean;
error?: string;
}
export interface LogoutResponse {
success: boolean;
error?: string;
}

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

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

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +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"),
},
},
})

View File

@@ -3,3 +3,9 @@ generate:
dartSdk:
- outputDir: ../../../apps/mobile/packages/data_connect/lib/src/dataconnect_generated
package: dataconnect_generated/generated.dart
javascriptSdk:
- react: true
angular: false
outputDir: ../../../apps/web/src/dataconnect-generated
package: "@dataconnect/generated"
packageJsonDir: ../../../apps/web

View File

@@ -18,7 +18,7 @@
},
{
"target": "app-dev",
"public": "apps/web-dashboard/dist",
"public": "apps/web/dist",
"ignore": [
"firebase.json",
"**/.*",
@@ -33,7 +33,7 @@
},
{
"target": "app-staging",
"public": "apps/web-dashboard/dist",
"public": "apps/web/dist",
"ignore": [
"firebase.json",
"**/.*",

View File

@@ -1,21 +1,45 @@
# --- Core Web Development ---
# --- Web App Development ---
.PHONY: install dev build deploy-app
.PHONY: web-install web-info web-dev web-build web-lint web-preview web-deploy
install:
WEB_DIR := apps/web
# --- General ---
web-install:
@echo "--> Installing web frontend dependencies..."
@cd apps/web-dashboard && npm install
@cd $(WEB_DIR) && pnpm install
dev:
@echo "--> Ensuring web frontend dependencies are installed..."
@cd apps/web-dashboard && npm install
@echo "--> Starting web frontend development server on http://localhost:5173 ..."
@cd apps/web-dashboard && npm run dev
web-info:
@echo "--> Web App Commands:"
@echo " make web-install - Install dependencies"
@echo " make web-dev - Start dev server"
@echo " make web-build - Build for production (ENV=dev|staging)"
@echo " make web-lint - Run linter"
@echo " make web-preview - Preview production build"
@echo " make web-deploy - Build and deploy (ENV=dev|staging)"
build:
@echo "--> Building web frontend for production..."
@cd apps/web-dashboard && VITE_APP_ENV=$(ENV) npm run build
web-dev:
@echo "--> Starting web frontend development server..."
@cd $(WEB_DIR) && pnpm dev
deploy-app: build
@echo "--> Deploying Frontend Web App to [$(ENV)] environment..."
web-build:
@echo "--> Building web frontend for [$(ENV)] environment..."
@cd $(WEB_DIR) && pnpm build -- --mode $(ENV)
web-lint:
@echo "--> Linting web frontend..."
@cd $(WEB_DIR) && pnpm lint
web-preview:
@echo "--> Previewing web frontend build..."
@cd $(WEB_DIR) && pnpm preview
web-deploy: web-build
@echo "--> Deploying Web App to [$(ENV)] environment..."
@firebase deploy --only hosting:$(HOSTING_TARGET) --project=$(FIREBASE_ALIAS)
# Aliases for root level access
install: web-install
dev: web-dev
build: web-build
deploy-app: web-deploy