feat: Integrate Data Connect and Implement Staff List View Directory
This commit is contained in:
@@ -125,7 +125,7 @@ const Login: React.FC = () => {
|
||||
// Dispatch Redux action to handle login
|
||||
dispatch(loginUser({ email, password }));
|
||||
};
|
||||
|
||||
console.log(user);
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50/50">
|
||||
{/* Left Side: Hero Image (Hidden on Mobile) */}
|
||||
@@ -280,32 +280,6 @@ const Login: React.FC = () => {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Helper for MVP Testing / Demo purposes */}
|
||||
<div className="mt-8 p-6 bg-slate-50/80 rounded-2xl border border-slate-100 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-bold text-muted-text uppercase tracking-widest mb-4">
|
||||
Quick Login (Development)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Admin", email: "admin@krow.com" },
|
||||
{ label: "Client", email: "client@krow.com" },
|
||||
{ label: "Vendor", email: "vendor@krow.com" },
|
||||
].map((cred) => (
|
||||
<button
|
||||
key={cred.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail(cred.email);
|
||||
setPassword("password");
|
||||
}}
|
||||
className="px-4 py-2 bg-white border border-slate-200 text-[11px] font-bold rounded-xl hover:border-primary hover:text-primary transition-all"
|
||||
>
|
||||
{cred.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface AuthUser {
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
userRole?: "admin" | "client" | "vendor";
|
||||
userRole?: "admin" | "client" | "vendor" | "ADMIN" | "CLIENT" | "VENDOR";
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -43,24 +43,26 @@ export const loginUser = createAsyncThunk(
|
||||
|
||||
const firebaseUser = result.user as User;
|
||||
|
||||
// Fetch user role from Firestore
|
||||
let userRole: "admin" | "client" | "vendor" = "client";
|
||||
// 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) {
|
||||
userRole = userData.userRole;
|
||||
if (userData && userData.userRole) {
|
||||
userRole = userData.userRole as AuthUser['userRole'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user role:", error);
|
||||
// Continue with default role on 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: userRole,
|
||||
userRole,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -82,20 +84,22 @@ export const logoutUser = createAsyncThunk("auth/logoutUser", async (_, { reject
|
||||
* 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 (_, { rejectWithValue }) => {
|
||||
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async () => {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (currentUser) {
|
||||
// Fetch user role from Firestore
|
||||
let userRole: "admin" | "client" | "vendor" = "client";
|
||||
// Fetch user role from backend (DataConnect) or fallback (Firestore)
|
||||
let userRole: AuthUser['userRole'] = undefined;
|
||||
try {
|
||||
const userData = await fetchUserData(currentUser.uid);
|
||||
if (userData) {
|
||||
userRole = userData.userRole;
|
||||
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);
|
||||
// Continue with default role on error
|
||||
// Do not apply a frontend default role
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -103,7 +107,7 @@ export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async (_
|
||||
email: currentUser.email,
|
||||
displayName: currentUser.displayName,
|
||||
photoURL: currentUser.photoURL,
|
||||
userRole: userRole,
|
||||
userRole,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
|
||||
@@ -17,7 +19,9 @@ const firebaseConfig = {
|
||||
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
|
||||
};
|
||||
|
||||
|
||||
// Initialize Firebase
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
export const analytics = getAnalytics(app);
|
||||
export const dataConnect = getDataConnect(app, connectorConfig);
|
||||
export const auth = getAuth(app);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RootState } from '../../store/store';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles: Array<'admin' | 'client' | 'vendor'>;
|
||||
allowedRoles: Array<'admin' | 'client' | 'vendor' | 'ADMIN' | 'CLIENT' | 'VENDOR'>;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
}
|
||||
|
||||
// If user is authenticated but role is not allowed, redirect to specified path
|
||||
if (user?.userRole && !allowedRoles.includes(user.userRole)) {
|
||||
// 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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
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";
|
||||
@@ -6,11 +6,29 @@ import { Badge } from "../../../common/components/ui/badge";
|
||||
import { UserPlus, Users, Star, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||
import { workforceService } from "@/services/workforceService";
|
||||
import type { Staff, User } from "../type";
|
||||
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");
|
||||
@@ -18,28 +36,11 @@ export default function StaffList() {
|
||||
const [ratingRange, setRatingRange] = useState<[number, number]>([0, 5]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [staff, setStaff] = useState<Staff[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { data: staffData, isLoading } = useListStaff(dataConnect);
|
||||
|
||||
console.log(user);
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const currentUser = await workforceService.auth.me();
|
||||
setUser(currentUser);
|
||||
|
||||
const staffList = await workforceService.entities.Staff.list('-created_date');
|
||||
setStaff(staffList);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
const staff = useMemo(() => {
|
||||
return staffData?.staffs || [];
|
||||
}, [staffData]);
|
||||
|
||||
const allSkills = useMemo(() => {
|
||||
const skillSet = new Set<string>();
|
||||
@@ -54,10 +55,10 @@ export default function StaffList() {
|
||||
const filteredStaff = useMemo(() => {
|
||||
return staff.filter(member => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
member.fullName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
member.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || member.status?.toLowerCase() === statusFilter.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)));
|
||||
@@ -97,22 +98,6 @@ export default function StaffList() {
|
||||
}
|
||||
};
|
||||
|
||||
const getLastActiveText = (lastActive?: string) => {
|
||||
if (!lastActive) return 'Never';
|
||||
const date = new Date(lastActive);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Staff Directory"
|
||||
@@ -174,7 +159,7 @@ export default function StaffList() {
|
||||
<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">
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -208,7 +193,6 @@ export default function StaffList() {
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground block text-center">{ratingRange[0].toFixed(1)} - {ratingRange[1].toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,21 +290,21 @@ export default function StaffList() {
|
||||
>
|
||||
<td className="py-4 px-6">
|
||||
<Link to={`/staff/${member.id}/edit`} className="font-bold text-foreground group-hover:text-primary transition-colors hover:underline">
|
||||
{member.employee_name || 'N/A'}
|
||||
{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.photo ? (
|
||||
<img src={member.photo} alt={member.employee_name} className="w-full h-full rounded-xl object-cover" />
|
||||
{member.photoUrl ? (
|
||||
<img src={member.photoUrl} alt={member.fullName} className="w-full h-full rounded-xl object-cover" />
|
||||
) : (
|
||||
member.employee_name?.charAt(0) || '?'
|
||||
member.fullName?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
<Badge className={`${getStatusColor(member.status)} font-black text-xs border`}>
|
||||
{member.status || 'Active'}
|
||||
<Badge className={`${getStatusColor('Active')} font-black text-xs border`}>
|
||||
Active
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
@@ -348,9 +332,9 @@ export default function StaffList() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-sm text-muted-foreground">
|
||||
{getLastActiveText(member.last_active)}
|
||||
<StaffActiveStatus staffId={member.id} />
|
||||
</td>
|
||||
</motion.tr>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -49,7 +49,7 @@ const AppRoutes: React.FC = () => {
|
||||
element={
|
||||
<ProtectedRoute
|
||||
allowedRoles={['admin']}
|
||||
redirectTo="/dashboard/client"
|
||||
redirectTo="/dashboard/admin"
|
||||
>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
@@ -71,7 +71,7 @@ const AppRoutes: React.FC = () => {
|
||||
element={
|
||||
<ProtectedRoute
|
||||
allowedRoles={['vendor']}
|
||||
redirectTo="/dashboard/client"
|
||||
redirectTo="/dashboard/vendor"
|
||||
>
|
||||
<VendorDashboard />
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
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;
|
||||
userRole: "admin" | "client" | "vendor";
|
||||
// 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 Firestore including their role
|
||||
* 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);
|
||||
|
||||
@@ -27,14 +50,14 @@ export const fetchUserData = async (uid: string): Promise<UserData | null> => {
|
||||
id: uid,
|
||||
email: data.email || "",
|
||||
fullName: data.fullName,
|
||||
userRole: data.userRole || "client",
|
||||
userRole: data.userRole, // no frontend defaulting
|
||||
photoURL: data.photoURL,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data from Firestore:", error);
|
||||
console.error("Error fetching user data from DataConnect/Firestore:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user