diff --git a/README.md b/README.md index c010812..b0665d0 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,26 @@ The project is built to demonstrate end-to-end product engineering: system desig └─────────────────────────────────────────┘ ``` +``` + GATELOG + + Authentication + │ + ▼ + Authorization + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + Users Visitors Sites + │ │ + ▼ ▼ + Zones Dashboard + │ │ + └──────────┬───────┘ + ▼ + Reports +``` + --- ## Data Model diff --git a/compose.yaml b/compose.yaml index d398691..7cc4648 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,6 @@ services: postgres: image: postgres:16-alpine - container_name: gatelog environment: POSTGRES_DB: gatelog POSTGRES_USER: postgres diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 20baa15..1e4f8a4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,7 +9,7 @@ export const apiClient = axios.create({ timeout: 15_000, }); -// ── Request interceptor — inject access token ────────────────────────────── +// Request interceptor - inject access token apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = tokenStorage.getAccess(); @@ -21,7 +21,7 @@ apiClient.interceptors.request.use( (error) => Promise.reject(error) ); -// ── Response interceptor — silent token refresh on 401 ──────────────────── +// Response interceptor - silent token refresh on 401 let isRefreshing = false; let refreshQueue: Array<{ resolve: (token: string) => void; diff --git a/frontend/src/features/admin/adminApi.ts b/frontend/src/features/admin/adminApi.ts new file mode 100644 index 0000000..3021896 --- /dev/null +++ b/frontend/src/features/admin/adminApi.ts @@ -0,0 +1,27 @@ +import { apiClient } from "../../api/client" + +export interface Site { + id: string, + name: string, + location: string +} + +export interface SiteRequest { + name: string, + location: string +} + +export interface AdminStats { + totalSites: number, + totalUsers: number, + totalVisitsToday: number, + totalVisitsAllTime: number, + currentlyOnPremises: number +} + +export const sitesApi = { + getAll: () => apiClient.get('/api/sites'), + create: (data: SiteRequest) => apiClient.post('/api/sites', data), + update: (id: string, data: SiteRequest) => apiClient.put(`/api/sites/${id}`, data), + deactivate: (id: string) => apiClient.delete(`/api/sites/${id}`) +} \ No newline at end of file diff --git a/frontend/src/features/admin/useSites.ts b/frontend/src/features/admin/useSites.ts new file mode 100644 index 0000000..7abc9af --- /dev/null +++ b/frontend/src/features/admin/useSites.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { sitesApi, type Site, type SiteRequest } from "./adminApi"; + +interface UseSitesResponse { + sites: Site[], + isLoading: boolean, + error: string | null, + createSite: (data: SiteRequest) => Promise, + updateSite: (id: string, data: SiteRequest) => Promise, + deactivateSite: (id: string) => Promise, + refresh: () => Promise +} + +export function useSites(): UseSitesResponse { + const [sites, setSites] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const isMountedRef = useRef(true) + + const fetchSites = useRef(async () => { + setIsLoading(true) + try { + const { data } = await sitesApi.getAll() + if (!isMountedRef.current) return + setSites(data) + setError(null) + } catch { + if (!isMountedRef.current) return + setError('Failed to load sites') + } finally { + if (isMountedRef.current) setIsLoading(false) + } + }).current + + useEffect(() => { + isMountedRef.current = true + fetchSites() + return () => { isMountedRef.current = false } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const createSite = useCallback(async (data: SiteRequest) => { + const { data: created } = await sitesApi.create(data) + setSites((prev) => [...prev, created]) + }, []) + + const updateSite = useCallback(async (id: string, data: SiteRequest) => { + const { data: updated } = await sitesApi.update(id, data) + setSites((prev) => prev.map((s) => (s.id === id ? updated : s))) + }, []) + + const deactivateSite = useCallback(async (id: string) => { + await sitesApi.deactivate(id) + setSites((prev) => prev.filter((s) => s.id !== id)) + }, []) + + const refresh = useCallback(async () => { + await fetchSites() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { sites, isLoading, error, createSite, updateSite, deactivateSite, refresh } +} \ No newline at end of file diff --git a/frontend/src/pages/sites/Sites.tsx b/frontend/src/pages/sites/Sites.tsx index fe5258f..99c964e 100644 --- a/frontend/src/pages/sites/Sites.tsx +++ b/frontend/src/pages/sites/Sites.tsx @@ -1,3 +1,355 @@ -export default function Sites() { - return
Sites — coming soon
; -} \ No newline at end of file +import { useState } from 'react' +import { useSites } from '../../features/admin/useSites' +import type { Site, SiteRequest } from '../../features/admin/adminApi' +import { + Building2, + Plus, + Pencil, + Trash2, + X, + Loader2, + AlertTriangle, + RefreshCw, +} from 'lucide-react' +import { extractErrorMessage } from '../../utils/errors' + +type ModalMode = 'create' | 'edit' | 'delete' | null + +interface ModalState { + mode: ModalMode + site: Site | null +} + +export default function SitesPage() { + const { sites, isLoading, error, createSite, updateSite, deactivateSite, refresh } = useSites() + const [modal, setModal] = useState({ mode: null, site: null }) + + const openCreate = () => setModal({ mode: 'create', site: null }) + const openEdit = (site: Site) => setModal({ mode: 'edit', site }) + const openDelete = (site: Site) => setModal({ mode: 'delete', site }) + const closeModal = () => setModal({ mode: null, site: null }) + + if (isLoading) return + + if (error) return ( +
+ +

{error}

+ +
+ ) + + return ( +
+ {/* Header */} +
+
+

+ Super Admin +

+

+ Sites +

+

+ {sites.length} site{sites.length !== 1 ? 's' : ''} on the platform +

+
+ +
+ + {/* Site list */} + {sites.length === 0 ? ( +
+
+ +
+

No sites yet

+

Create your first site to get started.

+ +
+ ) : ( +
+
    + {sites.map((site) => ( +
  • +
    + +
    +
    +

    {site.name}

    +

    + {site.location ?? 'No address set'} +

    +
    +
    + + +
    +
  • + ))} +
+
+ )} + + {/* Modals */} + {modal.mode === 'create' && ( + { await createSite(data); closeModal() }} + onClose={closeModal} + /> + )} + {modal.mode === 'edit' && modal.site && ( + { await updateSite(modal.site!.id, data); closeModal() }} + onClose={closeModal} + /> + )} + {modal.mode === 'delete' && modal.site && ( + { await deactivateSite(modal.site!.id); closeModal() }} + onClose={closeModal} + /> + )} +
+ ) +} + +/* Site form modal */ +function SiteFormModal({ + mode, + site, + onSubmit, + onClose, +}: { + mode: 'create' | 'edit' + site?: Site + onSubmit: (data: SiteRequest | SiteRequest) => Promise + onClose: () => void +}) { + const [name, setName] = useState(site?.name ?? '') + const [location, setLocation] = useState(site?.location ?? '') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!name.trim()) return + if (!location.trim()) return + setError(null) + setSubmitting(true) + try { + await onSubmit({ name: name.trim(), location: location.trim() }) + } catch (err) { + setError(extractErrorMessage(err)) + setSubmitting(false) + } + } + + return ( + +
+ {error && ( +
+ + {error} +
+ )} + + setName(e.target.value)} + placeholder="e.g. Nairobi HQ" + required + className={inputCls} + disabled={submitting} + /> + + + setLocation(e.target.value)} + placeholder="e.g. Westlands, Nairobi" + required + className={inputCls} + disabled={submitting} + /> + +
+ + +
+
+
+ ) +} + +/* Delete confirmation modal */ +function DeleteModal({ + siteName, + onConfirm, + onClose, +}: { + siteName: string + onConfirm: () => Promise + onClose: () => void +}) { + const [submitting, setSubmitting] = useState(false) + + async function handleConfirm() { + setSubmitting(true) + try { await onConfirm() } finally { setSubmitting(false) } + } + + return ( + +

+ Are you sure you want to remove{' '} + {siteName}? + This action cannot be undone. +

+
+ + +
+
+ ) +} + +/* Shared modal wrapper */ +function Modal({ + title, + onClose, + children, +}: { + title: string + onClose: () => void + children: React.ReactNode +}) { + return ( +
+ + ) +} + +/* Form field wrapper */ +function FormField({ + label, + required, + children, +}: { + label: string + required?: boolean + children: React.ReactNode +}) { + return ( +
+ + {children} +
+ ) +} + +/* Skeleton */ +function SitesSkeleton() { + return ( +
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ ) +} + +/*Shared styles */ +const inputCls = ` + w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-neutral-900 + outline-none transition-[border-color,box-shadow] duration-150 bg-white + placeholder:text-gray-400 + focus:border-green-500 focus:shadow-[0_0_0_3px_rgba(37,168,94,0.12)] + disabled:bg-gray-50 disabled:cursor-not-allowed +`.trim() + +const primaryBtn = ` + flex items-center gap-2 px-4 py-2 rounded-lg bg-green-700 text-white + text-sm font-semibold hover:bg-green-500 transition-colors duration-150 + disabled:opacity-50 disabled:cursor-not-allowed +`.trim() + +const ghostBtn = ` + px-4 py-2 rounded-lg border border-gray-200 text-sm font-medium + text-gray-600 hover:border-gray-300 hover:text-gray-900 + transition-colors duration-150 disabled:opacity-50 +`.trim() diff --git a/frontend/src/pages/users/Admin.tsx b/frontend/src/pages/users/Admin.tsx index c2161f2..b27429c 100644 --- a/frontend/src/pages/users/Admin.tsx +++ b/frontend/src/pages/users/Admin.tsx @@ -1,3 +1,337 @@ -export default function Admin() { - return
Admin — coming soon
; +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import { useDashboard } from '../../features/dashboard/useDashboard' +import { useSites } from '../../features/admin/useSites' +import { useAuth } from '../../auth/AuthContext' +import { ROUTE_PATHS } from '../../router/constants' +import { + Building2, + Users, + UserCheck, + Activity, + ArrowRight, + AlertTriangle, + RefreshCw, + Clock, +} from 'lucide-react' + +export default function AdminPage() { + useAuth() + const { + data, + isLoading: dashLoading, + isRefreshing, + lastUpdated, + refresh, + checkOut, + checkingOutId, + } = useDashboard() + const { sites, isLoading: sitesLoading } = useSites() + + useEffect(() => { + document.title = 'Admin Overview — Gatelog' + }, []) + + const isLoading = dashLoading || sitesLoading + + return ( +
+ {/* Page header */} +
+
+

+ Super Admin +

+

+ System Overview +

+ {lastUpdated && ( +

+ + Updated {formatTime(lastUpdated)} + +

+ )} +
+ + +
+ + {isLoading ? ( + + ) : ( + <> + {/* Platform summary cards */} +
+ } + color="green" + linkTo={ROUTE_PATHS.SITES} + /> + } + color="blue" + /> + } + color="gray" + /> + } + color={data?.summary.overdueCount ?? 0 > 0 ? 'amber' : 'gray'} + highlight={(data?.summary.overdueCount ?? 0) > 0} + /> +
+ + {/* Quick actions */} +
+ } + linkTo={ROUTE_PATHS.SITES} + linkLabel="Manage Sites" + count={sites.length} + countLabel="sites" + /> + } + linkTo={ROUTE_PATHS.USERS} + linkLabel="Manage Users" + /> +
+ + {/* Cross-site active visitors */} +
+
+

+ Active Visitors — All Sites +

+ + {data?.activeVisitors.length ?? 0} + +
+ + {!data?.activeVisitors.length ? ( + + ) : ( +
    + {data.activeVisitors.map((v) => ( +
  • +
    + {v.name.split(' ').slice(0, 2).map((n: string) => n[0]?.toUpperCase()).join('')} +
    +
    +

    + {v.name} +

    +

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

    +
    + +
  • + ))} +
+ )} +
+ + {/* Overdue across all sites */} + {(data?.overdueVisitors.length ?? 0) > 0 && ( +
+
+

+ + Overdue Visitors - All Sites +

+ + {data!.overdueVisitors.length} + +
+
    + {data!.overdueVisitors.map((v) => ( +
  • +
    + {v.name.split(' ').slice(0, 2).map((n: string) => n[0]?.toUpperCase()).join('')} +
    +
    +

    {v.name}

    +

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

    +
    + + Overdue + + +
  • + ))} +
+
+ )} + + )} +
+ ) +} + +/* Stat card */ +const COLOR_MAP = { + green: { bg: 'bg-white', icon: 'bg-green-100 text-green-600', value: 'text-green-700' }, + blue: { bg: 'bg-white', icon: 'bg-blue-100 text-blue-600', value: 'text-blue-700' }, + gray: { bg: 'bg-white', 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 StatCard({ + label, value, icon, color, highlight, linkTo, +}: { + label: string + value: number + icon: React.ReactNode + color: keyof typeof COLOR_MAP + highlight?: boolean + linkTo?: string +}) { + const c = COLOR_MAP[color] + const inner = ( +
+
+

{label}

+ + {icon} + +
+

+ {value} +

+
+ ) + + if (linkTo) { + return ( + + {inner} + + ) + } + return inner +} + +/* Quick action card */ +function QuickActionCard({ + title, desc, icon, linkTo, linkLabel, count, countLabel, +}: { + title: string + desc: string + icon: React.ReactNode + linkTo: string + linkLabel: string + count?: number + countLabel?: string +}) { + return ( +
+
+
+ {icon} +
+
+
+

{title}

+ {count !== undefined && ( + + {count} {countLabel} + + )} +
+

{desc}

+ + {linkLabel} + + +
+
+
+ ) +} + +/* Empty state */ +function EmptyState({ message }: { message: string }) { + return ( +
+ + + +

{message}

+
+ ) +} + +/* Skeleton */ +function AdminSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+
+ ) +} + +/* Helpers */ +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } \ No newline at end of file