Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 11 additions & 27 deletions frontend/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -69,22 +67,21 @@ const ROLE_LABELS: Record<string, string> = {
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)
Expand All @@ -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 <AppLoader />
}

// 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 (
<div className="flex min-h-screen bg-gray-50">
Expand Down Expand Up @@ -214,7 +198,7 @@ export default function AppShell() {
{/* User info */}
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-gray-900 truncate">
{user.email}
{user?.email}
</p>
<p className="text-xs text-gray-400">{roleLabel}</p>
</div>
Expand Down Expand Up @@ -254,7 +238,7 @@ export default function AppShell() {
</div>
</header>

{/* Page content Outlet renders the active route */}
{/* Page content - Outlet renders the active route */}
<main className="flex-1 p-6 md:p-8">
<Outlet />
</main>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/features/dashboard/dashboardApi.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardFeed>('/api/dashboard'),

checkOut: (visitorId: string) =>
apiClient.patch<VisitorRow>(`/api/visitors/${visitorId}/checkout`),
}
146 changes: 146 additions & 0 deletions frontend/src/features/dashboard/useDashboard.ts
Original file line number Diff line number Diff line change
@@ -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<void>
checkOut: (visitorId: string) => Promise<void>
checkingOutId: string | null
}

export function useDashboard(): UseDashboardReturn {
const [data, setData] = useState<DashboardFeed | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const [checkingOutId, setCheckingOutId] = useState<string | null>(null)

const isMountedRef = useRef(true)
const dataRef = useRef<DashboardFeed | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | 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,
}
}
Loading
Loading