feat(ui): Refactor Supplies and Visitor Management pages
- 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:
parent
6cb668bc92
commit
ae92d560ee
2 changed files with 804 additions and 658 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue