ca-grow-ops-manager/frontend/src/pages/VisitorManagementPage.tsx
fullsizemalt 4b37e9fa84
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
style: Clean up typography across all pages
- Remove italic styling from headers throughout app
- Reduce excessive font-black to font-bold
- Clean up tracking-widest to tracking-wider
- Normalize button styling across pages
2025-12-27 13:09:04 -08:00

529 lines
29 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Users, UserPlus, LogIn, LogOut, Building, Search,
Filter, Download, Calendar, Shield, AlertTriangle, X,
History, LayoutGrid, Camera, MapPin, Clock, MoreHorizontal, Plus
} from 'lucide-react';
import { visitorsApi, Visitor, VisitorLog, ActiveVisitor, AccessZone } from '../lib/visitorsApi';
import { PageHeader, MetricCard, EmptyState, StatusBadge, ActionButton } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { useToast } from '../context/ToastContext';
import { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
type TabType = 'active' | 'all' | 'zones' | 'reports' | 'gallery';
export default function VisitorManagementPage() {
const { addToast } = useToast();
const [activeTab, setActiveTab] = useState<TabType>('active');
const [activeVisitors, setActiveVisitors] = useState<(ActiveVisitor & { id: string })[]>([]);
const [allVisitors, setAllVisitors] = useState<(Visitor & { logs: VisitorLog[] })[]>([]);
const [zones, setZones] = useState<AccessZone[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [revokeModal, setRevokeModal] = useState<{ visitor: ActiveVisitor & { id: string }, notes: string } | null>(null);
useEffect(() => {
loadData();
}, [activeTab]);
const loadData = async () => {
setLoading(true);
try {
if (activeTab === 'active') {
const { visitors } = await visitorsApi.getActive();
setActiveVisitors(visitors.map(v => ({ ...v, id: v.logId })));
} else if (activeTab === 'all' || activeTab === 'gallery') {
const { visitors } = await visitorsApi.getAll({ search: searchQuery || undefined });
setAllVisitors(visitors);
} else if (activeTab === 'zones') {
const zonesData = await visitorsApi.getZones();
setZones(zonesData);
}
} catch (error) {
console.error('Failed to load data:', error);
addToast('Failed to load records', 'error');
} finally {
setLoading(false);
}
};
const handleCheckOut = async (visitor: ActiveVisitor) => {
try {
await visitorsApi.checkOut(visitor.visitorId);
addToast(`${visitor.name} checked out`, 'success');
loadData();
} catch (error) {
addToast('Failed to sign out visitor', 'error');
}
};
const formatDuration = (entryTime: string) => {
const minutes = Math.floor((Date.now() - new Date(entryTime).getTime()) / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
};
const handleRevoke = async () => {
if (!revokeModal) return;
try {
await visitorsApi.revoke(revokeModal.visitor.visitorId, revokeModal.notes);
setRevokeModal(null);
addToast('Access credentials revoked', 'warning');
loadData();
} catch (error) {
addToast('Failed to revoke access', 'error');
}
};
const getTypeBadge = (type: string) => {
const variants: Record<string, 'default' | 'pending' | 'active' | 'completed' | 'error'> = {
VISITOR: 'active',
CONTRACTOR: 'pending',
INSPECTOR: 'error',
VENDOR: 'default',
DELIVERY: 'default',
OTHER: 'default'
};
return <StatusBadge status={variants[type] || 'default'} label={type} className="px-2" />;
};
// Columns for Active (Live) Visitors
const activeColumns: Column<ActiveVisitor & { id: string }>[] = [
{
key: 'visitor',
header: 'On-Site Personnel',
cell: (v) => (
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-[var(--color-bg-tertiary)] rounded-full flex items-center justify-center font-bold text-[var(--color-text-tertiary)] text-xs">
{v.name.charAt(0)}
</div>
<div>
<div className="text-sm font-bold text-[var(--color-text-primary)] leading-none mb-1">{v.name}</div>
<div className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">{v.company || 'Private'}</div>
</div>
</div>
)
},
{
key: 'type',
header: 'Type',
cell: (v) => getTypeBadge(v.type),
hideOnMobile: true
},
{
key: 'badge',
header: 'Badge ID',
cell: (v) => <span className="text-xs font-mono text-indigo-500 font-bold">{v.badgeNumber}</span>,
hideOnMobile: true
},
{
key: 'duration',
header: 'Duration',
cell: (v) => (
<div className="flex items-center gap-1.5 text-xs font-mono">
<Clock size={12} className="text-[var(--color-text-tertiary)]" />
<span>{formatDuration(v.entryTime)}</span>
</div>
)
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (v) => (
<div className="flex justify-end gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleCheckOut(v); }}
className="text-[10px] font-medium px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 transition-all"
>
Sign Out
</button>
<button
onClick={(e) => { e.stopPropagation(); setRevokeModal({ visitor: v, notes: '' }); }}
className="p-2 text-[var(--color-error)] hover:bg-rose-50 dark:hover:bg-[var(--color-error)]/10 rounded-lg transition-all"
title="Revoke Credentials"
>
<AlertTriangle size={16} />
</button>
</div>
)
}
];
// Columns for Visitor History
const historyColumns: Column<Visitor & { logs: VisitorLog[] }>[] = [
{
key: 'visitor',
header: 'Visitor Information',
cell: (v) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[var(--color-bg-tertiary)] flex items-center justify-center">
<Users size={14} className="text-[var(--color-text-tertiary)]" />
</div>
<div>
<div className="text-sm font-bold text-[var(--color-text-primary)] leading-none mb-1">{v.name}</div>
<div className="text-[10px] text-[var(--color-text-tertiary)] truncate max-w-[150px]">{v.email}</div>
</div>
</div>
)
},
{
key: 'type',
header: 'Role',
cell: (v) => getTypeBadge(v.type),
hideOnMobile: true
},
{
key: 'company',
header: 'Entity',
cell: (v) => <span className="text-xs text-[var(--color-text-tertiary)]">{v.company || '—'}</span>,
hideOnMobile: true
},
{
key: 'lastVisit',
header: 'Last Entry',
cell: (v) => (
<div className="text-xs text-[var(--color-text-tertiary)]">
{v.logs[0] ? new Date(v.logs[0].entryTime).toLocaleDateString() : 'No history'}
</div>
)
},
{
key: 'status',
header: 'System Status',
cell: (v) => {
const status = v.logs[0]?.status;
if (status === 'CHECKED_IN') return <StatusBadge status="active" label="ON-SITE" />;
if (status === 'REVOKED') return <StatusBadge status="error" label="REVO-ACC" />;
return <StatusBadge status="default" label="OFF-SITE" />;
},
hideOnMobile: true
}
];
return (
<div className="max-w-[1600px] mx-auto space-y-8 pb-24 animate-in">
<PageHeader
title="Visitor Operations"
subtitle="Facility access auditing and credential management"
actions={
<div className="flex items-center gap-3">
<button className="p-2.5 text-[var(--color-text-tertiary)] hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<Download size={20} />
</button>
<a
href="/kiosk"
target="_blank"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-xl font-bold text-sm tracking-tight transition-all active:scale-95 shadow-xl shadow-indigo-600/10 flex items-center gap-2"
>
<UserPlus size={18} strokeWidth={2.5} />
<span>Launch Kiosk</span>
</a>
</div>
}
/>
{/* Metrics Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard icon={Users} label="Personnel On-Site" value={activeVisitors.length} accent="success" />
<MetricCard icon={Shield} label="Compliance Alerts" value="0" accent="default" subtitle="All systems nominal" />
<MetricCard icon={Building} label="Restricted Zones" value={zones.length} accent="accent" />
<div className="card p-5 bg-slate-100/50 dark:bg-slate-900/50 flex flex-col justify-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]" size={14} />
<input
type="text"
placeholder="Lookup visitor log..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && loadData()}
className="w-full bg-white dark:bg-slate-950 border border-[var(--color-border-subtle)] rounded-lg pl-9 pr-4 py-2 text-sm"
/>
</div>
</div>
</div>
{/* Tabbed Interface */}
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4 bg-white dark:bg-[#050505] p-2 rounded-2xl border border-[var(--color-border-subtle)] shadow-sm">
<div className="flex p-1 bg-[var(--color-bg-tertiary)] rounded-xl">
{[
{ id: 'active', label: 'Live Manifest', icon: RadioNodeIcon },
{ id: 'all', label: 'Access Logs', icon: History },
{ id: 'gallery', label: 'Visual Archive', icon: Camera },
{ id: 'zones', label: 'Zones & Gates', icon: Building },
{ id: 'reports', label: 'Audits', icon: Shield }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={cn(
"px-5 py-2 rounded-lg text-xs font-medium transition-all flex items-center gap-2",
activeTab === tab.id
? "bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300"
)}
>
<tab.icon size={14} />
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Content Views */}
<div className="min-h-[400px]">
<AnimatePresence mode="wait">
{activeTab === 'active' && (
<motion.div
key="active"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<DataTable
data={activeVisitors}
columns={activeColumns}
isLoading={loading}
emptyState={
<EmptyState
icon={Users}
title="Facility Clear"
description="No visitors currently recorded as on-site."
action={
<button onClick={() => window.open('/kiosk', '_blank')} className="font-bold text-indigo-500 text-xs tracking-widest uppercase">
Check-In Personnel
</button>
}
/>
}
/>
</motion.div>
)}
{activeTab === 'all' && (
<motion.div
key="all"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<DataTable
data={allVisitors}
columns={historyColumns}
isLoading={loading}
emptyState={
<EmptyState
icon={History}
title="No Records"
description="Historical visitor logs will appear here."
/>
}
/>
</motion.div>
)}
{activeTab === 'gallery' && (
<motion.div
key="gallery"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6"
>
{loading ? (
Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="aspect-[3/4] rounded-2xl bg-[var(--color-bg-tertiary)] animate-pulse" />
))
) : allVisitors.filter(v => v.photoUrl).length === 0 ? (
<div className="col-span-full py-20">
<EmptyState icon={Camera} title="No Media Found" description="Photographic identification records will appear here." />
</div>
) : (
allVisitors.filter(v => v.photoUrl).map(visitor => (
<div key={visitor.id} className="group relative aspect-[3/4] rounded-2xl overflow-hidden border border-[var(--color-border-subtle)] shadow-sm hover:shadow-xl hover:shadow-indigo-500/10 transition-all">
<img
src={visitor.photoUrl}
alt={visitor.name}
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute bottom-0 left-0 right-0 p-3 translate-y-2 group-hover:translate-y-0 transition-transform">
<p className="text-xs font-bold text-white truncate">{visitor.name}</p>
<p className="text-[9px] text-white/60 uppercase tracking-widest">{visitor.type}</p>
</div>
</div>
))
)}
</motion.div>
)}
{activeTab === 'zones' && (
<motion.div
key="zones"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6"
>
{zones.map(zone => (
<div key={zone.id} className="card p-6 flex flex-col justify-between group hover:border-indigo-500/50 transition-colors">
<div>
<div className="flex justify-between items-start mb-4">
<div className="bg-[var(--color-bg-tertiary)] p-3 rounded-2xl group-hover:bg-indigo-500/10 transition-colors">
<MapPin size={24} className="text-[var(--color-text-tertiary)] group-hover:text-indigo-500" />
</div>
<StatusBadge status={zone.escortRequired ? "pending" : "active"} label={zone.code} />
</div>
<h3 className="text-lg font-bold tracking-tight mb-1">{zone.name}</h3>
<p className="text-xs text-[var(--color-text-tertiary)] leading-relaxed">{zone.description || 'No zone constraints defined.'}</p>
</div>
<div className="mt-8 pt-6 border-t border-[var(--color-border-subtle)]/50 flex items-center justify-between">
<div className="flex -space-x-2">
{[1, 2].map(i => (
<div key={i} className="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-800 border-2 border-white dark:border-[#0C0C0C]" />
))}
</div>
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] dark:text-slate-600 uppercase tracking-widest">
{zone.maxOccupancy ? `Limit ${zone.maxOccupancy}` : 'No Limit'}
</span>
</div>
</div>
))}
<button className="card border-dashed p-8 flex flex-col items-center justify-center gap-3 text-[var(--color-text-tertiary)] hover:text-indigo-500 hover:border-indigo-500/50 transition-all">
<Plus className="animate-pulse" />
<span className="text-xs font-medium">Register Access Zone</span>
</button>
</motion.div>
)}
{activeTab === 'reports' && (
<motion.div
key="reports"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="grid grid-cols-1 md:grid-cols-2 gap-8"
>
<div className="card p-8">
<h3 className="text-lg font-bold mb-6 flex items-center gap-3">
<History size={20} className="text-indigo-500" />
Standard Audit Packages
</h3>
<div className="space-y-3">
{[
{ label: 'Weekly Access Manifest', size: '2.4mb' },
{ label: 'Compliance Incident Summary', size: '1.1mb' },
{ label: 'Contractor Billing verification', size: '4.8mb' }
].map(r => (
<button key={r.label} className="w-full flex items-center justify-between p-4 bg-[var(--color-bg-tertiary)]/50 rounded-2xl hover:bg-indigo-500/5 transition-all text-left">
<div>
<p className="text-sm font-bold text-[var(--color-text-primary)] leading-none mb-1">{r.label}</p>
<p className="text-[10px] text-[var(--color-text-tertiary)] uppercase tracking-widest font-mono">{r.size} · PDF FORMAT</p>
</div>
<Download size={16} className="text-[var(--color-text-tertiary)]" />
</button>
))}
</div>
</div>
<div className="card p-8 flex flex-col justify-center items-center text-center space-y-4">
<div className="w-16 h-16 bg-indigo-500/10 rounded-3xl flex items-center justify-center">
<Filter size={32} className="text-indigo-500" />
</div>
<div>
<h4 className="text-lg font-bold">Custom Data Weaver</h4>
<p className="text-sm text-[var(--color-text-tertiary)] max-w-xs mx-auto">Generate multi-dimensional reports across multiple facility zones and time periods.</p>
</div>
<button className="bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold text-sm tracking-tight hover:bg-indigo-500 transition-all">
Initialize Custom Audit
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Revoke Modal */}
<AnimatePresence>
{revokeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setRevokeModal(null)}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative w-full max-w-md bg-white dark:bg-[#0C0C0C] border border-rose-200 dark:border-rose-900/50 rounded-3xl shadow-2xl overflow-hidden"
>
<div className="p-6 border-b border-rose-100 dark:border-rose-900/20 flex justify-between items-center bg-rose-50/50 dark:bg-[var(--color-error)]/5">
<div className="flex items-center gap-3 text-rose-600">
<AlertTriangle size={20} />
<h3 className="text-lg font-bold tracking-tight">Security Revocation</h3>
</div>
<button onClick={() => setRevokeModal(null)} className="p-2 hover:bg-rose-100 dark:hover:bg-rose-900/40 rounded-xl transition-colors">
<X size={20} className="text-rose-400" />
</button>
</div>
<div className="p-8 space-y-6">
<p className="text-sm text-slate-600 dark:text-[var(--color-text-tertiary)] leading-relaxed">
You are about to revoke all facility access privileges for <span className="font-bold text-[var(--color-text-primary)]">{revokeModal.visitor.name}</span>.
This action is immediate and will be logged in the permanent compliance record.
</p>
<div className="space-y-2">
<label className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest ml-1">Justification for Revocation</label>
<textarea
value={revokeModal.notes}
onChange={e => setRevokeModal({ ...revokeModal, notes: e.target.value })}
className="w-full bg-[var(--color-bg-tertiary)]/50 border border-[var(--color-border-subtle)] rounded-xl px-4 py-3 text-sm h-32 resize-none focus:ring-2 focus:ring-rose-500/20 transition-all font-mono"
placeholder="Enter mandatory security justification..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setRevokeModal(null)}
className="flex-1 px-4 py-3 rounded-xl font-bold text-sm bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-[var(--color-text-tertiary)] hover:bg-slate-200 transition-all"
>
Cancel
</button>
<button
onClick={handleRevoke}
disabled={!revokeModal.notes.trim()}
className="flex-1 px-4 py-3 rounded-xl font-bold text-sm bg-rose-600 text-white hover:bg-[var(--color-error)] disabled:opacity-30 shadow-xl shadow-rose-600/10 transition-all"
>
Revoke Access
</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
// Icon helper
function RadioNodeIcon({ size = 16, className = "" }) {
return (
<div className={cn("relative", className)}>
<div className="absolute inset-0 animate-ping opacity-20 bg-[var(--color-primary)] rounded-full" />
<div className="relative w-4 h-4 bg-[var(--color-primary)]/20 rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
</div>
</div>
);
}