Merge pull request #338 from Oloodi/authentication-web
Authentication web
This commit is contained in:
19
Makefile
19
Makefile
@@ -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
24
apps/web/.gitignore
vendored
Normal 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
23
apps/web/eslint.config.js
Normal 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
12
apps/web/index.html
Normal 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
60
apps/web/package.json
Normal 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
5744
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
apps/web/pnpm-workspace.yaml
Normal file
4
apps/web/pnpm-workspace.yaml
Normal 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
32
apps/web/src/App.tsx
Normal 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;
|
||||
BIN
apps/web/src/assets/login-hero.png
Normal file
BIN
apps/web/src/assets/login-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
BIN
apps/web/src/assets/logo.png
Normal file
BIN
apps/web/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
53
apps/web/src/common/components/PageHeader.tsx
Normal file
53
apps/web/src/common/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/common/components/ui/badge.tsx
Normal file
35
apps/web/src/common/components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
83
apps/web/src/common/components/ui/button.tsx
Normal file
83
apps/web/src/common/components/ui/button.tsx
Normal 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 }
|
||||
80
apps/web/src/common/components/ui/card.tsx
Normal file
80
apps/web/src/common/components/ui/card.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
/**
|
||||
* Card component family based on Shadcn UI.
|
||||
* Used for grouping content in a contained area.
|
||||
*/
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-2xl border border-slate-200 bg-white transition-all duration-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-xl font-bold leading-none tracking-tight text-slate-900", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500/90 leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-8 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-8 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
21
apps/web/src/common/components/ui/checkbox.tsx
Normal file
21
apps/web/src/common/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Checkbox.displayName = "Checkbox"
|
||||
|
||||
export { Checkbox }
|
||||
49
apps/web/src/common/components/ui/input.tsx
Normal file
49
apps/web/src/common/components/ui/input.tsx
Normal 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 }
|
||||
16
apps/web/src/common/components/ui/label.tsx
Normal file
16
apps/web/src/common/components/ui/label.tsx
Normal 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 }
|
||||
86
apps/web/src/common/components/ui/select.tsx
Normal file
86
apps/web/src/common/components/ui/select.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../../lib/utils"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
const SelectContext = React.createContext<any>(null);
|
||||
|
||||
export const Select = ({ value, onValueChange, children }: any) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
|
||||
<div className="relative" ref={containerRef}>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectTrigger = ({ className, children, leadingIcon, trailingIcon }: any) => {
|
||||
const { open, setOpen } = React.useContext(SelectContext);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"flex h-12 w-full capitalize items-center gap-3 rounded-xl border border-input bg-background/50 px-4 py-3 text-sm transition-all duration-300 focus:outline-none focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-50 group",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<div className="w-4 h-4 flex items-center justify-center text-muted-foreground/60 group-focus:text-primary transition-colors shrink-0">
|
||||
{leadingIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-left overflow-hidden whitespace-nowrap">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trailingIcon && (
|
||||
<div className="w-4 h-4 flex items-center justify-center text-muted-foreground/60 group-focus:text-primary transition-colors">
|
||||
{trailingIcon}
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className={cn("h-4 w-4 opacity-50 transition-transform duration-200", open && "rotate-180")} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectValue = ({ placeholder }: any) => {
|
||||
const { value } = React.useContext(SelectContext);
|
||||
return <span className="block truncate">{value || placeholder}</span>
|
||||
}
|
||||
|
||||
export const SelectContent = ({ children, className }: any) => {
|
||||
const { open } = React.useContext(SelectContext);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className={cn("absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white text-popover-foreground animate-in fade-in-80 mt-1 w-full max-h-60 overflow-y-auto", className)}>
|
||||
<div className="p-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectItem = ({ value, children, className }: any) => {
|
||||
const { onValueChange, setOpen } = React.useContext(SelectContext);
|
||||
return (
|
||||
<div
|
||||
onClick={() => { onValueChange(value); setOpen(false); }}
|
||||
className={cn("relative capitalize flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-2 text-sm outline-none hover:bg-slate-100 cursor-pointer", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
apps/web/src/common/components/ui/textarea.tsx
Normal file
20
apps/web/src/common/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[120px] w-full rounded-xl border border-input bg-background/50 px-4 py-3 text-sm placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:border-primary focus-visible:bg-background transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
240
apps/web/src/common/config/navigation.ts
Normal file
240
apps/web/src/common/config/navigation.ts
Normal 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
44
apps/web/src/features/auth/AuthInitializer.tsx
Normal file
44
apps/web/src/features/auth/AuthInitializer.tsx
Normal 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;
|
||||
240
apps/web/src/features/auth/ForgotPassword.tsx
Normal file
240
apps/web/src/features/auth/ForgotPassword.tsx
Normal 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;
|
||||
288
apps/web/src/features/auth/Login.tsx
Normal file
288
apps/web/src/features/auth/Login.tsx
Normal 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;
|
||||
185
apps/web/src/features/auth/authSlice.ts
Normal file
185
apps/web/src/features/auth/authSlice.ts
Normal 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;
|
||||
26
apps/web/src/features/auth/firebase.ts
Normal file
26
apps/web/src/features/auth/firebase.ts
Normal 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);
|
||||
8
apps/web/src/features/dashboard/AdminDashboard.tsx
Normal file
8
apps/web/src/features/dashboard/AdminDashboard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
const AdminDashboard = () => {
|
||||
return (
|
||||
<div> Admin Dashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminDashboard
|
||||
9
apps/web/src/features/dashboard/ClientDashboard.tsx
Normal file
9
apps/web/src/features/dashboard/ClientDashboard.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
const ClientDashboard = () => {
|
||||
return (
|
||||
<div>ClientDashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClientDashboard
|
||||
27
apps/web/src/features/dashboard/RoleDashboardRedirect.tsx
Normal file
27
apps/web/src/features/dashboard/RoleDashboardRedirect.tsx
Normal 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;
|
||||
};
|
||||
9
apps/web/src/features/dashboard/VendorDashboard.tsx
Normal file
9
apps/web/src/features/dashboard/VendorDashboard.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
const VendorDashboard = () => {
|
||||
return (
|
||||
<div>VendorDashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VendorDashboard
|
||||
106
apps/web/src/features/layouts/AppLayout.tsx
Normal file
106
apps/web/src/features/layouts/AppLayout.tsx
Normal 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;
|
||||
48
apps/web/src/features/layouts/DashboardLayout.tsx
Normal file
48
apps/web/src/features/layouts/DashboardLayout.tsx
Normal 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;
|
||||
41
apps/web/src/features/layouts/ProtectedRoute.tsx
Normal file
41
apps/web/src/features/layouts/ProtectedRoute.tsx
Normal 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;
|
||||
23
apps/web/src/features/layouts/PublicLayout.tsx
Normal file
23
apps/web/src/features/layouts/PublicLayout.tsx
Normal 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;
|
||||
135
apps/web/src/features/layouts/Sidebar.tsx
Normal file
135
apps/web/src/features/layouts/Sidebar.tsx
Normal 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;
|
||||
210
apps/web/src/features/workforce/directory/AddStaff.tsx
Normal file
210
apps/web/src/features/workforce/directory/AddStaff.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
apps/web/src/features/workforce/directory/EditStaff.tsx
Normal file
226
apps/web/src/features/workforce/directory/EditStaff.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
419
apps/web/src/features/workforce/directory/StaffList.tsx
Normal file
419
apps/web/src/features/workforce/directory/StaffList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Card, CardContent } from "@/common/components/ui/card";
|
||||
import { Badge } from "@/common/components/ui/badge";
|
||||
import { Button } from "@/common/components/ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Mail, Phone, MapPin, Calendar, Edit,
|
||||
Star, TrendingUp, XCircle, CheckCircle, UserX,
|
||||
Shield, Globe, Briefcase
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { Staff } from "../../type";
|
||||
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return "?";
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating || 0);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < fullStars) {
|
||||
stars.push(<Star key={i} className="w-4 h-4 fill-amber-400 text-amber-400" />);
|
||||
} else {
|
||||
stars.push(<Star key={i} className="w-4 h-4 text-muted/40" />);
|
||||
}
|
||||
}
|
||||
return stars;
|
||||
};
|
||||
|
||||
const getReliabilityColor = (score: number) => {
|
||||
if (score >= 90) return {
|
||||
bg: 'bg-emerald-500',
|
||||
text: 'text-emerald-700',
|
||||
bgLight: 'bg-emerald-50/50',
|
||||
border: 'border-emerald-200',
|
||||
icon: <Shield className="w-3 h-3" />
|
||||
};
|
||||
if (score >= 70) return {
|
||||
bg: 'bg-amber-500',
|
||||
text: 'text-amber-700',
|
||||
bgLight: 'bg-amber-50/50',
|
||||
border: 'border-amber-200',
|
||||
icon: <TrendingUp className="w-3 h-3" />
|
||||
};
|
||||
return {
|
||||
bg: 'bg-rose-500',
|
||||
text: 'text-rose-700',
|
||||
bgLight: 'bg-rose-50/50',
|
||||
border: 'border-rose-200',
|
||||
icon: <XCircle className="w-3 h-3" />
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
if (!dateStr) return "N/A";
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch (e) {
|
||||
console.error("Failed to format date", e);
|
||||
return "N/A";
|
||||
}
|
||||
}
|
||||
|
||||
interface EmployeeCardProps {
|
||||
staff: Staff;
|
||||
}
|
||||
|
||||
export default function EmployeeCard({ staff }: EmployeeCardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const coveragePercentage = staff.shift_coverage_percentage || 0;
|
||||
const cancellationCount = staff.cancellation_count || 0;
|
||||
const noShowCount = staff.no_show_count || 0;
|
||||
const rating = staff.rating || 0;
|
||||
const reliabilityScore = staff.reliability_score || 0;
|
||||
const reliability = getReliabilityColor(reliabilityScore);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full backdrop-blur-sm border-border/50 transition-premium overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<CardContent className="p-6 space-y-5 relative z-10">
|
||||
{/* Top Row: Avatar & Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-bold text-xl transform group-hover:scale-105 transition-premium border border-primary/20">
|
||||
{staff.initial || getInitials(staff.employee_name)}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-background border-2 border-border rounded-full flex items-center justify-center">
|
||||
<div className={`w-2 h-2 rounded-full ${coveragePercentage >= 80 ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors">
|
||||
{staff.employee_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Briefcase className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="text-sm font-medium">{staff.position || 'Professional Staff'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/staff/${staff.id}/edit`)}
|
||||
className="rounded-full hover:bg-primary/10 hover:text-primary transition-premium border border-transparent hover:border-primary/20"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reliability & Rating */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderStars(rating)}
|
||||
<span className="text-sm font-bold text-foreground/80 ml-1">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-1 px-3 py-1 rounded-full border ${reliability.border} ${reliability.bgLight} ${reliability.text} text-[10px] font-bold uppercase tracking-wider`}>
|
||||
{reliability.icon}
|
||||
{reliabilityScore}% Reliable
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats & Professional Details */}
|
||||
<div className="space-y-1 py-1">
|
||||
{[
|
||||
{ label: 'Coverage', value: `${coveragePercentage}%`, icon: <TrendingUp className="w-4 h-4" />, color: 'emerald' },
|
||||
{ label: 'Cancels', value: cancellationCount, icon: <XCircle className="w-4 h-4" />, color: cancellationCount === 0 ? 'emerald' : 'rose' },
|
||||
{ label: 'No Shows', value: noShowCount, icon: <UserX className="w-4 h-4" />, color: noShowCount === 0 ? 'emerald' : 'rose' },
|
||||
staff.profile_type && { label: 'Level', value: staff.profile_type, icon: <Shield className="w-4 h-4" />, color: 'primary' },
|
||||
staff.english && { label: 'English', value: staff.english, icon: <Globe className="w-4 h-4" />, color: staff.english === 'Fluent' ? 'emerald' : 'blue' },
|
||||
staff.invoiced && { label: 'Status', value: 'Verified', icon: <CheckCircle className="w-4 h-4" />, color: 'emerald' },
|
||||
].filter(Boolean).map((item: any, i) => (
|
||||
<div key={i} className="flex items-center justify-between group/item p-1 rounded-xl hover:bg-muted/40 transition-premium border border-transparent hover:border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-premium group-hover/item:scale-110 border border-transparent group-hover/item:border-border/50 ${item.color === 'emerald' ? 'bg-emerald-500/10 text-emerald-600' :
|
||||
item.color === 'rose' ? 'bg-rose-500/10 text-rose-600' :
|
||||
item.color === 'primary' ? 'bg-primary/10 text-primary' :
|
||||
'bg-blue-500/10 text-blue-600'
|
||||
}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">{item.label}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`font-black text-[10px] h-6 px-2.5 border-transparent ${item.color === 'emerald' ? 'bg-emerald-500/10 text-emerald-700' :
|
||||
item.color === 'rose' ? 'bg-rose-500/10 text-rose-700' :
|
||||
item.color === 'primary' ? 'bg-primary/10 text-primary' :
|
||||
'bg-blue-500/10 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contact Details with Glass effect */}
|
||||
<div className="space-y-1 pt-4 border-t border-border/40 relative">
|
||||
{[
|
||||
{ icon: <Mail className="w-4 h-4" />, text: staff.email, visible: !!staff.email },
|
||||
{ icon: <Phone className="w-4 h-4" />, text: staff.contact_number, visible: !!staff.contact_number },
|
||||
{ icon: <MapPin className="w-4 h-4" />, text: staff.hub_location, visible: !!staff.hub_location },
|
||||
{ icon: <Calendar className="w-4 h-4" />, text: `Updated ${formatDate(staff.check_in || '')}`, visible: !!staff.check_in },
|
||||
].filter(d => d.visible).map((detail, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground group/detail cursor-pointer hover:text-foreground transition-colors text-ellipsis overflow-hidden">
|
||||
<div className="w-8 h-8 shrink-0 rounded-lg bg-secondary/50 flex items-center justify-center group-hover/detail:bg-primary/10 group-hover/detail:text-primary transition-premium text-ellipsis">
|
||||
{detail.icon}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-medium">{detail.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Input } from "../../../../common/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../common/components/ui/select";
|
||||
import { Search, MapPin, Briefcase } from "lucide-react";
|
||||
|
||||
interface FilterBarProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
departmentFilter: string;
|
||||
setDepartmentFilter: (value: string) => void;
|
||||
locationFilter: string;
|
||||
setLocationFilter: (value: string) => void;
|
||||
departments?: string[];
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
departmentFilter,
|
||||
setDepartmentFilter,
|
||||
locationFilter,
|
||||
setLocationFilter,
|
||||
locations
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col shadow-none lg:flex-row items-center gap-4 w-full">
|
||||
<Input
|
||||
placeholder="Search staff by name, role, or manager..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leadingIcon={<Search />}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 w-full lg:w-auto">
|
||||
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-48 font-bold text-xs uppercase tracking-wider"
|
||||
leadingIcon={<Briefcase />}
|
||||
>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-2xl border-border glass">
|
||||
<SelectItem value="all" className="font-bold text-xs uppercase cursor-pointer">All Departments</SelectItem>
|
||||
<SelectItem value="Operations" className="font-bold text-xs uppercase cursor-pointer">Operations</SelectItem>
|
||||
<SelectItem value="Sales" className="font-bold text-xs uppercase cursor-pointer">Sales</SelectItem>
|
||||
<SelectItem value="HR" className="font-bold text-xs uppercase cursor-pointer">HR</SelectItem>
|
||||
<SelectItem value="Finance" className="font-bold text-xs uppercase cursor-pointer">Finance</SelectItem>
|
||||
<SelectItem value="IT" className="font-bold text-xs uppercase cursor-pointer">IT</SelectItem>
|
||||
<SelectItem value="Marketing" className="font-bold text-xs uppercase cursor-pointer">Marketing</SelectItem>
|
||||
<SelectItem value="Customer Service" className="font-bold text-xs uppercase cursor-pointer">Customer Service</SelectItem>
|
||||
<SelectItem value="Logistics" className="font-bold text-xs uppercase cursor-pointer">Logistics</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={locationFilter} onValueChange={setLocationFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-48 font-bold text-xs uppercase tracking-wider"
|
||||
leadingIcon={<MapPin />}
|
||||
>
|
||||
<SelectValue placeholder="Location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-2xl border-border glass">
|
||||
<SelectItem value="all" className="font-bold text-xs uppercase cursor-pointer">All Locations</SelectItem>
|
||||
{locations.map(location => (
|
||||
<SelectItem key={location} value={location} className="font-bold text-xs uppercase cursor-pointer">
|
||||
{location}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/common/components/ui/card";
|
||||
|
||||
interface TabContentProps {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabContent = ({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
footer
|
||||
}: TabContentProps) => (
|
||||
<motion.div
|
||||
key={id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card className="glass border-border/50! overflow-hidden rounded-3xl">
|
||||
<CardHeader className="bg-secondary/30 border-b border-border/40 p-6">
|
||||
<CardTitle className="text-xl font-bold! flex items-center gap-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={className}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{footer}
|
||||
</motion.div>
|
||||
);
|
||||
71
apps/web/src/features/workforce/type.ts
Normal file
71
apps/web/src/features/workforce/type.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface Staff {
|
||||
id?: string;
|
||||
vendor_id?: string;
|
||||
vendor_name?: string;
|
||||
created_by?: string;
|
||||
created_date?: string;
|
||||
|
||||
// Basic Info
|
||||
employee_name: string;
|
||||
initial?: string;
|
||||
position?: string; // Primary Skill
|
||||
position_2?: string; // Secondary Skill
|
||||
profile_type?: string; // Skill Level
|
||||
employment_type?: string;
|
||||
manager?: string;
|
||||
email?: string;
|
||||
contact_number?: string;
|
||||
phone?: string; // Additional Phone
|
||||
photo?: string; // Photo URL
|
||||
status?: string; // 'Active' | 'Pending' | 'Suspended'
|
||||
skills?: string[]; // Array of skills
|
||||
|
||||
// Performance
|
||||
averageRating?: number;
|
||||
reliability_score?: number;
|
||||
shift_coverage_percentage?: number;
|
||||
cancellation_count?: number;
|
||||
no_show_count?: number;
|
||||
total_shifts?: number;
|
||||
invoiced?: boolean;
|
||||
last_active?: string; // ISO timestamp of last activity
|
||||
|
||||
// Location & Dept
|
||||
department?: string;
|
||||
city?: string;
|
||||
hub_location?: string;
|
||||
event_location?: string;
|
||||
track?: string;
|
||||
address?: string;
|
||||
|
||||
// Lang & Schedule
|
||||
english?: string;
|
||||
english_required?: boolean;
|
||||
check_in?: string;
|
||||
schedule_days?: string;
|
||||
|
||||
// Other
|
||||
replaced_by?: string;
|
||||
action?: string;
|
||||
ro?: string;
|
||||
mon?: string;
|
||||
notes?: string;
|
||||
accounting_comments?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string; // 'admin' | 'client' | 'vendor' | 'workforce' | 'operator' | 'procurement' | 'sector'
|
||||
user_role?: string; // MVP uses both sometimes
|
||||
company_name?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
client_email?: string;
|
||||
business_name?: string;
|
||||
created_by?: string;
|
||||
assigned_staff?: { staff_id: string }[];
|
||||
}
|
||||
127
apps/web/src/hooks/useSessionPersistence.ts
Normal file
127
apps/web/src/hooks/useSessionPersistence.ts
Normal 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
162
apps/web/src/index.css
Normal 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
13
apps/web/src/lib/utils.ts
Normal 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
10
apps/web/src/main.tsx
Normal 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
92
apps/web/src/routes.tsx
Normal 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;
|
||||
211
apps/web/src/services/authService.ts
Normal file
211
apps/web/src/services/authService.ts
Normal 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 };
|
||||
|
||||
78
apps/web/src/services/firestoreService.ts
Normal file
78
apps/web/src/services/firestoreService.ts
Normal 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";
|
||||
};
|
||||
15
apps/web/src/store/store.ts
Normal file
15
apps/web/src/store/store.ts
Normal 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;
|
||||
40
apps/web/src/types/auth.ts
Normal file
40
apps/web/src/types/auth.ts
Normal 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
101
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
body: ['Instrument Sans', 'sans-serif'],
|
||||
headline: ['Instrument Sans', 'sans-serif'],
|
||||
code: ['monospace'],
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))',
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
'primary-text': 'hsl(var(--text-primary))',
|
||||
'secondary-text': 'hsl(var(--text-secondary))',
|
||||
'muted-text': 'hsl(var(--text-muted))',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: '0',
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
},
|
||||
'accordion-up': {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
to: {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
} satisfies Config;
|
||||
34
apps/web/tsconfig.app.json
Normal file
34
apps/web/tsconfig.app.json
Normal 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
7
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/web/tsconfig.node.json
Normal file
26
apps/web/tsconfig.node.json
Normal 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
15
apps/web/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
"**/.*",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user