- 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
529 lines
29 KiB
TypeScript
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>
|
|
);
|
|
}
|