+
+
+
+ {staff.initial || getInitials(staff.employee_name)}
+
+
+
= 80 ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
+
+
+
+
+ {staff.employee_name}
+
+
+
+ {staff.position || 'Professional Staff'}
+
+
+
+
+
navigate(`/staff/${staff.id}/edit`)}
+ className="rounded-full hover:bg-primary/10 hover:text-primary transition-premium border border-transparent hover:border-primary/20"
+ >
+
+
+
+
+ {/* Reliability & Rating */}
+
+
+ {renderStars(rating)}
+ {rating.toFixed(1)}
+
+
+
+ {reliability.icon}
+ {reliabilityScore}% Reliable
+
+
+
+ {/* Stats & Professional Details */}
+
+ {[
+ { label: 'Coverage', value: `${coveragePercentage}%`, icon:
, color: 'emerald' },
+ { label: 'Cancels', value: cancellationCount, icon:
, color: cancellationCount === 0 ? 'emerald' : 'rose' },
+ { label: 'No Shows', value: noShowCount, icon:
, color: noShowCount === 0 ? 'emerald' : 'rose' },
+ staff.profile_type && { label: 'Level', value: staff.profile_type, icon:
, color: 'primary' },
+ staff.english && { label: 'English', value: staff.english, icon:
, color: staff.english === 'Fluent' ? 'emerald' : 'blue' },
+ staff.invoiced && { label: 'Status', value: 'Verified', icon:
, color: 'emerald' },
+ ].filter(Boolean).map((item: any, i) => (
+
+
+
+ {item.icon}
+
+
{item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+ {/* Contact Details with Glass effect */}
+
+ {[
+ { icon:
, text: staff.email, visible: !!staff.email },
+ { icon:
, text: staff.contact_number, visible: !!staff.contact_number },
+ { icon:
, text: staff.hub_location, visible: !!staff.hub_location },
+ { icon:
, text: `Updated ${formatDate(staff.check_in || '')}`, visible: !!staff.check_in },
+ ].filter(d => d.visible).map((detail, i) => (
+
+
+ {detail.icon}
+
+
{detail.text}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/workforce/directory/components/FilterBar.tsx b/apps/web/src/features/workforce/directory/components/FilterBar.tsx
new file mode 100644
index 00000000..f5bebf61
--- /dev/null
+++ b/apps/web/src/features/workforce/directory/components/FilterBar.tsx
@@ -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 (
+
+
setSearchTerm(e.target.value)}
+ leadingIcon={
}
+ className="flex-1"
+ />
+
+
+
+ }
+ >
+
+
+
+ All Departments
+ Operations
+ Sales
+ HR
+ Finance
+ IT
+ Marketing
+ Customer Service
+ Logistics
+
+
+
+
+ }
+ >
+
+
+
+ All Locations
+ {locations.map(location => (
+
+ {location}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/workforce/directory/components/StaffForm.tsx b/apps/web/src/features/workforce/directory/components/StaffForm.tsx
new file mode 100644
index 00000000..dbc5095f
--- /dev/null
+++ b/apps/web/src/features/workforce/directory/components/StaffForm.tsx
@@ -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
) => Promise;
+ 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("overview");
+
+ const { register, handleSubmit, control, reset } = useForm({
+ 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: },
+ { id: "documents", label: "Documents", icon: },
+ { id: "work_history", label: "Work History", icon: },
+ { id: "compliance", label: "Compliance", icon: },
+ ];
+
+ return (
+
+ );
+}
+
+const TrendingUp = ({ className }: { className?: string }) => (
+
+);
\ No newline at end of file
diff --git a/apps/web/src/features/workforce/directory/components/TabContent.tsx b/apps/web/src/features/workforce/directory/components/TabContent.tsx
new file mode 100644
index 00000000..19087bc4
--- /dev/null
+++ b/apps/web/src/features/workforce/directory/components/TabContent.tsx
@@ -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) => (
+
+
+
+
+
+ {icon}
+
+ {title}
+
+
+
+ {children}
+
+
+ {footer}
+
+);
diff --git a/apps/web/src/features/workforce/type.ts b/apps/web/src/features/workforce/type.ts
new file mode 100644
index 00000000..d57b4322
--- /dev/null
+++ b/apps/web/src/features/workforce/type.ts
@@ -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 }[];
+}
diff --git a/apps/web/src/hooks/useSessionPersistence.ts b/apps/web/src/hooks/useSessionPersistence.ts
new file mode 100644
index 00000000..c16bcc75
--- /dev/null
+++ b/apps/web/src/hooks/useSessionPersistence.ts
@@ -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();
+ 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]);
+};
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 00000000..c5d95a9b
--- /dev/null
+++ b/apps/web/src/index.css
@@ -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);
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
new file mode 100644
index 00000000..74c715a6
--- /dev/null
+++ b/apps/web/src/lib/utils.ts
@@ -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))
+}
\ No newline at end of file
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -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(
+
+
+ ,
+)
diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
new file mode 100644
index 00000000..536f8a59
--- /dev/null
+++ b/apps/web/src/routes.tsx
@@ -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 (
+
+
+ {/* Public Routes */}
+
+
+
+ }
+ />
+
+ }
+ />
+ {/* Authenticated Routes */}
+ }>
+ {/* Dashboard Redirect Logic - redirects to user's correct dashboard based on role */}
+ } />
+
+ {/* Protected Dashboard Routes */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ {/* Workforce Routes */}
+ } />
+ } />
+ } />
+
+ } />
+
+
+ );
+};
+
+
+export default AppRoutes;
diff --git a/apps/web/src/services/authService.ts b/apps/web/src/services/authService.ts
new file mode 100644
index 00000000..243508bb
--- /dev/null
+++ b/apps/web/src/services/authService.ts
@@ -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 - true if refresh successful, false otherwise
+ */
+export const refreshUserToken = async (): Promise => {
+ 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 = {
+ "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 };
+
diff --git a/apps/web/src/services/firestoreService.ts b/apps/web/src/services/firestoreService.ts
new file mode 100644
index 00000000..6b4c8199
--- /dev/null
+++ b/apps/web/src/services/firestoreService.ts
@@ -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 => {
+ 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 = {
+ admin: "/dashboard/admin",
+ client: "/dashboard/client",
+ vendor: "/dashboard/vendor",
+ };
+
+ return roleMap[userRole.toLowerCase()] || "/dashboard/client";
+};
diff --git a/apps/web/src/store/store.ts b/apps/web/src/store/store.ts
new file mode 100644
index 00000000..4c8ac146
--- /dev/null
+++ b/apps/web/src/store/store.ts
@@ -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;
+export type AppDispatch = typeof store.dispatch;
diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts
new file mode 100644
index 00000000..cdcc3709
--- /dev/null
+++ b/apps/web/src/types/auth.ts
@@ -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;
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 00000000..da4786cc
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -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;
\ No newline at end of file
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
new file mode 100644
index 00000000..ea64ed3b
--- /dev/null
+++ b/apps/web/tsconfig.app.json
@@ -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"]
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
new file mode 100644
index 00000000..8a67f62f
--- /dev/null
+++ b/apps/web/tsconfig.node.json
@@ -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"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 00000000..641af15a
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -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"),
+ },
+ },
+})
diff --git a/backend/dataconnect/connector/connector.yaml b/backend/dataconnect/connector/connector.yaml
index 1cf85dcd..9e6e0943 100644
--- a/backend/dataconnect/connector/connector.yaml
+++ b/backend/dataconnect/connector/connector.yaml
@@ -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
diff --git a/firebase.json b/firebase.json
index c9b664b4..887856f8 100644
--- a/firebase.json
+++ b/firebase.json
@@ -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",
"**/.*",
diff --git a/makefiles/web.mk b/makefiles/web.mk
index 0ddeefcf..4e4ba6b7 100644
--- a/makefiles/web.mk
+++ b/makefiles/web.mk
@@ -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