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 {
Plus, Search, ShoppingCart, Package, Filter,
AlertCircle, ExternalLink, X, Loader2
AlertCircle, ExternalLink, X, Loader2, Minus, MoreVertical
} from 'lucide-react';
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 { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
export default function SuppliesPage() {
const { addToast } = useToast();
@ -39,12 +42,14 @@ export default function SuppliesPage() {
setItems(data);
} catch (error) {
console.error('Failed to load supplies', error);
addToast('Failed to load inventory', 'error');
} finally {
setLoading(false);
}
};
const handleQuantityAdjust = async (id: string, adjustment: number) => {
// Optimistic update
setItems(current => current.map(item => {
if (item.id === id) {
return { ...item, quantity: Math.max(0, item.quantity + adjustment) };
@ -55,6 +60,7 @@ export default function SuppliesPage() {
try {
await suppliesApi.adjustQuantity(id, adjustment);
} catch (error) {
addToast('Failed to sync quantity', 'error');
loadItems();
}
};
@ -81,7 +87,7 @@ export default function SuppliesPage() {
setItems(current => current.map(item => item.id === id ? updated : item));
addToast('Marked as ordered', 'success');
} catch (error) {
addToast('Failed to update', 'error');
addToast('Failed to update order status', 'error');
}
};
@ -98,304 +104,397 @@ export default function SuppliesPage() {
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 (
<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
title={view === 'shopping' ? 'Shopping List' : 'Storage & Inventory'}
title={view === 'shopping' ? 'Supply Shortages' : 'Facility Inventory'}
subtitle={view === 'shopping'
? `${filteredItems.length} items need attention`
: 'Manage facility supplies'
? `Action required for ${filteredItems.length} low-stock items`
: 'Real-time tracking of facility and maintenance supplies'
}
actions={
<button
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} />
<span className="hidden sm:inline">Add Item</span>
<Plus size={18} strokeWidth={2.5} />
<span>Add Supply Record</span>
</button>
}
/>
{/* View Toggle */}
<div className="card p-1 inline-flex gap-1">
<button
onClick={() => setView('all')}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
${view === 'all' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
`}
>
Inventory
</button>
{/* Dashboard Ribbon */}
<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={`
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast relative
${view === 'shopping' ? 'bg-accent text-white' : 'text-secondary hover:text-primary hover:bg-tertiary'}
`}
>
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>
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>
{/* Filters */}
<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
onClick={() => setCategoryFilter('ALL')}
className={`badge flex-shrink-0 ${categoryFilter === 'ALL' ? 'badge-accent' : ''}`}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
className={`badge flex-shrink-0 capitalize ${categoryFilter === cat ? 'badge-accent' : ''}`}
>
{cat.toLowerCase()}
</button>
))}
</div>
</div>
{/* Items */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => <CardSkeleton key={i} />)}
</div>
) : filteredItems.length === 0 ? (
<EmptyState
icon={Package}
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'No supplies yet'}
description={search || categoryFilter !== 'ALL'
? 'Try adjusting your search or filters.'
: 'Add your first item to track inventory.'}
action={
search || categoryFilter !== 'ALL' ? (
<button
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
className="text-accent hover:underline text-sm"
>
Clear filters
</button>
) : (
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
<Plus size={16} />
Add First Item
</button>
)
}
/>
) : (
<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 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>
)}
{/* 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 */}
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 animate-fade-in">
<div className="card w-full max-w-md max-h-[90vh] overflow-y-auto animate-scale-in">
<div className="p-4 border-b border-subtle flex justify-between items-center sticky top-0 bg-primary z-10">
<h2 className="text-lg font-semibold text-primary">Add New Item</h2>
<button onClick={() => setIsAddModalOpen(false)} className="p-2 hover:bg-tertiary rounded-md">
<X size={16} className="text-tertiary" />
</button>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Item Name</label>
<input
required
className="input w-full"
value={newItem.name}
onChange={e => setNewItem({ ...newItem, name: e.target.value })}
placeholder="e.g. 5 Gallon Pots"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Category</label>
<select
className="input w-full"
value={newItem.category}
onChange={e => setNewItem({ ...newItem, category: e.target.value as any })}
>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Unit</label>
<input
className="input w-full"
value={newItem.unit}
onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
placeholder="box, each..."
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Current Qty</label>
<input
type="number"
className="input w-full"
value={newItem.quantity}
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Min Threshold</label>
<input
type="number"
className="input w-full"
value={newItem.minThreshold}
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Vendor</label>
<input
className="input w-full"
value={newItem.vendor}
onChange={e => setNewItem({ ...newItem, vendor: e.target.value })}
placeholder="e.g. Amazon"
/>
</div>
<div>
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">Product URL</label>
<input
className="input w-full"
value={newItem.productUrl}
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
placeholder="https://..."
/>
</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>
<button type="submit" className="btn btn-primary w-full h-12 mt-4">
Create Item
</button>
</form>
</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
onClick={() => setView('all')}
className={cn(
"px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all",
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"
)}
>
Full Inventory
</button>
<button
onClick={() => setView('shopping')}
className={cn(
"px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all relative",
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
</button>
</div>
<div className="flex gap-1 overflow-x-auto pb-1 no-scrollbar pr-2">
<button
onClick={() => setCategoryFilter('ALL')}
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 Categories
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
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()}
</button>
))}
</div>
</div>
<DataTable
data={filteredItems}
columns={columns}
isLoading={loading}
emptyState={
<EmptyState
icon={Package}
title={search || categoryFilter !== 'ALL' ? 'No items found' : 'Inventory Empty'}
description={search || categoryFilter !== 'ALL'
? 'Try adjusting your search query or category filters.'
: 'Your facility inventory is currently empty. Add your first supply record to start tracking.'}
action={
(search || categoryFilter !== 'ALL') ? (
<button
onClick={() => { setSearch(''); setCategoryFilter('ALL'); }}
className="font-bold text-indigo-500 hover:text-indigo-600 uppercase text-xs tracking-widest"
>
Reset Filters
</button>
) : (
<button onClick={() => setIsAddModalOpen(true)} className="btn btn-primary">
<Plus size={16} />
Add First Item
</button>
)
}
/>
}
/>
</div>
{/* Add Modal */}
<AnimatePresence>
{isAddModalOpen && (
<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={() => 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>
</div>
<form onSubmit={handleCreate} className="p-8 space-y-6">
<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
required
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}
onChange={e => setNewItem({ ...newItem, name: e.target.value })}
placeholder="e.g. 5 Gallon Nursery Pots"
/>
</div>
<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">Category</label>
<select
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}
onChange={e => setNewItem({ ...newItem, category: e.target.value as any })}
>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Unit</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.unit}
onChange={e => setNewItem({ ...newItem, unit: e.target.value })}
placeholder="box, each, gal..."
/>
</div>
</div>
<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">Initial Stock</label>
<input
type="number"
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}
onChange={e => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Alert Threshold</label>
<input
type="number"
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}
onChange={e => setNewItem({ ...newItem, minThreshold: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Storage Location</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.location}
onChange={e => setNewItem({ ...newItem, location: e.target.value })}
placeholder="e.g. Warehouse Rack B4"
/>
</div>
<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
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}
onChange={e => setNewItem({ ...newItem, productUrl: e.target.value })}
placeholder="https://..."
/>
</div>
</div>
</div>
<button
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>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -1,12 +1,16 @@
import { useState, useEffect } from 'react';
import {
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';
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 { cn } from '../lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
type TabType = 'active' | 'all' | 'zones' | 'reports' | 'gallery';
@ -39,6 +43,7 @@ export default function VisitorManagementPage() {
}
} catch (error) {
console.error('Failed to load data:', error);
addToast('Failed to load records', 'error');
} finally {
setLoading(false);
}
@ -50,7 +55,7 @@ export default function VisitorManagementPage() {
addToast(`${visitor.name} checked out`, 'success');
loadData();
} catch (error) {
addToast('Failed to check out', 'error');
addToast('Failed to sign out visitor', 'error');
}
};
@ -66,7 +71,7 @@ export default function VisitorManagementPage() {
try {
await visitorsApi.revoke(revokeModal.visitor.visitorId, revokeModal.notes);
setRevokeModal(null);
addToast('Access revoked', 'warning');
addToast('Access credentials revoked', 'warning');
loadData();
} catch (error) {
addToast('Failed to revoke access', 'error');
@ -74,409 +79,451 @@ export default function VisitorManagementPage() {
};
const getTypeBadge = (type: string) => {
const badges: Record<string, string> = {
VISITOR: 'badge-accent',
CONTRACTOR: 'badge-warning',
INSPECTOR: 'badge-destructive',
VENDOR: 'badge-accent',
DELIVERY: 'badge-success',
OTHER: 'badge'
const variants: Record<string, 'accent' | 'warning' | 'error' | 'active' | 'default'> = {
VISITOR: 'active',
CONTRACTOR: 'warning',
INSPECTOR: 'error',
VENDOR: 'accent',
DELIVERY: 'default',
OTHER: 'default'
};
return badges[type] || 'badge';
return <StatusBadge status={variants[type] || 'default'} label={type} className="px-2" />;
};
return (
<div className="space-y-6 animate-in">
<PageHeader
title="Visitor Management"
subtitle="Real-time facility access monitoring"
actions={
<a
href="/kiosk"
target="_blank"
className="btn btn-primary"
// Columns for Active (Live) Visitors
const activeColumns: Column<ActiveVisitor>[] = [
{
key: 'visitor',
header: 'On-Site Personnel',
cell: (v) => (
<div className="flex items-center gap-3">
<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">
{v.name.charAt(0)}
</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] font-bold text-slate-400 dark:text-slate-500 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-slate-400" />
<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-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"
>
<UserPlus size={16} />
<span className="hidden sm:inline">Open Kiosk</span>
</a>
Sign Out
</button>
<button
onClick={(e) => { e.stopPropagation(); setRevokeModal({ visitor: v, notes: '' }); }}
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={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-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>
}
/>
{/* 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."
{/* 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
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-slate-200 dark:border-slate-800 rounded-lg pl-9 pr-4 py-2 text-sm"
/>
) : (
<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 ? (
</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-slate-200 dark:border-slate-800 shadow-sm">
<div className="flex p-1 bg-slate-100 dark:bg-slate-900 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-bold uppercase tracking-widest transition-all flex items-center gap-2",
activeTab === tab.id
? "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"
)}
>
<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-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>
) : (
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"
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500"
/>
) : (
<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 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>
)}
{/* 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>
)}
))
)}
</motion.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="w-9 h-9 bg-accent-muted rounded-full flex items-center justify-center text-accent font-semibold text-sm">
{visitor.name.charAt(0)}
</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 items-center gap-2">
<span className="font-medium text-primary text-sm">{visitor.name}</span>
<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 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>
</div>
<div className="text-right hidden md:block">
<div className="text-[10px] text-tertiary uppercase tracking-wider">Duration</div>
<div className="text-xs text-primary">{formatDuration(visitor.entryTime)}</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleCheckOut(visitor)}
className="btn btn-secondary text-xs h-8"
>
<LogOut size={14} />
Sign Out
</button>
<button
onClick={() => setRevokeModal({ visitor, notes: '' })}
className="btn btn-ghost text-destructive hover:bg-destructive-muted text-xs h-8"
>
<AlertTriangle size={14} />
Revoke
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* All Visitors Tab */}
{activeTab === 'all' && (
<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 all records..."
className="input w-full pl-9"
/>
</div>
<button className="btn btn-secondary">
<Filter size={16} />
Filter
</button>
</div>
<div className="card overflow-hidden">
<table className="w-full">
<thead className="bg-secondary">
<tr className="text-left text-xs text-tertiary uppercase tracking-wider">
<th className="p-4">Visitor</th>
<th className="p-4">Type</th>
<th className="p-4">Company</th>
<th className="p-4">Last Visit</th>
<th className="p-4">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{allVisitors.map(visitor => (
<tr key={visitor.id} className="hover:bg-tertiary transition-colors duration-fast">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-tertiary rounded-full flex items-center justify-center text-secondary text-xs font-semibold">
{visitor.name.charAt(0)}
</div>
<div>
<div className="text-sm font-medium text-primary">{visitor.name}</div>
<div className="text-xs text-tertiary">{visitor.email}</div>
<div className="flex justify-between items-start mb-4">
<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>
<StatusBadge status={zone.escortRequired ? "warning" : "active"} label={zone.code} />
</div>
</td>
<td className="p-4">
<span className={`${getTypeBadge(visitor.type)} text-[10px]`}>
{visitor.type}
<h3 className="text-lg font-bold tracking-tight mb-1">{zone.name}</h3>
<p className="text-xs text-slate-500 leading-relaxed">{zone.description || 'No zone constraints defined.'}</p>
</div>
<div className="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800/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-slate-400 dark:text-slate-600 uppercase tracking-widest">
{zone.maxOccupancy ? `Limit ${zone.maxOccupancy}` : 'No Limit'}
</span>
</td>
<td className="p-4 text-sm text-secondary">{visitor.company || '—'}</td>
<td className="p-4 text-xs text-tertiary">
{visitor.logs[0]
? new Date(visitor.logs[0].entryTime).toLocaleDateString()
: 'Never'
}
</td>
<td className="p-4">
{visitor.logs[0]?.status === 'CHECKED_IN' ? (
<span className="badge-success text-[10px]">On-Site</span>
) : visitor.logs[0]?.status === 'REVOKED' ? (
<span className="badge-destructive text-[10px]">REVOKED</span>
) : (
<span className="badge text-[10px]">Off-Site</span>
)}
</td>
</tr>
</div>
</div>
))}
</tbody>
</table>
</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">
<Plus className="animate-pulse" />
<span className="text-xs font-bold uppercase tracking-widest">Register Access Zone</span>
</button>
</motion.div>
)}
{/* Zones Tab */}
{activeTab === 'zones' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{zones.map(zone => (
<div key={zone.id} className="card p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium text-primary text-sm">{zone.name}</h3>
<span className="text-xs text-tertiary font-mono">{zone.code}</span>
</div>
<Building className="text-tertiary" size={20} />
</div>
{zone.description && (
<p className="text-xs text-tertiary mb-3">{zone.description}</p>
)}
<div className="flex flex-wrap gap-1">
{zone.escortRequired && <span className="badge-warning text-[10px]">Escort Required</span>}
{zone.ndaRequired && <span className="badge-destructive text-[10px]">NDA Required</span>}
{zone.maxOccupancy && <span className="badge-accent text-[10px]">Max: {zone.maxOccupancy}</span>}
</div>
</div>
))}
<button className="card card-interactive p-8 flex flex-col items-center justify-center gap-2 border-dashed">
<Building size={24} className="text-tertiary" />
<span className="text-sm text-tertiary">Add Zone</span>
</button>
</div>
)}
{/* Reports Tab */}
{activeTab === 'reports' && (
<div className="card p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium text-primary">Visitor Reports</h3>
<button className="btn btn-secondary">
<Download size={16} />
Export Report
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-tertiary rounded-lg p-4">
<h4 className="text-sm font-medium text-primary mb-3">Quick Reports</h4>
<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 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>
<label className="text-xs text-tertiary">Date Range</label>
<div className="flex gap-2 mt-1">
<input type="date" className="input flex-1 h-9 text-xs" />
<input type="date" className="input flex-1 h-9 text-xs" />
{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-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>
))}
</div>
</div>
<button className="btn btn-ghost w-full text-accent hover:bg-accent-muted text-sm">
Generate Report
</button>
</div>
</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-slate-500 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 */}
{revokeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="card w-full max-w-md p-6 animate-scale-in">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
<h3 className="text-lg font-semibold">Revoke Access</h3>
<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-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>
</div>
<button
onClick={() => setRevokeModal(null)}
className="p-2 hover:bg-tertiary rounded-md transition-colors"
>
<X size={16} className="text-tertiary" />
</button>
</div>
<p className="text-sm text-secondary mb-4">
Revoke access for <span className="font-semibold text-primary">{revokeModal.visitor.name}</span>?
This will invalidate their badge.
</p>
<div className="p-8 space-y-6">
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
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>
<div className="mb-6">
<label className="block text-xs font-medium text-tertiary uppercase tracking-wider mb-2">
Reason for Revocation
</label>
<textarea
value={revokeModal.notes}
onChange={e => setRevokeModal({ ...revokeModal, notes: e.target.value })}
className="input w-full h-24 py-3 resize-none"
placeholder="Security violation, elapsed time, etc."
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 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-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="Enter mandatory security justification..."
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setRevokeModal(null)}
className="btn btn-secondary flex-1"
>
Cancel
</button>
<button
onClick={handleRevoke}
disabled={!revokeModal.notes.trim()}
className="btn flex-1 bg-destructive hover:bg-destructive/90 text-white disabled:opacity-50"
>
REVOKE ACCESS
</button>
</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-slate-400 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-rose-500 disabled:opacity-30 shadow-xl shadow-rose-600/10 transition-all"
>
Revoke Access
</button>
</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>
);
}