diff --git a/frontend/src/auth/jwt.ts b/frontend/src/auth/jwt.ts index e649733..f7c99bf 100644 --- a/frontend/src/auth/jwt.ts +++ b/frontend/src/auth/jwt.ts @@ -15,7 +15,7 @@ export function parseJwt(token: string): JwtPayload { export function isTokenExpired(token: string): boolean { try { const { exp } = parseJwt(token); - // treat as expired 30 seconds early to avoid edge cases + // treating token as expired 30 seconds early to avoid edge cases return Date.now() / 1000 >= exp - 30; } catch { return true; diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index ad6e6bf..33d4c56 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,9 +1,7 @@ -// src/components/layout/AppShell.tsx import { useState, useEffect } from 'react' import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { useAuth } from '../../auth/AuthContext' import { ROUTE_PATHS } from '../../router/constants' -import { AppLoader } from '../AppLoader' import { LayoutDashboard, UserPlus, @@ -69,22 +67,21 @@ const ROLE_LABELS: Record = { STAFF: 'Staff', } -function getInitials(email: string): string { - // Add defensive check even here - if (!email) return '??' - const parts = email.split('@')[0].split(/[._-]/) +function getInitials(email: string | undefined | null): string { + if (!email) return "?" + const parts = email.split("@")[0].split(/[._-]/) return parts .slice(0, 2) - .map((p) => p[0]?.toUpperCase() ?? '') - .join('') + .map((p) => p[0]?.toUpperCase() ?? "") + .join("") } export default function AppShell() { - const { user, logout, isRole, isLoading } = useAuth() + const { user, logout, isRole } = useAuth() const navigate = useNavigate() const [drawerOpen, setDrawerOpen] = useState(false) - // Close drawer on Escape key + // close drawer on escape useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false) @@ -98,25 +95,12 @@ export default function AppShell() { navigate(ROUTE_PATHS.LOGIN, { replace: true }) } - // Check BOTH isLoading AND user - // The problem: isLoading can be false while user is still null - if (isLoading || !user) { - // If we're still loading or no user, show loader or redirect - if (!user && !isLoading) { - // Not loading but no user = not authenticated - navigate(ROUTE_PATHS.LOGIN, { replace: true }) - return null - } - return - } - - // Safe to use user object now const visibleLinks = NAV_ITEMS.filter((item) => item.roles.some((r) => isRole(r)) ) - const initials = getInitials(user.email) - const roleLabel = ROLE_LABELS[user.role] ?? user.role + const initials = getInitials(user?.email) + const roleLabel = user ? (ROLE_LABELS[user.role] ?? user.role) : "" return (
@@ -214,7 +198,7 @@ export default function AppShell() { {/* User info */}

- {user.email} + {user?.email}

{roleLabel}

@@ -254,7 +238,7 @@ export default function AppShell() {
- {/* Page content — Outlet renders the active route */} + {/* Page content - Outlet renders the active route */}
diff --git a/frontend/src/features/dashboard/dashboardApi.ts b/frontend/src/features/dashboard/dashboardApi.ts new file mode 100644 index 0000000..335d879 --- /dev/null +++ b/frontend/src/features/dashboard/dashboardApi.ts @@ -0,0 +1,42 @@ +import { apiClient } from '../../api/client' + +export interface VisitorSummary { + currentlyOnPremises: number + checkedInToday: number + checkedOutToday: number + overdueCount: number + asOf: string +} + +export interface VisitorRow { + id: string + name: string + phone: string + visitorType: string + purpose: string + status: string + siteId: string + zoneId: string | null + zoneName: string | null + hostId: string | null + hostName: string | null + createdById: string + createdByName: string + checkInTime: string + checkOutTime: string | null +} + +export interface DashboardFeed { + summary: VisitorSummary + activeVisitors: VisitorRow[] + overdueVisitors: VisitorRow[] + recentlyCheckedOut: VisitorRow[] +} + +export const dashboardApi = { + getFeed: () => + apiClient.get('/api/dashboard'), + + checkOut: (visitorId: string) => + apiClient.patch(`/api/visitors/${visitorId}/checkout`), +} \ No newline at end of file diff --git a/frontend/src/features/dashboard/useDashboard.ts b/frontend/src/features/dashboard/useDashboard.ts new file mode 100644 index 0000000..00c4e6c --- /dev/null +++ b/frontend/src/features/dashboard/useDashboard.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { dashboardApi, type DashboardFeed } from './dashboardApi' + +const POLL_INTERVAL_MS = 30_000 + +interface UseDashboardReturn { + data: DashboardFeed | null + isLoading: boolean + isRefreshing: boolean + error: string | null + lastUpdated: Date | null + refresh: () => Promise + checkOut: (visitorId: string) => Promise + checkingOutId: string | null +} + +export function useDashboard(): UseDashboardReturn { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + const [checkingOutId, setCheckingOutId] = useState(null) + + const isMountedRef = useRef(true) + const dataRef = useRef(null) + const intervalRef = useRef | null>(null) + + // keep dataRef in sync so fetchData can read latest data + // without being a dependency of fetchData itself + useEffect(() => { + dataRef.current = data + }, [data]) + + // stable fetch - never recreated, no dependency on data state + const fetchData = useRef(async (silent = false) => { + if (!isMountedRef.current) return + + if (!silent) { + if (dataRef.current) setIsRefreshing(true) + else setIsLoading(true) + } + + try { + const { data: feed } = await dashboardApi.getFeed() + if (!isMountedRef.current) return + setData(feed) + setLastUpdated(new Date()) + setError(null) + } catch { + if (!isMountedRef.current) return + if (!silent) { + setError('Failed to load dashboard. Check your connection.') + } + // on silent failure keep stale data - do not wipe the dashboard + } finally { + if (isMountedRef.current) { + setIsLoading(false) + setIsRefreshing(false) + } + } + }).current + + // initial load - runs once + useEffect(() => { + isMountedRef.current = true + fetchData(false) + return () => { isMountedRef.current = false } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // auto-poll - stable interval, never reset by data changes + useEffect(() => { + intervalRef.current = setInterval(() => fetchData(true), POLL_INTERVAL_MS) + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // refresh tab on visibility change + useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') fetchData(true) + } + document.addEventListener('visibilitychange', onVisibility) + return () => document.removeEventListener('visibilitychange', onVisibility) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const refresh = useCallback(async () => { + await fetchData(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const checkOut = useCallback(async (visitorId: string) => { + setCheckingOutId(visitorId) + try { + await dashboardApi.checkOut(visitorId) + setData((prev) => { + if (!prev) return prev + const wasActive = prev.activeVisitors.find((v) => v.id === visitorId) + const wasOverdue = prev.overdueVisitors.find((v) => v.id === visitorId) + const checkedOut = wasActive ?? wasOverdue + if (!checkedOut) return prev + + const updated = { + ...checkedOut, + status: 'CHECKED_OUT', + checkOutTime: new Date().toISOString(), + } + + return { + ...prev, + summary: { + ...prev.summary, + currentlyOnPremises: Math.max(0, prev.summary.currentlyOnPremises - 1), + checkedOutToday: prev.summary.checkedOutToday + 1, + overdueCount: wasOverdue + ? Math.max(0, prev.summary.overdueCount - 1) + : prev.summary.overdueCount, + }, + activeVisitors: prev.activeVisitors.filter((v) => v.id !== visitorId), + overdueVisitors: prev.overdueVisitors.filter((v) => v.id !== visitorId), + recentlyCheckedOut: [updated, ...prev.recentlyCheckedOut].slice(0, 10), + } + }) + } catch { + await fetchData(true) + } finally { + setCheckingOutId(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { + data, + isLoading, + isRefreshing, + error, + lastUpdated, + refresh, + checkOut, + checkingOutId, + } +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0a85d5d..d874215 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,3 +1,385 @@ +import { useDashboard } from '../features/dashboard/useDashboard' +import type { VisitorRow } from '../features/dashboard/dashboardApi' +import { + Users, + UserCheck, + UserMinus, + AlertTriangle, + RefreshCw, + Clock, + LogOut, + Loader2, +} from 'lucide-react' + export default function Dashboard() { - return
Dashboard — coming soon
; + const { + data, + isLoading, + isRefreshing, + error, + lastUpdated, + refresh, + checkOut, + checkingOutId, + } = useDashboard() + + if (isLoading) return + if (error && !data) return + + const { summary, activeVisitors, overdueVisitors, recentlyCheckedOut } = + data! + + return ( +
+ {/* Page header */} +
+
+

+ Dashboard +

+ {lastUpdated && ( +

+ + Updated {formatTime(lastUpdated)} + +

+ )} +
+ + +
+ + {/* Summary cards */} +
+ } + color="green" + /> + } + color="blue" + /> + } + color="gray" + /> + } + color={summary.overdueCount > 0 ? 'amber' : 'gray'} + highlight={summary.overdueCount > 0} + /> +
+ + {/* Active + Overdue */} +
+ + +
+ + {/* Recently checked out */} + +
+ ) +} + +/* Summary card */ +interface SummaryCardProps { + label: string + value: number + icon: React.ReactNode + color: 'green' | 'blue' | 'gray' | 'amber' + highlight?: boolean +} + +const COLOR_MAP = { + green: { + bg: 'bg-green-50', + icon: 'bg-green-100 text-green-600', + value: 'text-green-700', + }, + blue: { + bg: 'bg-blue-50', + icon: 'bg-blue-100 text-blue-600', + value: 'text-blue-700', + }, + gray: { + bg: 'bg-gray-50', + icon: 'bg-gray-100 text-gray-500', + value: 'text-gray-700', + }, + amber: { + bg: 'bg-amber-50', + icon: 'bg-amber-100 text-amber-600', + value: 'text-amber-700', + }, +} + +function SummaryCard({ label, value, icon, color, highlight }: SummaryCardProps) { + const c = COLOR_MAP[color] + return ( +
+
+

+ {label} +

+ + {icon} + +
+

+ {value} +

+
+ ) +} + +/* Visitor table */ +interface VisitorTableProps { + title: string + visitors: VisitorRow[] + emptyMessage: string + showCheckout: boolean + checkOut?: (id: string) => Promise + checkingOutId?: string | null + overdue?: boolean +} + +function VisitorTable({ + title, + visitors, + emptyMessage, + showCheckout, + checkOut, + checkingOutId, + overdue, +}: VisitorTableProps) { + return ( +
+ {/* Table header */} +
+

+ {title} +

+ 0 + ? 'bg-amber-100 text-amber-700' + : 'bg-gray-100 text-gray-500', + ].join(' ')} + > + {visitors.length} + +
+ + {/* Rows */} + {visitors.length === 0 ? ( +
+ + + +

{emptyMessage}

+
+ ) : ( +
    + {visitors.map((visitor) => ( + + ))} +
+ )} +
+ ) +} + +/* Visitor row */ +interface VisitorRowProps { + visitor: VisitorRow + showCheckout: boolean + onCheckOut?: (id: string) => Promise + isCheckingOut: boolean + overdue?: boolean +} + +function VisitorRow({ + visitor, + showCheckout, + onCheckOut, + isCheckingOut, + overdue, +}: VisitorRowProps) { + const initials = visitor.name + .split(' ') + .slice(0, 2) + .map((n) => n[0]?.toUpperCase() ?? '') + .join('') + + return ( +
  • + {/* Avatar */} +
    + {initials} +
    + + {/* Info */} +
    +

    + {visitor.name} +

    +

    + {visitor.zoneName + ? `${visitor.zoneName} · ` + : ''} + {visitor.visitorType} · {formatTime(new Date(visitor.checkInTime))} +

    +
    + + {/* Overdue badge */} + {overdue && ( + + Overdue + + )} + + {/* Checkout button */} + {showCheckout && onCheckOut && ( + + )} + + {/* Checkout time for recently-checked-out */} + {!showCheckout && visitor.checkOutTime && ( + + + {formatTime(new Date(visitor.checkOutTime))} + + )} +
  • + ) +} + +/* Skeleton */ +function DashboardSkeleton() { + return ( +
    +
    +
    +
    + {Array.from({ length: 4 }).map((_, i) => ( +
    + ))} +
    +
    +
    +
    +
    +
    +
    + ) +} + +/* Error */ +function DashboardError({ + message, + onRetry, +}: { + message: string + onRetry: () => void +}) { + return ( +
    + + + +

    + Failed to load dashboard +

    +

    {message}

    + +
    + ) +} + +/* Helpers */ +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) } \ No newline at end of file diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx index c23ac1c..7d58bf6 100644 --- a/frontend/src/pages/auth/Login.tsx +++ b/frontend/src/pages/auth/Login.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../../auth/AuthContext"; -import { getRoleDestination } from "../../router/constants"; +import { getRoleDestination, isPathAllowedForRole } from "../../router/constants"; import { extractErrorMessage } from "../../utils/errors"; interface LocationState { @@ -22,13 +22,19 @@ export default function Login() { // redirect if already authenticated useEffect(() => { - if (isAuthenticated && user) { - const state = location.state as LocationState; - const destination = - state?.from?.pathname ?? getRoleDestination(user.role); - navigate(destination, { replace: true }); - } - }, [isAuthenticated, user, navigate, location.state]); + if (isAuthenticated && user) { + const state = location.state as LocationState + const roleDest = getRoleDestination(user.role) + const fromPath = state?.from?.pathname + + // only honour the stored path if it is appropriate for this role + const destination = fromPath && isPathAllowedForRole(fromPath, user.role) + ? fromPath + : roleDest + + navigate(destination, { replace: true }) + } +}, [isAuthenticated, user, navigate, location.state]) // focus email on mount useEffect(() => { diff --git a/frontend/src/router/Router.tsx b/frontend/src/router/Router.tsx index 4b0699b..be19619 100644 --- a/frontend/src/router/Router.tsx +++ b/frontend/src/router/Router.tsx @@ -9,8 +9,8 @@ import { ROUTE_PATHS } from './constants' export const router = createBrowserRouter([ // Public - { path: ROUTE_PATHS.ROOT, element: }, - { path: ROUTE_PATHS.LOGIN, element: }, + { path: ROUTE_PATHS.ROOT, element: }, + { path: ROUTE_PATHS.LOGIN, element: }, { path: ROUTE_PATHS.UNAUTHORIZED, element: }, // Authenticated: shell wraps all protected routes @@ -20,7 +20,7 @@ export const router = createBrowserRouter([ { element: , children: [ - // Any role + // any authenticated role { path: ROUTE_PATHS.NEW_VISITOR, lazy: () => import('../pages/visitors/NewVisitor') @@ -32,33 +32,43 @@ export const router = createBrowserRouter([ .then((m) => ({ Component: m.default })), }, - // Manager + Super Admin + // manager + super admin only { - path: ROUTE_PATHS.DASHBOARD, - lazy: () => import('../pages/Dashboard') - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.REPORTS, - lazy: () => import('../pages/Reports') - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.USERS, - lazy: () => import('../pages/users/Users') - .then((m) => ({ Component: m.default })), + element: , + children: [ + { + path: ROUTE_PATHS.DASHBOARD, + lazy: () => import('../pages/Dashboard') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.REPORTS, + lazy: () => import('../pages/Reports') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.USERS, + lazy: () => import('../pages/users/Users') + .then((m) => ({ Component: m.default })), + }, + ], }, - // Super Admin only + // super admin only { - path: ROUTE_PATHS.ADMIN, - lazy: () => import('../pages/users/Admin') - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.SITES, - lazy: () => import('../pages/sites/Sites') - .then((m) => ({ Component: m.default })), + element: , + children: [ + { + path: ROUTE_PATHS.ADMIN, + lazy: () => import('../pages/users/Admin') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.SITES, + lazy: () => import('../pages/sites/Sites') + .then((m) => ({ Component: m.default })), + }, + ], }, ], }, diff --git a/frontend/src/router/constants.ts b/frontend/src/router/constants.ts index 50d28f1..0279463 100644 --- a/frontend/src/router/constants.ts +++ b/frontend/src/router/constants.ts @@ -23,4 +23,17 @@ export function getRoleDestination(role: Role | undefined): string { return role && role in ROLE_DESTINATION_MAP ? ROLE_DESTINATION_MAP[role] : ROUTE_PATHS.LOGIN; +} + +export function isPathAllowedForRole(path: string, role: Role): boolean { + const staffPaths = [ROUTE_PATHS.VISITORS, ROUTE_PATHS.NEW_VISITOR] + const managerPaths = [...staffPaths, ROUTE_PATHS.DASHBOARD, ROUTE_PATHS.REPORTS, ROUTE_PATHS.USERS] + const adminPaths = [...managerPaths, ROUTE_PATHS.ADMIN, ROUTE_PATHS.SITES] + + switch (role) { + case 'STAFF': return staffPaths.some(p => path.startsWith(p)) + case 'MANAGER': return managerPaths.some(p => path.startsWith(p)) + case 'SUPER_ADMIN': return adminPaths.some(p => path.startsWith(p)) + default: return false + } } \ No newline at end of file