feat(ui): Refactor Supplies and Visitor Management pages
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Implemented DataTable for high-density inventory tracking
- Modernized Visitor Management with Live Manifest and Access Logs
- Applied 777 Wolfpack theme and motion transitions
- Standardized metrics with MetricCard and StatusBadge
This commit is contained in:
fullsizemalt 2025-12-19 19:36:23 -08:00
parent 6cb668bc92
commit ae92d560ee
2 changed files with 804 additions and 658 deletions

View file

@ -1,11 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Plus, Search, ShoppingCart, Package, Filter, Plus, Search, ShoppingCart, Package, Filter,
AlertCircle, ExternalLink, X, Loader2 AlertCircle, ExternalLink, X, Loader2, Minus, MoreVertical
} from 'lucide-react'; } from 'lucide-react';
import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi'; import { suppliesApi, SupplyItem, SupplyCategory } from '../lib/suppliesApi';
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; import { PageHeader, EmptyState, StatusBadge } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
export default function SuppliesPage() { export default function SuppliesPage() {
const { addToast } = useToast(); const { addToast } = useToast();
@ -39,12 +42,14 @@ export default function SuppliesPage() {
setItems(data); setItems(data);
} catch (error) { } catch (error) {
console.error('Failed to load supplies', error); console.error('Failed to load supplies', error);
addToast('Failed to load inventory', 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleQuantityAdjust = async (id: string, adjustment: number) => { const handleQuantityAdjust = async (id: string, adjustment: number) => {
// Optimistic update
setItems(current => current.map(item => { setItems(current => current.map(item => {
if (item.id === id) { if (item.id === id) {
return { ...item, quantity: Math.max(0, item.quantity + adjustment) }; return { ...item, quantity: Math.max(0, item.quantity + adjustment) };
@ -55,6 +60,7 @@ export default function SuppliesPage() {
try { try {
await suppliesApi.adjustQuantity(id, adjustment); await suppliesApi.adjustQuantity(id, adjustment);
} catch (error) { } catch (error) {
addToast('Failed to sync quantity', 'error');
loadItems(); loadItems();
} }
}; };
@ -81,7 +87,7 @@ export default function SuppliesPage() {
setItems(current => current.map(item => item.id === id ? updated : item)); setItems(current => current.map(item => item.id === id ? updated : item));
addToast('Marked as ordered', 'success'); addToast('Marked as ordered', 'success');
} catch (error) { } catch (error) {
addToast('Failed to update', 'error'); addToast('Failed to update order status', 'error');
} }
}; };
@ -98,76 +104,235 @@ export default function SuppliesPage() {
const categories: SupplyCategory[] = ['FILTER', 'CLEANING', 'PPE', 'OFFICE', 'BATHROOM', 'KITCHEN', 'MAINTENANCE', 'OTHER']; const categories: SupplyCategory[] = ['FILTER', 'CLEANING', 'PPE', 'OFFICE', 'BATHROOM', 'KITCHEN', 'MAINTENANCE', 'OTHER'];
const columns: Column<SupplyItem>[] = [
{
key: 'name',
header: 'Item Name',
cell: (item) => (
<div className="flex items-center gap-2">
<span className={cn("font-bold text-sm", item.quantity <= item.minThreshold ? "text-rose-500" : "text-slate-900 dark:text-slate-100")}>
{item.name}
</span>
{item.quantity <= item.minThreshold && (
<AlertCircle size={14} className="text-rose-500 animate-pulse" />
)}
</div>
)
},
{
key: 'category',
header: 'Category',
cell: (item) => (
<StatusBadge
status="default"
label={item.category.toLowerCase()}
className="bg-slate-100 dark:bg-slate-800 border-none px-2"
/>
),
hideOnMobile: true
},
{
key: 'location',
header: 'Location',
cell: (item) => <span className="text-xs font-mono text-slate-500">{item.location || '—'}</span>,
hideOnMobile: true
},
{
key: 'quantity',
header: 'Quantity',
className: 'w-48',
cell: (item) => (
<div className="flex items-center gap-3">
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 border border-slate-200 dark:border-slate-700/50">
<button
onClick={(e) => { e.stopPropagation(); handleQuantityAdjust(item.id, -1); }}
disabled={item.quantity <= 0}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm transition-all disabled:opacity-30"
>
<Minus size={14} />
</button>
<div className="w-10 text-center font-bold text-sm tabular-nums">
{item.quantity}
</div>
<button
onClick={(e) => { e.stopPropagation(); handleQuantityAdjust(item.id, 1); }}
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm transition-all"
>
<Plus size={14} />
</button>
</div>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{item.unit}</span>
</div>
)
},
{
key: 'minThreshold',
header: 'Min',
cell: (item) => (
<div className="text-xs font-bold text-slate-400">
<span className="md:hidden">Min: </span>{item.minThreshold}
</div>
),
hideOnMobile: true
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (item) => (
<div className="flex justify-end gap-2">
{item.productUrl && (
<a
href={item.productUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded-lg transition-colors"
title="Buy Now"
>
<ExternalLink size={16} />
</a>
)}
{item.quantity <= item.minThreshold && (
<button
onClick={(e) => { e.stopPropagation(); handleMarkOrdered(item.id); }}
className={cn(
"text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-lg border transition-all",
item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? "bg-emerald-50 text-emerald-600 border-emerald-100"
: "bg-rose-50 text-rose-600 border-rose-100 hover:bg-rose-100"
)}
>
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? 'Ordered'
: 'Order'
}
</button>
)}
</div>
)
}
];
return ( return (
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in"> <div className="max-w-[1600px] mx-auto space-y-8 pb-24 animate-in">
<PageHeader <PageHeader
title={view === 'shopping' ? 'Shopping List' : 'Storage & Inventory'} title={view === 'shopping' ? 'Supply Shortages' : 'Facility Inventory'}
subtitle={view === 'shopping' subtitle={view === 'shopping'
? `${filteredItems.length} items need attention` ? `Action required for ${filteredItems.length} low-stock items`
: 'Manage facility supplies' : 'Real-time tracking of facility and maintenance supplies'
} }
actions={ actions={
<button <button
onClick={() => setIsAddModalOpen(true)} onClick={() => setIsAddModalOpen(true)}
className="btn btn-primary" className="group relative 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"
> >
<Plus size={16} /> <Plus size={18} strokeWidth={2.5} />
<span className="hidden sm:inline">Add Item</span> <span>Add Supply Record</span>
</button> </button>
} }
/> />
{/* View Toggle */} {/* Dashboard Ribbon */}
<div className="card p-1 inline-flex gap-1"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card p-5 bg-gradient-to-br from-indigo-500/5 to-transparent flex items-center justify-between">
<div>
<p className="text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Stock Health</p>
<h4 className="text-2xl font-bold">{items.length} Unique SKUs</h4>
</div>
<Package className="text-indigo-500/20" size={40} />
</div>
<button
onClick={() => setView('shopping')}
className={cn(
"card p-5 transition-all text-left group",
shoppingListCount > 0
? "bg-rose-500/5 hover:bg-rose-500/10 border-rose-200/50"
: "bg-emerald-500/5 hover:bg-emerald-500/10 border-emerald-200/50"
)}
>
<div className="flex justify-between items-start">
<div>
<p className={cn(
"text-[10px] font-bold uppercase tracking-widest mb-1",
shoppingListCount > 0 ? "text-rose-500" : "text-emerald-500"
)}>
Shortages Detected
</p>
<h4 className="text-2xl font-bold">{shoppingListCount} Items</h4>
</div>
<AlertCircle className={cn(
"group-hover:scale-110 transition-transform",
shoppingListCount > 0 ? "text-rose-500/40" : "text-emerald-500/40"
)} size={40} />
</div>
</button>
<div className="card p-5 bg-slate-100/50 dark:bg-slate-900/50 flex flex-col justify-center">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="Scan or search inventory..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg pl-9 pr-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
/>
</div>
</div>
</div>
</div>
{/* Controls & Table */}
<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-slate-200 dark:border-slate-800 shadow-sm">
<div className="flex p-1 bg-slate-100 dark:bg-slate-900 rounded-xl">
<button <button
onClick={() => setView('all')} onClick={() => setView('all')}
className={` className={cn(
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast "px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all",
${view === 'all' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'} view === 'all'
`} ? "bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
)}
> >
Inventory Full Inventory
</button> </button>
<button <button
onClick={() => setView('shopping')} onClick={() => setView('shopping')}
className={` className={cn(
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast relative "px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all relative",
${view === 'shopping' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'} view === 'shopping'
`} ? "bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
)}
> >
Shopping List Shopping List
{shoppingListCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-white text-[10px] flex items-center justify-center rounded-full">
{shoppingListCount}
</span>
)}
</button> </button>
</div> </div>
{/* Filters */} <div className="flex gap-1 overflow-x-auto pb-1 no-scrollbar pr-2">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
<input
type="text"
placeholder="Search supplies..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input w-full pl-9"
/>
</div>
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<button <button
onClick={() => setCategoryFilter('ALL')} onClick={() => setCategoryFilter('ALL')}
className={`badge flex-shrink-0 ${categoryFilter === 'ALL' ? 'badge-accent' : ''}`} className={cn(
"px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border",
categoryFilter === 'ALL'
? "bg-indigo-500 text-white border-indigo-500"
: "bg-slate-50 dark:bg-slate-900 text-slate-500 border-slate-200 dark:border-slate-800"
)}
> >
All All Categories
</button> </button>
{categories.map(cat => ( {categories.map(cat => (
<button <button
key={cat} key={cat}
onClick={() => setCategoryFilter(cat)} onClick={() => setCategoryFilter(cat)}
className={`badge flex-shrink-0 capitalize ${categoryFilter === cat ? 'badge-accent' : ''}`} className={cn(
"px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap",
categoryFilter === cat
? "bg-indigo-500 text-white border-indigo-500"
: "bg-slate-50 dark:bg-slate-900 text-slate-500 border-slate-200 dark:border-slate-800"
)}
> >
{cat.toLowerCase()} {cat.toLowerCase()}
</button> </button>
@ -175,25 +340,24 @@ export default function SuppliesPage() {
</div> </div>
</div> </div>
{/* Items */} <DataTable
{loading ? ( data={filteredItems}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> columns={columns}
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)} isLoading={loading}
</div> emptyState={
) : filteredItems.length === 0 ? (
<EmptyState <EmptyState
icon={Package} icon={Package}
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'No supplies yet'} title={search || categoryFilter !== 'ALL' ? 'No items found' : 'Inventory Empty'}
description={search || categoryFilter !== 'ALL' description={search || categoryFilter !== 'ALL'
? 'Try adjusting your search or filters.' ? 'Try adjusting your search query or category filters.'
: 'Add your first item to track inventory.'} : 'Your facility inventory is currently empty. Add your first supply record to start tracking.'}
action={ action={
search || categoryFilter !== 'ALL' ? ( (search || categoryFilter !== 'ALL') ? (
<button <button
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }} onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
className="text-accent hover:underline text-sm" className="font-bold text-indigo-500 hover:text-indigo-600 uppercase text-xs tracking-widest"
> >
Clear filters Reset Filters
</button> </button>
) : ( ) : (
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary"> <button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
@ -203,199 +367,134 @@ export default function SuppliesPage() {
) )
} }
/> />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredItems.map(item => (
<div
key={item.id}
className={`
card p-4 flex flex-col
${item.quantity <= item.minThreshold ? 'ring-1 ring-destructive/30' : ''}
`}
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-primary text-sm flex items-center gap-2">
{item.name}
{item.quantity <= item.minThreshold && (
<AlertCircle size={14} className="text-destructive flex-shrink-0" />
)}
</h3>
<div className="flex flex-wrap gap-1 mt-1">
<span className="badge text-[10px] capitalize">{item.category.toLowerCase()}</span>
{item.location && <span className="badge text-[10px]">📍 {item.location}</span>}
</div>
</div>
<div className="text-right">
<div className="text-xl font-semibold text-primary tabular-nums">{item.quantity}</div>
<div className="text-xs text-tertiary">{item.unit}</div>
</div>
</div>
<div className="mt-auto pt-3 border-t border-subtle flex items-center gap-2">
<div className="flex items-center gap-0.5 bg-tertiary rounded-md p-0.5">
<button
onClick={() => handleQuantityAdjust(item.id, -1)}
disabled={item.quantity <= 0}
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => handleQuantityAdjust(item.id, 1)}
className="w-7 h-7 rounded flex items-center justify-center bg-primary text-secondary hover:text-primary transition-colors"
>
+
</button>
</div>
{item.quantity <= item.minThreshold && (
<div className="flex-1 flex gap-1">
{item.productUrl && (
<a
href={item.productUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost text-xs h-7 flex-1 text-accent hover:bg-accent-muted"
>
<ExternalLink size={12} />
Buy
</a>
)}
<button
onClick={() => handleMarkOrdered(item.id)}
className={`
btn text-xs h-7 flex-1
${item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? 'btn-secondary'
: 'btn-ghost text-destructive hover:bg-destructive-muted'
} }
`} />
>
{item.lastOrdered && new Date(item.lastOrdered).toDateString() === new Date().toDateString()
? 'Ordered'
: 'Order'
}
</button>
</div> </div>
)}
</div>
</div>
))}
</div>
)}
{/* Mobile FAB */}
<button
onClick={() => setIsAddModalOpen(true)}
className="md:hidden fixed bottom-20 right-4 w-14 h-14 bg-accent text-white rounded-full shadow-lg flex items-center justify-center z-40 hover:bg-accent/90 active:scale-95 transition-all"
>
<Plus size={24} />
</button>
{/* Add Modal */} {/* Add Modal */}
<AnimatePresence>
{isAddModalOpen && ( {isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="card w-full max-w-md max-h-[90vh] overflow-y-auto animate-scale-in"> <motion.div
<div className="p-4 border-b border-subtle flex justify-between items-center sticky top-0 bg-primary z-10"> initial={{ opacity: 0 }}
<h2 className="text-lg font-semibold text-primary">Add New Item</h2> animate={{ opacity: 1 }}
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-tertiary rounded-md"> exit={{ opacity: 0 }}
<X size={16} className="text-tertiary" /> onClick={() => setIsAddModalOpen(false)}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-lg bg-white dark:bg-[#0C0C0C] border border-slate-200 dark:border-slate-800 rounded-3xl shadow-2xl overflow-hidden"
>
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h2 className="text-xl font-bold tracking-tight">Add Supply Record</h2>
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-colors">
<X size={20} className="text-slate-400" />
</button> </button>
</div> </div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div> <form onSubmit={handleCreate} className="p-8 space-y-6">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Item Name</label> <div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Item Name</label>
<input <input
required required
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.name} value={newItem.name}
onChange={e => setNewItem({ ...newItem, name: e.target.value })} onChange={e => setNewItem({ ...newItem, name: e.target.value })}
placeholder="e.g. 5 Gallon Pots" placeholder="e.g. 5 Gallon Nursery Pots"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div className="space-y-2">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Category</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Category</label>
<select <select
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.category} value={newItem.category}
onChange={e => setNewItem({ ...newItem, category: e.target.value as any })} onChange={e => setNewItem({ ...newItem, category: e.target.value as any })}
> >
{categories.map(c => <option key={c} value={c}>{c}</option>)} {categories.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
</div> </div>
<div> <div className="space-y-2">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Unit</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Unit</label>
<input <input
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.unit} value={newItem.unit}
onChange={e => setNewItem({ ...newItem, unit: e.target.value })} onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
placeholder="box, each..." placeholder="box, each, gal..."
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div className="space-y-2">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Current Qty</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Initial Stock</label>
<input <input
type="number" type="number"
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.quantity} value={newItem.quantity}
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })} onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Min Threshold</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Alert Threshold</label>
<input <input
type="number" type="number"
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.minThreshold} value={newItem.minThreshold}
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })} onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-2">
<div> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Storage Location</label>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Vendor</label>
<input <input
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.vendor} value={newItem.location}
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })} onChange={e => setNewItem({ ...newItem, location: e.target.value })}
placeholder="e.g. Amazon" placeholder="e.g. Warehouse Rack B4"
/> />
</div> </div>
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Product URL</label> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Vendor</label>
<input <input
className="input w-full" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.vendor}
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
placeholder="Amazon, Home Depot..."
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">URL</label>
<input
className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500/20 transition-all"
value={newItem.productUrl} value={newItem.productUrl}
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })} onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Location</label>
<input
className="input w-full"
value={newItem.location}
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
placeholder="e.g. Storage Room A"
/>
</div> </div>
<button type="submit" className="btn btn-primary w-full h-12 mt-4"> <button
Create Item type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl h-14 font-bold tracking-tight shadow-xl shadow-indigo-600/10 transition-all active:scale-[0.98] flex items-center justify-center gap-2"
>
<Package size={18} />
<span>Create Inventory Record</span>
</button> </button>
</form> </form>
</div> </motion.div>
</div> </div>
)} )}
</AnimatePresence>
</div> </div>
); );
} }

View file

@ -1,12 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Users, UserPlus, LogIn, LogOut, Building, Search, Users, UserPlus, LogIn, LogOut, Building, Search,
Filter, Download, Calendar, Shield, AlertTriangle, X Filter, Download, Calendar, Shield, AlertTriangle, X,
History, LayoutGrid, Camera, MapPin, Clock, MoreHorizontal
} from 'lucide-react'; } from 'lucide-react';
import { visitorsApi, Visitor, VisitorLog, ActiveVisitor, AccessZone } from '../lib/visitorsApi'; import { visitorsApi, Visitor, VisitorLog, ActiveVisitor, AccessZone } from '../lib/visitorsApi';
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; import { PageHeader, MetricCard, EmptyState, StatusBadge, ActionButton } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
type TabType = 'active' | 'all' | 'zones' | 'reports' | 'gallery'; type TabType = 'active' | 'all' | 'zones' | 'reports' | 'gallery';
@ -39,6 +43,7 @@ export default function VisitorManagementPage() {
} }
} catch (error) { } catch (error) {
console.error('Failed to load data:', error); console.error('Failed to load data:', error);
addToast('Failed to load records', 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -50,7 +55,7 @@ export default function VisitorManagementPage() {
addToast(`${visitor.name} checked out`, 'success'); addToast(`${visitor.name} checked out`, 'success');
loadData(); loadData();
} catch (error) { } catch (error) {
addToast('Failed to check out', 'error'); addToast('Failed to sign out visitor', 'error');
} }
}; };
@ -66,7 +71,7 @@ export default function VisitorManagementPage() {
try { try {
await visitorsApi.revoke(revokeModal.visitor.visitorId, revokeModal.notes); await visitorsApi.revoke(revokeModal.visitor.visitorId, revokeModal.notes);
setRevokeModal(null); setRevokeModal(null);
addToast('Access revoked', 'warning'); addToast('Access credentials revoked', 'warning');
loadData(); loadData();
} catch (error) { } catch (error) {
addToast('Failed to revoke access', 'error'); addToast('Failed to revoke access', 'error');
@ -74,409 +79,451 @@ export default function VisitorManagementPage() {
}; };
const getTypeBadge = (type: string) => { const getTypeBadge = (type: string) => {
const badges: Record<string, string> = { const variants: Record<string, 'accent' | 'warning' | 'error' | 'active' | 'default'> = {
VISITOR: 'badge-accent', VISITOR: 'active',
CONTRACTOR: 'badge-warning', CONTRACTOR: 'warning',
INSPECTOR: 'badge-destructive', INSPECTOR: 'error',
VENDOR: 'badge-accent', VENDOR: 'accent',
DELIVERY: 'badge-success', DELIVERY: 'default',
OTHER: 'badge' OTHER: 'default'
}; };
return badges[type] || 'badge'; return <StatusBadge status={variants[type] || 'default'} label={type} className="px-2" />;
}; };
return ( // Columns for Active (Live) Visitors
<div className="space-y-6 animate-in"> const activeColumns: Column<ActiveVisitor>[] = [
<PageHeader {
title="Visitor Management" key: 'visitor',
subtitle="Real-time facility access monitoring" header: 'On-Site Personnel',
actions={ cell: (v) => (
<a
href="/kiosk"
target="_blank"
className="btn btn-primary"
>
<UserPlus size={16} />
<span className="hidden sm:inline">Open Kiosk</span>
</a>
}
/>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<MetricCard icon={Users} label="On-Site Now" value={activeVisitors.length} accent="success" />
<MetricCard icon={LogIn} label="Today's Visits" value="--" accent="accent" />
<MetricCard icon={Building} label="Zones Active" value={zones.length} accent="accent" />
<MetricCard icon={AlertTriangle} label="Alerts" value="0" accent="warning" />
</div>
{/* Tabs */}
<div className="card p-1 inline-flex gap-1">
{(['active', 'all', 'zones', 'reports'] as TabType[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast capitalize
${activeTab === tab
? 'bg-accent text-white'
: 'text-secondary hover:text-primary hover:bg-tertiary'
}
`}
>
{tab === 'active' ? 'Live View' : tab}
</button>
))}
</div>
{/* Gallery View */}
{activeTab === 'gallery' && (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && loadData()}
placeholder="Search visitor badges..."
className="input w-full pl-9"
/>
</div>
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 8 }).map((_, i) => <CardSkeleton key={i} />)}
</div>
) : allVisitors.length === 0 ? (
<EmptyState
icon={Users}
title="No visitors found"
description="Upload photos during check-in to see them here."
/>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{allVisitors.map(visitor => (
<div key={visitor.id} className="card overflow-hidden group hover:ring-1 hover:ring-accent transition-all">
<div className="aspect-[4/3] bg-tertiary relative">
{visitor.photoUrl ? (
<img
src={visitor.photoUrl}
alt={visitor.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-tertiary">
<Users size={32} className="mb-2 opacity-50" />
<span className="text-[10px] uppercase tracking-wider">No Photo</span>
</div>
)}
{/* Overlay Type Badge */}
<div className="absolute top-2 right-2">
<span className={`${getTypeBadge(visitor.type)} text-[10px] shadow-sm`}>
{visitor.type}
</span>
</div>
</div>
<div className="p-3">
<h4 className="font-medium text-primary text-sm truncate" title={visitor.name}>{visitor.name}</h4>
<p className="text-xs text-secondary truncate">{visitor.company || 'Private Visitor'}</p>
<div className="mt-2 flex items-center justify-between text-[10px] text-tertiary">
<span>
{visitor.logs[0] ? new Date(visitor.logs[0].entryTime).toLocaleDateString() : 'N/A'}
</span>
<span className={visitor.logs[0]?.status === 'CHECKED_IN' ? 'text-success font-medium' : ''}>
{visitor.logs[0]?.status === 'CHECKED_IN' ? 'Active' : 'Offline'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Active Visitors Tab */}
{activeTab === 'active' && (
<div className="card overflow-hidden">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
</div>
) : activeVisitors.length === 0 ? (
<EmptyState
icon={Users}
title="No visitors on-site"
description="Visitors will appear here when they check in."
/>
) : (
<div className="divide-y divide-subtle">
{activeVisitors.map(visitor => (
<div key={visitor.logId} className="p-4 flex items-center justify-between hover:bg-tertiary transition-colors duration-fast">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 bg-accent-muted rounded-full flex items-center justify-center text-accent font-semibold text-sm"> <div className="w-9 h-9 bg-slate-100 dark:bg-slate-900 rounded-full flex items-center justify-center font-bold text-slate-500 text-xs">
{visitor.name.charAt(0)} {v.name.charAt(0)}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="text-sm font-bold text-slate-900 dark:text-white leading-none mb-1">{v.name}</div>
<span className="font-medium text-primary text-sm">{visitor.name}</span> <div className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{v.company || 'Private'}</div>
<span className={`${getTypeBadge(visitor.type)} text-[10px]`}>
{visitor.type}
</span>
</div>
<div className="text-xs text-tertiary">
{visitor.company && `${visitor.company} · `}
{visitor.purpose}
</div> </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-slate-400" />
<span>{formatDuration(v.entryTime)}</span>
</div> </div>
<div className="flex items-center gap-4"> )
<div className="text-right hidden md:block"> },
<div className="text-[10px] text-tertiary uppercase tracking-wider">Badge</div> {
<div className="text-xs text-accent font-mono">{visitor.badgeNumber}</div> key: 'actions',
</div> header: '',
<div className="text-right hidden md:block"> className: 'text-right',
<div className="text-[10px] text-tertiary uppercase tracking-wider">Duration</div> cell: (v) => (
<div className="text-xs text-primary">{formatDuration(visitor.entryTime)}</div> <div className="flex justify-end gap-2">
</div>
<div className="flex gap-2">
<button <button
onClick={() => handleCheckOut(visitor)} onClick={(e) => { e.stopPropagation(); handleCheckOut(v); }}
className="btn btn-secondary text-xs h-8" className="text-[10px] font-bold uppercase tracking-widest 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"
> >
<LogOut size={14} />
Sign Out Sign Out
</button> </button>
<button <button
onClick={() => setRevokeModal({ visitor, notes: '' })} onClick={(e) => { e.stopPropagation(); setRevokeModal({ visitor: v, notes: '' }); }}
className="btn btn-ghost text-destructive hover:bg-destructive-muted text-xs h-8" className="p-2 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition-all"
title="Revoke Credentials"
> >
<AlertTriangle size={14} /> <AlertTriangle size={16} />
Revoke
</button> </button>
</div> </div>
</div> )
</div> }
))} ];
</div>
)}
</div>
)}
{/* All Visitors Tab */} // Columns for Visitor History
{activeTab === 'all' && ( const historyColumns: Column<Visitor & { logs: VisitorLog[] }>[] = [
<div className="space-y-4"> {
<div className="flex gap-3"> key: 'visitor',
<div className="flex-1 relative"> header: 'Visitor Information',
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} /> cell: (v) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-900 flex items-center justify-center">
<Users size={14} className="text-slate-400" />
</div>
<div>
<div className="text-sm font-bold text-slate-900 dark:text-white leading-none mb-1">{v.name}</div>
<div className="text-[10px] text-slate-500 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-slate-500">{v.company || '—'}</span>,
hideOnMobile: true
},
{
key: 'lastVisit',
header: 'Last Entry',
cell: (v) => (
<div className="text-xs text-slate-500">
{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-slate-400 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-slate-400" size={14} />
<input <input
type="text" type="text"
placeholder="Lookup visitor log..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && loadData()} onKeyDown={e => e.key === 'Enter' && loadData()}
placeholder="Search all records..." className="w-full bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg pl-9 pr-4 py-2 text-sm"
className="input w-full pl-9"
/> />
</div> </div>
<button className="btn btn-secondary"> </div>
<Filter size={16} />
Filter
</button>
</div> </div>
<div className="card overflow-hidden"> {/* Tabbed Interface */}
<table className="w-full"> <div className="space-y-4">
<thead className="bg-secondary"> <div className="flex flex-wrap items-center justify-between gap-4 bg-white dark:bg-[#050505] p-2 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm">
<tr className="text-left text-xs text-tertiary uppercase tracking-wider"> <div className="flex p-1 bg-slate-100 dark:bg-slate-900 rounded-xl">
<th className="p-4">Visitor</th> {[
<th className="p-4">Type</th> { id: 'active', label: 'Live Manifest', icon: RadioNodeIcon },
<th className="p-4">Company</th> { id: 'all', label: 'Access Logs', icon: History },
<th className="p-4">Last Visit</th> { id: 'gallery', label: 'Visual Archive', icon: Camera },
<th className="p-4">Status</th> { id: 'zones', label: 'Zones & Gates', icon: Building },
</tr> { id: 'reports', label: 'Audits', icon: Shield }
</thead> ].map(tab => (
<tbody className="divide-y divide-subtle"> <button
{allVisitors.map(visitor => ( key={tab.id}
<tr key={visitor.id} className="hover:bg-tertiary transition-colors duration-fast"> onClick={() => setActiveTab(tab.id as TabType)}
<td className="p-4"> className={cn(
<div className="flex items-center gap-3"> "px-5 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all flex items-center gap-2",
<div className="w-8 h-8 bg-tertiary rounded-full flex items-center justify-center text-secondary text-xs font-semibold"> activeTab === tab.id
{visitor.name.charAt(0)} ? "bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm"
</div> : "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
<div> )}
<div className="text-sm font-medium text-primary">{visitor.name}</div> >
<div className="text-xs text-tertiary">{visitor.email}</div> <tab.icon size={14} />
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div> </div>
</div> </div>
</td>
<td className="p-4"> {/* Content Views */}
<span className={`${getTypeBadge(visitor.type)} text-[10px]`}> <div className="min-h-[400px]">
{visitor.type} <AnimatePresence mode="wait">
</span> {activeTab === 'active' && (
</td> <motion.div
<td className="p-4 text-sm text-secondary">{visitor.company || '—'}</td> key="active"
<td className="p-4 text-xs text-tertiary"> initial={{ opacity: 0, y: 10 }}
{visitor.logs[0] animate={{ opacity: 1, y: 0 }}
? new Date(visitor.logs[0].entryTime).toLocaleDateString() exit={{ opacity: 0, y: -10 }}
: 'Never' >
<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>
} }
</td> />
<td className="p-4"> }
{visitor.logs[0]?.status === 'CHECKED_IN' ? ( />
<span className="badge-success text-[10px]">On-Site</span> </motion.div>
) : visitor.logs[0]?.status === 'REVOKED' ? ( )}
<span className="badge-destructive text-[10px]">REVOKED</span>
{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-slate-100 dark:bg-slate-900 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>
) : ( ) : (
<span className="badge text-[10px]">Off-Site</span> allVisitors.filter(v => v.photoUrl).map(visitor => (
<div key={visitor.id} className="group relative aspect-[3/4] rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 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>
))
)} )}
</td> </motion.div>
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
{/* Zones Tab */}
{activeTab === 'zones' && ( {activeTab === 'zones' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <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 => ( {zones.map(zone => (
<div key={zone.id} className="card p-4"> <div key={zone.id} className="card p-6 flex flex-col justify-between group hover:border-indigo-500/50 transition-colors">
<div className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="font-medium text-primary text-sm">{zone.name}</h3> <div className="flex justify-between items-start mb-4">
<span className="text-xs text-tertiary font-mono">{zone.code}</span> <div className="bg-slate-100 dark:bg-slate-900 p-3 rounded-2xl group-hover:bg-indigo-500/10 transition-colors">
<MapPin size={24} className="text-slate-400 group-hover:text-indigo-500" />
</div> </div>
<Building className="text-tertiary" size={20} /> <StatusBadge status={zone.escortRequired ? "warning" : "active"} label={zone.code} />
</div> </div>
{zone.description && ( <h3 className="text-lg font-bold tracking-tight mb-1">{zone.name}</h3>
<p className="text-xs text-tertiary mb-3">{zone.description}</p> <p className="text-xs text-slate-500 leading-relaxed">{zone.description || 'No zone constraints defined.'}</p>
)} </div>
<div className="flex flex-wrap gap-1">
{zone.escortRequired && <span className="badge-warning text-[10px]">Escort Required</span>} <div className="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-between">
{zone.ndaRequired && <span className="badge-destructive text-[10px]">NDA Required</span>} <div className="flex -space-x-2">
{zone.maxOccupancy && <span className="badge-accent text-[10px]">Max: {zone.maxOccupancy}</span>} {[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-slate-400 dark:text-slate-600 uppercase tracking-widest">
{zone.maxOccupancy ? `Limit ${zone.maxOccupancy}` : 'No Limit'}
</span>
</div> </div>
</div> </div>
))} ))}
<button className="card border-dashed p-8 flex flex-col items-center justify-center gap-3 text-slate-400 hover:text-indigo-500 hover:border-indigo-500/50 transition-all">
<button className="card card-interactive p-8 flex flex-col items-center justify-center gap-2 border-dashed"> <Plus className="animate-pulse" />
<Building size={24} className="text-tertiary" /> <span className="text-xs font-bold uppercase tracking-widest">Register Access Zone</span>
<span className="text-sm text-tertiary">Add Zone</span>
</button> </button>
</div> </motion.div>
)} )}
{/* Reports Tab */}
{activeTab === 'reports' && ( {activeTab === 'reports' && (
<div className="card p-6"> <motion.div
<div className="flex items-center justify-between mb-6"> key="reports"
<h3 className="text-lg font-medium text-primary">Visitor Reports</h3> initial={{ opacity: 0, y: 10 }}
<button className="btn btn-secondary"> animate={{ opacity: 1, y: 0 }}
<Download size={16} /> exit={{ opacity: 0, y: -10 }}
Export Report 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-slate-50 dark:bg-slate-900/50 rounded-2xl hover:bg-indigo-500/5 transition-all text-left">
<div>
<p className="text-sm font-bold text-slate-900 dark:text-white leading-none mb-1">{r.label}</p>
<p className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">{r.size} · PDF FORMAT</p>
</div>
<Download size={16} className="text-slate-400" />
</button> </button>
))}
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="card p-8 flex flex-col justify-center items-center text-center space-y-4">
<div className="bg-tertiary rounded-lg p-4"> <div className="w-16 h-16 bg-indigo-500/10 rounded-3xl flex items-center justify-center">
<h4 className="text-sm font-medium text-primary mb-3">Quick Reports</h4> <Filter size={32} className="text-indigo-500" />
<ul className="space-y-2">
<li>
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
<Calendar size={14} />
Last 7 Days Visitor Log
</a>
</li>
<li>
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
<Calendar size={14} />
Last 30 Days Visitor Log
</a>
</li>
<li>
<a href="#" className="text-accent hover:underline text-sm flex items-center gap-2">
<Shield size={14} />
Compliance Report (90 days)
</a>
</li>
</ul>
</div> </div>
<div className="bg-tertiary rounded-lg p-4">
<h4 className="text-sm font-medium text-primary mb-3">Custom Report</h4>
<div className="space-y-3">
<div> <div>
<label className="text-xs text-tertiary">Date Range</label> <h4 className="text-lg font-bold">Custom Data Weaver</h4>
<div className="flex gap-2 mt-1"> <p className="text-sm text-slate-500 max-w-xs mx-auto">Generate multi-dimensional reports across multiple facility zones and time periods.</p>
<input type="date" className="input flex-1 h-9 text-xs" />
<input type="date" className="input flex-1 h-9 text-xs" />
</div> </div>
</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">
<button className="btn btn-ghost w-full text-accent hover:bg-accent-muted text-sm"> Initialize Custom Audit
Generate Report
</button> </button>
</div> </div>
</div> </motion.div>
</div>
</div>
)} )}
</AnimatePresence>
</div>
</div>
{/* Revoke Modal */} {/* Revoke Modal */}
<AnimatePresence>
{revokeModal && ( {revokeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="card w-full max-w-md p-6 animate-scale-in"> <motion.div
<div className="flex items-center justify-between mb-4"> initial={{ opacity: 0 }}
<div className="flex items-center gap-2 text-destructive"> animate={{ opacity: 1 }}
<AlertTriangle size={20} /> exit={{ opacity: 0 }}
<h3 className="text-lg font-semibold">Revoke Access</h3>
</div>
<button
onClick={() => setRevokeModal(null)} onClick={() => setRevokeModal(null)}
className="p-2 hover:bg-tertiary rounded-md transition-colors" 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"
> >
<X size={16} className="text-tertiary" /> <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-rose-500/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> </button>
</div> </div>
<p className="text-sm text-secondary mb-4"> <div className="p-8 space-y-6">
Revoke access for <span className="font-semibold text-primary">{revokeModal.visitor.name}</span>? <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
This will invalidate their badge. You are about to revoke all facility access privileges for <span className="font-bold text-slate-900 dark:text-white">{revokeModal.visitor.name}</span>.
This action is immediate and will be logged in the permanent compliance record.
</p> </p>
<div className="mb-6"> <div className="space-y-2">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2"> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Justification for Revocation</label>
Reason for Revocation
</label>
<textarea <textarea
value={revokeModal.notes} value={revokeModal.notes}
onChange={e => setRevokeModal({ ...revokeModal, notes: e.target.value })} onChange={e => setRevokeModal({ ...revokeModal, notes: e.target.value })}
className="input w-full h-24 py-3 resize-none" className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 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="Security violation, elapsed time, etc." placeholder="Enter mandatory security justification..."
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3 pt-2">
<button <button
onClick={() => setRevokeModal(null)} onClick={() => setRevokeModal(null)}
className="btn btn-secondary flex-1" 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-slate-400 hover:bg-slate-200 transition-all"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleRevoke} onClick={handleRevoke}
disabled={!revokeModal.notes.trim()} disabled={!revokeModal.notes.trim()}
className="btn flex-1 bg-destructive hover:bg-destructive/90 text-white disabled:opacity-50" className="flex-1 px-4 py-3 rounded-xl font-bold text-sm bg-rose-600 text-white hover:bg-rose-500 disabled:opacity-30 shadow-xl shadow-rose-600/10 transition-all"
> >
REVOKE ACCESS Revoke Access
</button> </button>
</div> </div>
</div> </div>
</motion.div>
</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-emerald-500 rounded-full" />
<div className="relative w-4 h-4 bg-emerald-500/20 rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
</div>
</div> </div>
); );
} }