feat(theme): Universal Ersen OS Refactor
- Implemented global page transitions (Framer Motion) - Unified Data Views with new high-density DataTable primitive - Refactored Navbar and Layout for 'Ersen OS' branding - Modernized Login Page with premium split-screen design - Upgraded System Primitives for consistent operational aesthetics
This commit is contained in:
parent
56948c20ec
commit
4fd7aed250
8 changed files with 808 additions and 1079 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Navbar } from './aura/Navbar';
|
||||
import { MobileNav } from './layout/MobileNav';
|
||||
|
|
@ -10,14 +11,15 @@ import { PageTitleUpdater } from '../hooks/usePageTitle';
|
|||
import AnnouncementBanner from './AnnouncementBanner';
|
||||
import { DevTools } from './dev/DevTools';
|
||||
import { Breadcrumbs } from './ui/Breadcrumbs';
|
||||
import { pageVariants } from '../lib/animations';
|
||||
|
||||
export default function Layout() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-slate-50 dark:bg-slate-900 transition-colors duration-300">
|
||||
{/* Skip to main content link (accessibility) */}
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-[#0A0A0A] transition-colors duration-500 selection:bg-cyan-500/30 selection:text-cyan-900 dark:selection:text-cyan-100">
|
||||
{/* Accessibility: Skip to main content */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="absolute left-0 top-0 -translate-y-full bg-cyan-600 text-white px-4 py-2 rounded-br-lg font-medium focus:translate-y-0 z-50 transition-transform duration-fast"
|
||||
|
|
@ -25,43 +27,47 @@ export default function Layout() {
|
|||
Skip to main content
|
||||
</a>
|
||||
|
||||
{/* Top Navigation Bar */}
|
||||
{/* Global Navbar */}
|
||||
<Navbar onOpenMobileMenu={() => setMobileSheetOpen(true)} />
|
||||
|
||||
<div className="flex flex-1 relative">
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 relative overflow-hidden">
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 w-full pb-20 lg:pb-8 custom-scrollbar relative"
|
||||
className="flex-1 w-full pb-24 lg:pb-12 overflow-y-auto custom-scrollbar"
|
||||
role="main"
|
||||
>
|
||||
<PageTitleUpdater />
|
||||
<AnnouncementBanner />
|
||||
|
||||
<div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-8 space-y-6">
|
||||
{/* Global Breadcrumbs - appears on all pages */}
|
||||
<div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-10 space-y-8">
|
||||
<Breadcrumbs />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="w-full"
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{/* Mobile Interface Components */}
|
||||
<MobileNav onMoreClick={() => setMobileSheetOpen(true)} />
|
||||
|
||||
{/* Mobile Navigation Sheet */}
|
||||
<MobileNavSheet
|
||||
isOpen={mobileSheetOpen}
|
||||
onClose={() => setMobileSheetOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Command Palette */}
|
||||
{/* System Utilities */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* Session Timeout Warning */}
|
||||
<SessionTimeoutWarning />
|
||||
|
||||
{/* Dev Tools */}
|
||||
<DevTools />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { Link, useLocation } from 'react-router-dom';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
Search,
|
||||
ChevronDown,
|
||||
Command,
|
||||
Bell,
|
||||
LogOut,
|
||||
User,
|
||||
Settings
|
||||
Settings,
|
||||
Shield,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { getFilteredNavSections, type NavSection } from '../../lib/navigation';
|
||||
|
|
@ -27,11 +26,8 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
const location = useLocation();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
// Handle scroll effect
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
};
|
||||
const handleScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
|
@ -39,39 +35,36 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
return (
|
||||
<header
|
||||
className={`
|
||||
sticky top-0 z-40 w-full border-b backdrop-blur-xl transition-all duration-300
|
||||
sticky top-0 z-40 w-full border-b backdrop-blur-xl transition-all duration-500
|
||||
${scrolled
|
||||
? 'bg-white/90 border-slate-200 shadow-sm dark:bg-slate-950/90 dark:border-slate-800'
|
||||
: 'bg-white/50 border-transparent dark:bg-slate-950/50'
|
||||
? 'bg-white/80 border-slate-200 dark:bg-[#050505]/80 dark:border-slate-800 shadow-2xl shadow-black/5'
|
||||
: 'bg-transparent border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Left: Logo & Desktop Nav */}
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3 group relative z-50">
|
||||
{/* Left: Branding & Navigation */}
|
||||
<div className="flex items-center gap-12">
|
||||
<Link to="/dashboard" className="flex items-center gap-3 group relative z-50">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-white dark:border-slate-900 animate-pulse" />
|
||||
<div className="w-9 h-9 rounded-xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20 group-hover:scale-105 transition-transform duration-500">
|
||||
<Shield className="text-white" size={20} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tight">
|
||||
777 Wolfpack
|
||||
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
|
||||
ERSEN OS
|
||||
</h1>
|
||||
<p className="text-[10px] font-medium text-slate-500 dark:text-slate-400 uppercase tracking-widest">
|
||||
Operations
|
||||
<p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
|
||||
Operational Node
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation - Sections Dropdowns */}
|
||||
<nav className="hidden lg:flex items-center gap-1">
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center gap-2">
|
||||
{sections.map(section => (
|
||||
<NavDropdown
|
||||
key={section.id}
|
||||
|
|
@ -83,24 +76,24 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Global Search Trigger */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Search Mock */}
|
||||
<button
|
||||
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
||||
className="hidden md:flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-slate-500 bg-slate-100/50 hover:bg-slate-100 hover:text-slate-700 dark:bg-slate-800/50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200 rounded-full border border-transparent hover:border-slate-200 dark:hover:border-slate-700 transition-all group"
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-slate-500 bg-slate-100/50 hover:bg-white dark:bg-slate-900/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
||||
>
|
||||
<Search size={14} className="group-hover:text-cyan-600 transition-colors" />
|
||||
<span>Search...</span>
|
||||
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded border border-slate-200 bg-white px-1.5 font-mono text-[10px] font-medium text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
<span className="text-xs">⌘</span>K
|
||||
<Terminal size={12} className="group-hover:text-indigo-500 transition-colors" />
|
||||
<span className="uppercase tracking-widest">Execute...</span>
|
||||
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-slate-500">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
||||
|
||||
<button className="relative p-2 text-slate-500 hover:text-cyan-600 dark:text-slate-400 dark:hover:text-cyan-400 transition-colors rounded-full hover:bg-slate-100 dark:hover:bg-slate-800">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full ring-2 ring-white dark:ring-slate-950" />
|
||||
<button className="relative p-2 text-slate-400 hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
||||
</button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
|
@ -109,7 +102,6 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
<UserDropdown />
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={onOpenMobileMenu}
|
||||
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
|
|
@ -142,10 +134,10 @@ function UserDropdown() {
|
|||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
className="flex items-center gap-2 p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300 flex items-center justify-center font-medium text-sm ring-2 ring-transparent hover:ring-cyan-200 dark:hover:ring-cyan-800 transition-all">
|
||||
{user?.email?.[0]?.toUpperCase() || 'U'}
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-900 text-white dark:bg-white dark:text-slate-900 flex items-center justify-center font-bold text-xs ring-2 ring-transparent hover:ring-indigo-500/20 transition-all">
|
||||
{user?.email?.[0]?.toUpperCase() || 'E'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -156,13 +148,14 @@ function UserDropdown() {
|
|||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="absolute top-full right-0 mt-2 w-56 p-2 bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-xl z-50"
|
||||
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-800 mb-1">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||
{user?.name || 'User'}
|
||||
<div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated As</p>
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
||||
{user?.name || 'Authorized Operator'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||
<p className="text-[10px] text-slate-500 truncate font-mono mt-1">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -170,10 +163,10 @@ function UserDropdown() {
|
|||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800"
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-slate-400 dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Settings
|
||||
<Settings size={14} />
|
||||
Terminal Config
|
||||
</Link>
|
||||
|
||||
<button
|
||||
|
|
@ -181,10 +174,10 @@ function UserDropdown() {
|
|||
setIsOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/20 rounded-xl transition-all"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sign Out
|
||||
<LogOut size={14} />
|
||||
Terminate Session
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -198,74 +191,62 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
|||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isActive = section.items.some(item => item.path === currentPath);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => setIsOpen(false), 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative px-0.5"
|
||||
onMouseEnter={() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsOpen(true); }}
|
||||
onMouseLeave={() => { timeoutRef.current = setTimeout(() => setIsOpen(false), 150); }}
|
||||
>
|
||||
<button
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all
|
||||
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
|
||||
${isActive || isOpen
|
||||
? 'text-cyan-700 bg-cyan-50/80 dark:text-cyan-400 dark:bg-cyan-900/20'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50/80 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/50'
|
||||
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
|
||||
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{section.label}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isOpen ? 'rotate-180 text-cyan-500' : 'text-slate-400'}`}
|
||||
size={10}
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-180 text-indigo-500' : 'opacity-40'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="absolute top-full left-0 mt-1 w-64 p-2 bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-slate-200/50 dark:border-slate-800/50 rounded-2xl shadow-xl shadow-slate-200/20 dark:shadow-black/40 ring-1 ring-slate-900/5 z-50 overflow-hidden"
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50"
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<p className="px-3 py-1.5 text-[9px] font-bold text-slate-400 uppercase tracking-[0.2em]">{section.label} Protocol</p>
|
||||
{section.items.map(item => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`
|
||||
group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all
|
||||
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
|
||||
${item.path === currentPath
|
||||
? 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/20 dark:text-cyan-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200'
|
||||
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
p-1.5 rounded-lg transition-colors
|
||||
${item.path === currentPath
|
||||
? 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400'
|
||||
: 'bg-slate-100 text-slate-500 group-hover:bg-cyan-50 group-hover:text-cyan-600 dark:bg-slate-800 dark:text-slate-500 dark:group-hover:bg-slate-700 dark:group-hover:text-cyan-400'
|
||||
}
|
||||
`}>
|
||||
<item.icon size={16} />
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg transition-all group-hover:scale-110",
|
||||
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||
)}>
|
||||
<item.icon size={14} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium leading-none">{item.label}</div>
|
||||
{/* Optional Description if we had it */}
|
||||
<div className="text-xs font-bold leading-none tracking-tight">{item.label}</div>
|
||||
</div>
|
||||
{item.path === currentPath && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,38 @@
|
|||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Route configuration for breadcrumbs
|
||||
// Route mapping for Ersen System
|
||||
const ROUTE_CONFIG: Record<string, { label: string; parent?: string }> = {
|
||||
'/': { label: 'Dashboard' },
|
||||
'/': { label: 'Command Center' },
|
||||
'/dashboard': { label: 'Operational Overview', parent: '/' },
|
||||
'/walkthrough': { label: 'Daily Walkthrough', parent: '/' },
|
||||
'/tasks': { label: 'Tasks', parent: '/' },
|
||||
'/tasks/templates': { label: 'SOP Templates', parent: '/tasks' },
|
||||
'/batches': { label: 'Batches', parent: '/' },
|
||||
'/rooms': { label: 'Rooms', parent: '/' },
|
||||
'/timeclock': { label: 'Timeclock', parent: '/' },
|
||||
'/timeclock': { label: 'Time & Attendance', parent: '/' },
|
||||
'/supplies': { label: 'Inventory', parent: '/' },
|
||||
'/reports': { label: 'Reports', parent: '/' },
|
||||
'/reports': { label: 'Advanced Analytics', parent: '/' },
|
||||
'/ipm': { label: 'IPM Dashboard', parent: '/' },
|
||||
'/touch-points': { label: 'Quick Actions', parent: '/' },
|
||||
'/visitors': { label: 'Visitors', parent: '/' },
|
||||
// Compliance section
|
||||
'/compliance/audit': { label: 'Audit Log', parent: '/' },
|
||||
'/compliance/documents': { label: 'SOP Library', parent: '/' },
|
||||
// Advanced dashboards
|
||||
'/environment': { label: 'Environment', parent: '/' },
|
||||
'/financial': { label: 'Financial', parent: '/' },
|
||||
'/touch-points': { label: 'Action Logs', parent: '/' },
|
||||
'/visitors': { label: 'Visitor Management', parent: '/' },
|
||||
'/compliance/audit': { label: 'Audit Trail', parent: '/' },
|
||||
'/compliance/documents': { label: 'Compliance Docs', parent: '/' },
|
||||
'/metrc': { label: 'METRC Sync', parent: '/' },
|
||||
'/environment': { label: 'Environmental Controls', parent: '/' },
|
||||
'/financial': { label: 'Financials', parent: '/' },
|
||||
'/insights': { label: 'AI Insights', parent: '/' },
|
||||
// Settings
|
||||
'/roles': { label: 'Roles', parent: '/settings' },
|
||||
'/settings': { label: 'Settings', parent: '/' },
|
||||
'/settings/walkthrough': { label: 'Walkthrough Settings', parent: '/settings' },
|
||||
'/roles': { label: 'Access Control', parent: '/settings' },
|
||||
'/settings': { label: 'System Settings', parent: '/' },
|
||||
'/settings/walkthrough': { label: 'Walkthrough Config', parent: '/settings' },
|
||||
};
|
||||
|
||||
// Dynamic route patterns
|
||||
const DYNAMIC_ROUTES: { pattern: RegExp; getLabel: (match: RegExpMatchArray) => string; parent: string }[] = [
|
||||
{ pattern: /^\/batches\/(.+)$/, getLabel: () => 'Batch Details', parent: '/batches' },
|
||||
{ pattern: /^\/rooms\/(.+)$/, getLabel: () => 'Room Details', parent: '/rooms' },
|
||||
|
|
@ -44,7 +43,6 @@ function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
|||
const crumbs: BreadcrumbItem[] = [];
|
||||
let currentPath = pathname;
|
||||
|
||||
// Check for dynamic routes first
|
||||
for (const route of DYNAMIC_ROUTES) {
|
||||
const match = pathname.match(route.pattern);
|
||||
if (match) {
|
||||
|
|
@ -54,10 +52,8 @@ function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
|||
}
|
||||
}
|
||||
|
||||
// Build breadcrumb chain from static routes
|
||||
while (currentPath && ROUTE_CONFIG[currentPath]) {
|
||||
const config = ROUTE_CONFIG[currentPath];
|
||||
// Don't add if already in crumbs (from dynamic route)
|
||||
if (!crumbs.find(c => c.path === currentPath)) {
|
||||
crumbs.unshift({ label: config.label, path: currentPath });
|
||||
}
|
||||
|
|
@ -71,41 +67,43 @@ export function Breadcrumbs() {
|
|||
const location = useLocation();
|
||||
const crumbs = getBreadcrumbs(location.pathname);
|
||||
|
||||
// Don't show breadcrumbs on dashboard
|
||||
if (location.pathname === '/' || crumbs.length <= 1) {
|
||||
// Filter home
|
||||
if (location.pathname === '/' || location.pathname === '/dashboard' || crumbs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-4">
|
||||
<ol className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<li>
|
||||
<nav aria-label="Breadcrumb" className="mb-0">
|
||||
<ol className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500 transition-all">
|
||||
<li className="flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="p-1.5 rounded-md hover:bg-tertiary text-tertiary hover:text-primary transition-colors flex items-center"
|
||||
to="/dashboard"
|
||||
className="p-1 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Home"
|
||||
>
|
||||
<Home size={16} />
|
||||
<Home size={12} />
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
|
||||
// Skip dashboard in the crumb list (we have the home icon)
|
||||
if (crumb.path === '/') return null;
|
||||
if (crumb.path === '/' || crumb.path === '/dashboard') return null;
|
||||
|
||||
return (
|
||||
<li key={crumb.path} className="flex items-center gap-1">
|
||||
<ChevronRight size={14} className="text-tertiary flex-shrink-0" />
|
||||
<ChevronRight size={10} className="opacity-40" />
|
||||
{isLast ? (
|
||||
<span className="px-2 py-1 font-medium text-primary">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -4 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="px-2 py-0.5 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</motion.span>
|
||||
) : (
|
||||
<Link
|
||||
to={crumb.path}
|
||||
className="px-2 py-1 rounded-md text-tertiary hover:text-primary hover:bg-tertiary transition-colors"
|
||||
className="px-2 py-0.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
|
|
@ -118,20 +116,17 @@ export function Breadcrumbs() {
|
|||
);
|
||||
}
|
||||
|
||||
// Page title helper
|
||||
export function getPageTitle(pathname: string): string {
|
||||
// Check dynamic routes first
|
||||
for (const route of DYNAMIC_ROUTES) {
|
||||
const match = pathname.match(route.pattern);
|
||||
if (match) {
|
||||
return `${route.getLabel(match)} | 777 Wolfpack`;
|
||||
return `${route.getLabel(match)} | Ersen OS`;
|
||||
}
|
||||
}
|
||||
|
||||
const config = ROUTE_CONFIG[pathname];
|
||||
if (config) {
|
||||
return `${config.label} | 777 Wolfpack`;
|
||||
return `${config.label} | Ersen OS`;
|
||||
}
|
||||
return '777 Wolfpack - Grow Ops Manager';
|
||||
return 'Ersen OS - Operational Management';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* Linear-inspired UI primitives
|
||||
* Consistent components for the new design system
|
||||
* Ersen UI Primitives
|
||||
* High-performance, high-density components for operational management.
|
||||
*/
|
||||
|
||||
// Page header with title and optional actions
|
||||
|
|
@ -11,21 +13,24 @@ interface PageHeaderProps {
|
|||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
export function PageHeader({ title, subtitle, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
<header className={cn("flex flex-col md:flex-row md:items-center justify-between gap-4 mb-2 animate-in fade-in slide-in-from-top-4 duration-500", className)}>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-slate-900 to-slate-500 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-secondary text-sm mt-1">{subtitle}</p>
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 max-w-2xl leading-relaxed">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 md:self-end">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -43,25 +48,27 @@ interface SectionHeaderProps {
|
|||
|
||||
export function SectionHeader({ icon: Icon, title, count, accent = 'default' }: SectionHeaderProps) {
|
||||
const accentClasses = {
|
||||
default: 'bg-tertiary text-secondary',
|
||||
accent: 'bg-accent-muted text-accent',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
default: 'text-slate-500 bg-slate-100 dark:bg-slate-800/50',
|
||||
accent: 'text-indigo-500 bg-indigo-500/10',
|
||||
success: 'text-emerald-500 bg-emerald-500/10',
|
||||
warning: 'text-amber-500 bg-amber-500/10',
|
||||
destructive: 'text-rose-500 bg-rose-500/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-2.5 mb-4 group">
|
||||
{Icon && (
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${accentClasses[accent]}`}>
|
||||
<Icon size={14} />
|
||||
<div className={cn("w-6 h-6 rounded-md flex items-center justify-center transition-transform group-hover:scale-110", accentClasses[accent])}>
|
||||
<Icon size={12} />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-tertiary uppercase tracking-wider">
|
||||
<h3 className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">
|
||||
{title}
|
||||
</h3>
|
||||
{count !== undefined && (
|
||||
<span className="badge">{count}</span>
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-[10px] font-bold text-slate-500 border border-slate-200 dark:border-slate-700/50">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -77,15 +84,15 @@ interface EmptyStateProps {
|
|||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="w-12 h-12 mx-auto bg-tertiary rounded-xl flex items-center justify-center mb-4">
|
||||
<Icon size={24} className="text-tertiary" />
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl bg-slate-50/30 dark:bg-slate-900/10">
|
||||
<div className="w-16 h-16 bg-white dark:bg-slate-900 rounded-2xl flex items-center justify-center shadow-xl mb-6 ring-1 ring-slate-200 dark:ring-slate-800">
|
||||
<Icon size={32} className="text-slate-300 dark:text-slate-700" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-tertiary text-sm mt-1 max-w-sm mx-auto">{description}</p>
|
||||
<p className="text-sm text-slate-500 mt-2 max-w-sm mx-auto leading-relaxed">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
{action && <div className="mt-8">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -101,30 +108,42 @@ interface MetricCardProps {
|
|||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, label, value, subtitle, accent = 'default', trend }: MetricCardProps) {
|
||||
const accentClasses = {
|
||||
default: 'bg-tertiary text-secondary',
|
||||
accent: 'bg-accent-muted text-accent',
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
destructive: 'bg-destructive-muted text-destructive',
|
||||
const accentColor = {
|
||||
default: 'text-slate-500 dark:text-slate-400',
|
||||
accent: 'text-indigo-500',
|
||||
success: 'text-emerald-500',
|
||||
warning: 'text-amber-500',
|
||||
destructive: 'text-rose-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-interactive p-4">
|
||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center mb-3 ${accentClasses[accent]}`}>
|
||||
<Icon size={16} />
|
||||
<motion.div
|
||||
whileHover={{ y: -2 }}
|
||||
className="group relative overflow-hidden bg-white dark:bg-[#0C0C0C] border border-slate-200 dark:border-slate-800/60 p-5 rounded-2xl transition-all hover:shadow-2xl hover:shadow-indigo-500/5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={cn("p-2.5 rounded-xl bg-slate-50 dark:bg-slate-900 ring-1 ring-slate-200 dark:ring-slate-800 transition-colors group-hover:ring-indigo-500/30", accentColor[accent])}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary tracking-tight">{value}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-tertiary">{label}</p>
|
||||
{trend && (
|
||||
<span className={`text-xs font-medium ${trend.positive ? 'text-success' : 'text-destructive'}`}>
|
||||
{trend.positive ? '+' : ''}{trend.value}%
|
||||
</span>
|
||||
<div className={cn(
|
||||
"text-[10px] font-bold px-2 py-0.5 rounded-full",
|
||||
trend.positive ? "text-emerald-600 bg-emerald-500/10" : "text-rose-600 bg-rose-500/10"
|
||||
)}>
|
||||
{trend.positive ? '↑' : '↓'} {trend.value}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <p className="text-[10px] text-tertiary mt-1">{subtitle}</p>}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{value}</p>
|
||||
<p className="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{label}</p>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800/50">
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-500">{subtitle}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -136,20 +155,18 @@ interface ListItemProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function ListItem({ children, onClick, active, className = '' }: ListItemProps) {
|
||||
export function ListItem({ children, onClick, active, className }: ListItemProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-md
|
||||
transition-colors duration-fast
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${active
|
||||
? 'bg-accent-muted border border-accent/20'
|
||||
: 'hover:bg-tertiary'
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-4 rounded-xl transition-all duration-300",
|
||||
onClick && "cursor-pointer",
|
||||
active
|
||||
? "bg-indigo-500/10 ring-1 ring-indigo-500/20"
|
||||
: "hover:bg-slate-50 dark:hover:bg-slate-900/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -160,24 +177,24 @@ export function ListItem({ children, onClick, active, className = '' }: ListItem
|
|||
interface ActionButtonProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
variant?: 'default' | 'accent' | 'success' | 'warning' | 'destructive';
|
||||
}
|
||||
|
||||
export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }: ActionButtonProps) {
|
||||
const variantClasses = {
|
||||
default: 'text-secondary hover:text-primary hover:bg-tertiary',
|
||||
accent: 'text-secondary hover:text-accent hover:bg-accent-muted',
|
||||
success: 'text-secondary hover:text-success hover:bg-success-muted',
|
||||
warning: 'text-secondary hover:text-warning hover:bg-warning-muted',
|
||||
destructive: 'text-secondary hover:text-destructive hover:bg-destructive-muted',
|
||||
const variants = {
|
||||
default: 'text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
|
||||
accent: 'text-indigo-500 hover:bg-indigo-500/10',
|
||||
success: 'text-emerald-500 hover:bg-emerald-500/10',
|
||||
warning: 'text-amber-500 hover:bg-amber-500/10',
|
||||
destructive: 'text-rose-500 hover:bg-rose-500/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={`p-2 rounded-md transition-colors duration-fast ${variantClasses[variant]}`}
|
||||
className={cn("p-2 rounded-lg transition-all active:scale-95", variants[variant])}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
|
|
@ -188,42 +205,39 @@ export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }
|
|||
interface StatusBadgeProps {
|
||||
status: 'active' | 'pending' | 'completed' | 'error' | 'default';
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
const statusClasses = {
|
||||
active: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
completed: 'badge-accent',
|
||||
error: 'badge-destructive',
|
||||
default: 'badge',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Active',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
error: 'Error',
|
||||
default: label || 'Unknown',
|
||||
export function StatusBadge({ status, label, className }: StatusBadgeProps) {
|
||||
const variants = {
|
||||
active: 'text-emerald-600 bg-emerald-500/10 border-emerald-500/20',
|
||||
pending: 'text-amber-600 bg-amber-500/10 border-amber-500/20',
|
||||
completed: 'text-indigo-600 bg-indigo-500/10 border-indigo-500/20',
|
||||
error: 'text-rose-600 bg-rose-500/10 border-rose-500/20',
|
||||
default: 'text-slate-500 bg-slate-100 border-slate-200 dark:bg-slate-800 dark:border-slate-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`badge ${statusClasses[status]}`}>
|
||||
{label || statusLabels[status]}
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border",
|
||||
variants[status],
|
||||
className
|
||||
)}>
|
||||
{label || status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse bg-slate-100 dark:bg-slate-800 rounded", className)} />;
|
||||
}
|
||||
|
||||
// Card skeleton
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<Skeleton className="w-8 h-8 rounded-md" />
|
||||
<div className="bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 p-4 rounded-xl space-y-3">
|
||||
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||
<Skeleton className="w-3/4 h-5" />
|
||||
<Skeleton className="w-1/2 h-4" />
|
||||
</div>
|
||||
|
|
@ -231,6 +245,6 @@ export function CardSkeleton() {
|
|||
}
|
||||
|
||||
// Divider
|
||||
export function Divider({ className = '' }: { className?: string }) {
|
||||
return <div className={`divider my-4 ${className}`} />;
|
||||
export function Divider({ className }: { className?: string }) {
|
||||
return <div className={cn("h-px w-full bg-slate-100 dark:bg-slate-800 my-4", className)} />;
|
||||
}
|
||||
|
|
|
|||
37
frontend/src/lib/animations.ts
Normal file
37
frontend/src/lib/animations.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export const pageVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.99,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1], // expo out
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.7, 0, 0.84, 0], // ease in
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const itemVariants = {
|
||||
initial: { opacity: 0, y: 10 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,22 +1,26 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Shield, Search, Filter, Download, ChevronLeft, ChevronRight,
|
||||
User, Clock, Eye, X, Loader2, FileText,
|
||||
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown
|
||||
Shield, Search, Filter, Download,
|
||||
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown,
|
||||
Eye, Clock, User
|
||||
} from 'lucide-react';
|
||||
import { auditApi, AuditLog, AuditLogSummary, AuditLogFilters } from '../lib/auditApi';
|
||||
import { PageHeader, MetricCard, EmptyState } from '../components/ui/LinearPrimitives';
|
||||
import { DataTable, Column } from '../components/ui/DataTable';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: React.ElementType; badge: string; label: string }> = {
|
||||
CREATE: { icon: FileText, badge: 'badge-success', label: 'Created' },
|
||||
UPDATE: { icon: Edit, badge: 'badge-accent', label: 'Updated' },
|
||||
DELETE: { icon: Trash2, badge: 'badge-destructive', label: 'Deleted' },
|
||||
LOGIN: { icon: LogIn, badge: 'badge-accent', label: 'Login' },
|
||||
LOGOUT: { icon: LogOut, badge: 'badge', label: 'Logout' },
|
||||
ACCESS: { icon: Eye, badge: 'badge-accent', label: 'Accessed' },
|
||||
EXPORT: { icon: FileDown, badge: 'badge-warning', label: 'Exported' },
|
||||
APPROVE: { icon: ThumbsUp, badge: 'badge-success', label: 'Approved' },
|
||||
REJECT: { icon: ThumbsDown, badge: 'badge-destructive', label: 'Rejected' }
|
||||
// --- Configuration ---
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: any; color: string; label: string }> = {
|
||||
CREATE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-500/10', label: 'Create' },
|
||||
UPDATE: { icon: Edit, color: 'text-blue-500 bg-blue-500/10', label: 'Update' },
|
||||
DELETE: { icon: Trash2, color: 'text-rose-500 bg-rose-500/10', label: 'Delete' },
|
||||
LOGIN: { icon: LogIn, color: 'text-cyan-500 bg-cyan-500/10', label: 'Login' },
|
||||
LOGOUT: { icon: LogOut, color: 'text-slate-500 bg-slate-500/10', label: 'Logout' },
|
||||
ACCESS: { icon: Eye, color: 'text-indigo-500 bg-indigo-500/10', label: 'Access' },
|
||||
EXPORT: { icon: FileDown, color: 'text-amber-500 bg-amber-500/10', label: 'Export' },
|
||||
APPROVE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-500/10', label: 'Approve' },
|
||||
REJECT: { icon: ThumbsDown, color: 'text-rose-500 bg-rose-500/10', label: 'Reject' }
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
|
|
@ -25,6 +29,8 @@ const ENTITY_LABELS: Record<string, string> = {
|
|||
Plant: 'Plant', Supply: 'Supply'
|
||||
};
|
||||
|
||||
// --- Page Component ---
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [summary, setSummary] = useState<AuditLogSummary | null>(null);
|
||||
|
|
@ -33,8 +39,6 @@ export default function AuditLogPage() {
|
|||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({ page: 1, limit: 25 });
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -81,328 +85,207 @@ export default function AuditLogPage() {
|
|||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string) {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
// --- Table Definition ---
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
cell: (log) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{new Date(log.timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'userName',
|
||||
header: 'User',
|
||||
cell: (log) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
|
||||
<User size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{log.userName || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
cell: (log) => {
|
||||
const config = ACTION_CONFIG[log.action] || { icon: Shield, color: 'text-slate-500 bg-slate-100', label: log.action };
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider",
|
||||
config.color
|
||||
)}>
|
||||
<Icon size={10} />
|
||||
{config.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getActionConfig(action: string) {
|
||||
return ACTION_CONFIG[action] || { icon: FileText, badge: 'badge', label: action };
|
||||
},
|
||||
{
|
||||
key: 'entity',
|
||||
header: 'Target',
|
||||
cell: (log) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{ENTITY_LABELS[log.entity] || log.entity}
|
||||
</span>
|
||||
{log.entityName && (
|
||||
<span className="text-[10px] text-slate-500 truncate max-w-[150px]">
|
||||
{log.entityName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
header: 'Details',
|
||||
hideOnMobile: true,
|
||||
cell: (log) => (
|
||||
<span className="text-xs text-slate-500 truncate max-w-[200px] block">
|
||||
{log.changes ? `Updated ${Object.keys(log.changes).join(', ')}` :
|
||||
log.ipAddress ? `IP: ${log.ipAddress}` : '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<PageHeader
|
||||
title="Audit Log"
|
||||
subtitle="Complete activity trail for compliance"
|
||||
title="System Audit"
|
||||
subtitle="High-fidelity activity trail for compliance and security."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`btn ${showFilters ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="btn btn-primary"
|
||||
className="btn btn-primary shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
{exporting ? 'Exporting...' : 'Export CSV'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{/* Metrics Row */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard icon={Shield} label="Total Events" value={summary.totalLogs.toLocaleString()} accent="accent" />
|
||||
<MetricCard icon={FileText} label="Creates" value={summary.byAction['CREATE'] || 0} accent="success" />
|
||||
<MetricCard icon={ThumbsUp} label="Creates" value={summary.byAction['CREATE'] || 0} accent="success" />
|
||||
<MetricCard icon={Edit} label="Updates" value={summary.byAction['UPDATE'] || 0} accent="accent" />
|
||||
<MetricCard icon={Trash2} label="Deletes" value={summary.byAction['DELETE'] || 0} accent="destructive" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filters.entity || ''}
|
||||
onChange={(e) => setFilters({ ...filters, entity: e.target.value || undefined, page: 1 })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Entities</option>
|
||||
{summary?.byEntity.map(e => (
|
||||
<option key={e.entity} value={e.entity}>
|
||||
{ENTITY_LABELS[e.entity] || e.entity} ({e.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => setFilters({ ...filters, action: e.target.value || undefined, page: 1 })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="CREATE">Create</option>
|
||||
<option value="UPDATE">Update</option>
|
||||
<option value="DELETE">Delete</option>
|
||||
<option value="LOGIN">Login</option>
|
||||
<option value="LOGOUT">Logout</option>
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
|
||||
className="input flex-1 h-10 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
|
||||
className="input flex-1 h-10 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(filters.entity || filters.action || filters.startDate || filters.endDate) && (
|
||||
<button
|
||||
onClick={() => setFilters({ page: 1, limit: 25 })}
|
||||
className="mt-3 text-sm text-accent hover:underline flex items-center gap-1"
|
||||
>
|
||||
<X size={14} /> Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="animate-spin text-tertiary" size={24} />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
{/* Unified Table View */}
|
||||
<DataTable
|
||||
data={logs}
|
||||
columns={columns}
|
||||
isLoading={loading}
|
||||
onRowClick={(log) => setSelectedLog(log)}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No audit logs found"
|
||||
description="Activity will appear here as users interact with the system."
|
||||
title="No activity recorded"
|
||||
description="Audit logs will appear here once users begin interacting with the system."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-secondary border-b border-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Time</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Action</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Entity</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Details</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-tertiary uppercase tracking-wider">View</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{logs.map(log => {
|
||||
const config = getActionConfig(log.action);
|
||||
return (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-tertiary cursor-pointer transition-colors duration-fast"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-secondary">
|
||||
<Clock size={12} className="text-tertiary" />
|
||||
<span>{formatTimestamp(log.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-tertiary mt-0.5">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-tertiary flex items-center justify-center">
|
||||
<User size={12} className="text-tertiary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{log.userName || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`${config.badge} text-[10px]`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{ENTITY_LABELS[log.entity] || log.entity}
|
||||
</span>
|
||||
{log.entityName && (
|
||||
<p className="text-xs text-tertiary truncate max-w-[120px]">{log.entityName}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-xs text-tertiary truncate max-w-[150px]">
|
||||
{log.changes ? `Changed: ${Object.keys(log.changes).join(', ')}` :
|
||||
log.ipAddress ? `IP: ${log.ipAddress}` : '—'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button className="p-1.5 rounded hover:bg-tertiary text-tertiary hover:text-primary transition-colors">
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-subtle">
|
||||
<p className="text-xs text-tertiary">
|
||||
{((pagination.page - 1) * pagination.limit) + 1}–{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}
|
||||
{/* Simplified Pagination Footer */}
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
Showing <span className="font-medium text-slate-700 dark:text-slate-300">{((pagination.page - 1) * pagination.limit) + 1}–{Math.min(pagination.page * pagination.limit, pagination.total)}</span> of {pagination.total} events
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: pagination.page - 1 })}
|
||||
disabled={pagination.page <= 1}
|
||||
className="btn btn-ghost p-2 disabled:opacity-50"
|
||||
className="btn btn-secondary btn-sm h-8 px-2 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-secondary px-2">{pagination.page}/{pagination.pages}</span>
|
||||
<div className="flex items-center px-3 text-xs font-medium text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 rounded-md">
|
||||
{pagination.page} / {pagination.pages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: pagination.page + 1 })}
|
||||
disabled={pagination.page >= pagination.pages}
|
||||
className="btn btn-ghost p-2 disabled:opacity-50"
|
||||
className="btn btn-secondary btn-sm h-8 px-2 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{/* Detail View Modal (Keep existing logic but styled) */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in" onClick={() => setSelectedLog(null)}>
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm flex items-center justify-center p-4 z-[100] animate-in fade-in" onClick={() => setSelectedLog(null)}>
|
||||
<div
|
||||
className="card max-w-2xl w-full max-h-[80vh] overflow-hidden animate-scale-in"
|
||||
className="bg-white dark:bg-[#0A0A0A] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col scale-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-subtle flex justify-between items-start">
|
||||
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-primary">Audit Log Detail</h3>
|
||||
<p className="text-xs text-tertiary font-mono">ID: {selectedLog.id.slice(0, 8)}...</p>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">Event Details</h3>
|
||||
<p className="text-xs text-indigo-500 font-mono mt-1 uppercase tracking-widest">{selectedLog.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="p-2 rounded-md hover:bg-tertiary transition-colors"
|
||||
>
|
||||
<X size={16} className="text-tertiary" />
|
||||
<button onClick={() => setSelectedLog(null)} className="p-2 hover:bg-slate-200 dark:hover:bg-slate-800 rounded-full transition-colors">
|
||||
<Search className="rotate-45" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto max-h-[60vh] space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Timestamp</p>
|
||||
<p className="text-sm text-primary">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">User</p>
|
||||
<p className="text-sm text-primary">{selectedLog.userName || 'System'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Action</p>
|
||||
<span className={`${getActionConfig(selectedLog.action).badge} text-[10px]`}>
|
||||
{getActionConfig(selectedLog.action).label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Entity</p>
|
||||
<p className="text-sm text-primary">
|
||||
{ENTITY_LABELS[selectedLog.entity] || selectedLog.entity}
|
||||
{selectedLog.entityName && <span className="text-tertiary ml-1">({selectedLog.entityName})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.ipAddress && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">IP Address</p>
|
||||
<p className="text-sm font-mono text-primary">{selectedLog.ipAddress}</p>
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
{/* Change set visualization */}
|
||||
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Property Changes</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedLog.changes).map(([key, val]: [string, any]) => (
|
||||
<div key={key} className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg border border-slate-100 dark:border-slate-800/50">
|
||||
<div className="text-xs font-bold text-slate-500 mb-2">{key}</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex-1 p-2 bg-rose-500/5 border border-rose-500/10 rounded text-rose-500 line-through truncate">
|
||||
{JSON.stringify(val.from)}
|
||||
</div>
|
||||
<Clock size={14} className="text-slate-300" />
|
||||
<div className="flex-1 p-2 bg-emerald-500/5 border border-emerald-500/10 rounded text-emerald-500 truncate">
|
||||
{JSON.stringify(val.to)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">Changes</p>
|
||||
<div className="bg-tertiary rounded-md p-3 space-y-2">
|
||||
{Object.entries(selectedLog.changes).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex items-start gap-2 text-xs">
|
||||
<span className="font-medium text-secondary min-w-[80px]">{key}:</span>
|
||||
<span className="text-destructive line-through">{JSON.stringify(value.from)}</span>
|
||||
<span className="text-tertiary">→</span>
|
||||
<span className="text-success">{JSON.stringify(value.to)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center bg-slate-50 dark:bg-slate-900 rounded-2xl border border-dashed border-slate-200 dark:border-slate-800">
|
||||
<Clock className="mx-auto mb-3 text-slate-300" size={32} />
|
||||
<p className="text-sm text-slate-500">No attribute-level changes recorded for this event.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedLog.before || selectedLog.after) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedLog.before && (
|
||||
<div className="grid grid-cols-2 gap-6 bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl">
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Before</p>
|
||||
<pre className="text-[10px] bg-destructive-muted p-2 rounded-md overflow-x-auto max-h-32 text-destructive font-mono">
|
||||
{JSON.stringify(selectedLog.before, null, 2)}
|
||||
</pre>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Actor</p>
|
||||
<p className="text-sm font-medium">{selectedLog.userName || 'System Processor'}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog.after && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">After</p>
|
||||
<pre className="text-[10px] bg-success-muted p-2 rounded-md overflow-x-auto max-h-32 text-success font-mono">
|
||||
{JSON.stringify(selectedLog.after, null, 2)}
|
||||
</pre>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Source IP</p>
|
||||
<p className="text-sm font-mono">{selectedLog.ipAddress || 'Internal'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Shield, ArrowRight, Loader2, Key, ChevronRight, Lock } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { DevTools } from '../components/dev/DevTools';
|
||||
import { Loader2, ArrowRight } from 'lucide-react';
|
||||
import { pageVariants, itemVariants } from '../lib/animations';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
|
@ -13,9 +15,8 @@ export default function LoginPage() {
|
|||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = '777 Wolfpack - Login';
|
||||
document.title = 'Ersen OS | Authentication';
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
|
@ -26,127 +27,156 @@ export default function LoginPage() {
|
|||
try {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
login(data.accessToken, data.refreshToken, data.user);
|
||||
navigate('/');
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed');
|
||||
setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
|
||||
{/* Background subtle pattern */}
|
||||
<div
|
||||
className="fixed inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen bg-[#050505] text-slate-100 flex overflow-hidden font-sans selection:bg-indigo-500/30">
|
||||
{/* Left Side: Brand/Visual */}
|
||||
<div className="hidden lg:flex flex-1 relative items-center justify-center border-r border-slate-800/50 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-slate-900 via-[#050505] to-[#050505]">
|
||||
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(#1e293b 1px, transparent 1px)', backgroundSize: '32px 32px' }} />
|
||||
|
||||
{/* Main container */}
|
||||
<div className="w-full max-w-[380px] animate-in">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src="/assets/logo-777-wolfpack.jpg"
|
||||
alt="777 Wolfpack"
|
||||
className="w-20 h-20 rounded-2xl shadow-lg transition-transform duration-slow ease-out-expo group-hover:scale-105"
|
||||
/>
|
||||
{/* Subtle glow on hover */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent opacity-0 group-hover:opacity-10 transition-opacity duration-normal blur-xl" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className="relative z-10 flex flex-col items-center text-center p-12 space-y-8"
|
||||
>
|
||||
<div className="w-24 h-24 rounded-3xl bg-indigo-500 flex items-center justify-center shadow-2xl shadow-indigo-500/20 rotate-3">
|
||||
<Lock className="text-white -rotate-3" size={48} strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card p-8 animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-secondary text-sm mt-2">
|
||||
Sign in to 777 Wolfpack
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-5xl font-bold tracking-tighter bg-gradient-to-b from-white to-slate-500 bg-clip-text text-transparent">
|
||||
ERSEN OS
|
||||
</h2>
|
||||
<p className="text-slate-500 font-mono text-sm tracking-[0.3em] uppercase">
|
||||
Operational Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-3 bg-destructive-muted rounded-md animate-scale-in">
|
||||
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
<div className="flex items-center gap-6 mt-12 bg-slate-900/50 backdrop-blur-md px-6 py-3 rounded-full border border-slate-800/50 shadow-xl">
|
||||
<div className="flex -space-x-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-8 h-8 rounded-full border-2 border-[#050505] bg-slate-800" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 font-medium">Security Verified Nodes Active</p>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Email */}
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute bottom-12 left-12 text-[10px] font-mono text-slate-700 tracking-tighter uppercase leading-none">
|
||||
Distributed ledger validated<br />
|
||||
Secure tunnel engaged<br />
|
||||
v4.2.0-stable
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Form */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 lg:p-24 relative">
|
||||
<motion.div
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="w-full max-w-sm space-y-12"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<motion.div variants={itemVariants} className="lg:hidden w-12 h-12 rounded-xl bg-indigo-500 flex items-center justify-center mb-8 shadow-lg shadow-indigo-500/20">
|
||||
<Lock className="text-white" size={24} />
|
||||
</motion.div>
|
||||
<motion.h1 variants={itemVariants} className="text-3xl font-bold tracking-tight text-white lg:text-4xl">
|
||||
Authenticate
|
||||
</motion.h1>
|
||||
<motion.p variants={itemVariants} className="text-slate-400 text-sm">
|
||||
Access your mission-critical operations portal.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.form variants={itemVariants} onSubmit={handleSubmit} className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<Shield className="text-rose-500 flex-shrink-0 mt-0.5" size={16} />
|
||||
<p className="text-sm text-rose-500 leading-snug">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Secure Email</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="input w-full"
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="terminal@ersen.xyz"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-xl bg-indigo-500/5 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Access Key</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="input w-full"
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-xl bg-indigo-500/5 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn btn-primary w-full h-11 text-sm font-medium group"
|
||||
className="group relative 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] disabled:opacity-50 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-[100%] group-hover:translate-x-[100%] transition-transform duration-1000" />
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
<span>Continue</span>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="transition-transform duration-fast group-hover:translate-x-0.5"
|
||||
/>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Initiate Protocol</span>
|
||||
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center mt-6 text-xs text-tertiary animate-in" style={{ animationDelay: '150ms' }}>
|
||||
Authorized personnel only
|
||||
<motion.div variants={itemVariants} className="pt-24 border-t border-slate-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold text-slate-600 uppercase tracking-widest">Global Security</p>
|
||||
<div className="h-px flex-1 bg-slate-800 mx-4" />
|
||||
<Shield className="text-slate-700" size={14} />
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed text-center">
|
||||
This system is for the exclusive use of authorized Ersen personnel. All activity is logged and monitored for compliance integrity.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Dev Tools */}
|
||||
<DevTools />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,26 +2,24 @@ import { useState, useEffect } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Cloud, CloudOff, RefreshCw, Download, AlertTriangle,
|
||||
CheckCircle, XCircle, Clock, MapPin, ArrowUpDown,
|
||||
FileText, Loader2, ExternalLink, Filter, Box
|
||||
CheckCircle, Clock, MapPin, ArrowUpDown,
|
||||
FileText, ExternalLink, Filter, Box
|
||||
} from 'lucide-react';
|
||||
import { metrcApi, MetrcLocation, MetrcDiscrepancy, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
import Hero1 from '../components/aura/Hero';
|
||||
import { metrcApi, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi';
|
||||
import { PageHeader, EmptyState, MetricCard } from '../components/ui/LinearPrimitives';
|
||||
import { DataTable, Column } from '../components/ui/DataTable';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
CRITICAL: { color: 'text-red-600 bg-red-100 dark:bg-red-900/30', label: 'Critical' },
|
||||
HIGH: { color: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30', label: 'High' },
|
||||
MEDIUM: { color: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30', label: 'Medium' },
|
||||
LOW: { color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30', label: 'Low' }
|
||||
CRITICAL: { color: 'text-rose-500 bg-rose-500/10', label: 'Critical' },
|
||||
HIGH: { color: 'text-amber-500 bg-amber-500/10', label: 'High' },
|
||||
MEDIUM: { color: 'text-yellow-500 bg-yellow-500/10', label: 'Medium' },
|
||||
LOW: { color: 'text-blue-500 bg-blue-500/10', label: 'Low' }
|
||||
};
|
||||
|
||||
const DISCREPANCY_TYPE_LABELS = {
|
||||
MISSING_IN_METRC: 'Missing in METRC',
|
||||
MISSING_LOCALLY: 'Missing Locally',
|
||||
LOCATION_MISMATCH: 'Location Mismatch',
|
||||
STATUS_MISMATCH: 'Status Mismatch'
|
||||
};
|
||||
// --- Page Component ---
|
||||
|
||||
export default function MetrcDashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -83,451 +81,236 @@ export default function MetrcDashboardPage() {
|
|||
p.room.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) || [];
|
||||
|
||||
// METRC connection status (simulated - would check actual API in production)
|
||||
const metrcEnabled = false; // Set via env var in production
|
||||
const connectionStatus = metrcEnabled ? 'connected' : 'demo';
|
||||
// --- Table Column Definitions ---
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<Hero1 />
|
||||
|
||||
<div className="px-6 space-y-6">
|
||||
{/* Connection Status Banner */}
|
||||
<div className={`card p-4 ${connectionStatus === 'demo' ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800' : 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{connectionStatus === 'demo' ? (
|
||||
<>
|
||||
<CloudOff className="text-amber-600 dark:text-amber-400" size={24} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">Demo Mode</p>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
METRC API not connected. Data shown is for demonstration purposes.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://api-ca.metrc.com/Documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost text-amber-700 dark:text-amber-300 text-sm"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
API Docs
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="text-emerald-600 dark:text-emerald-400" size={24} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-emerald-800 dark:text-emerald-200">Connected to METRC</p>
|
||||
<p className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
Last sync: {lastSync ? lastSync.toLocaleString() : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="text-emerald-600" size={20} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 p-1 bg-secondary rounded-lg">
|
||||
const plantColumns: Column<any>[] = [
|
||||
{
|
||||
[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'plants', label: 'Plant Locations' },
|
||||
{ id: 'audit', label: 'Audit Trail' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${activeTab === tab.id
|
||||
? 'bg-primary text-primary shadow-sm'
|
||||
: 'text-secondary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
key: 'tagNumber',
|
||||
header: 'Plant Tag',
|
||||
cell: (plant) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-bold text-slate-800 dark:text-slate-200">{plant.tagNumber}</span>
|
||||
<span className="text-[10px] text-indigo-500 font-medium">METRC ID</span>
|
||||
</div>
|
||||
|
||||
)
|
||||
},
|
||||
{
|
||||
loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" >
|
||||
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<MapPin className="text-emerald-600 dark:text-emerald-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-primary">{report?.plantCount || 0}</p>
|
||||
<p className="text-xs text-tertiary">Total Plants</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<ArrowUpDown className="text-blue-600 dark:text-blue-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-primary">{audit?.summary.totalMoves || 0}</p>
|
||||
<p className="text-xs text-tertiary">Plant Moves (30d)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle className="text-green-600 dark:text-green-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-primary">100%</p>
|
||||
<p className="text-xs text-tertiary">Sync Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="text-amber-600 dark:text-amber-400" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-primary">0</p>
|
||||
<p className="text-xs text-tertiary">Discrepancies</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="card p-4">
|
||||
<h3 className="font-medium text-primary mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<button className="btn btn-ghost justify-start h-auto py-3" onClick={handleExportCsv}>
|
||||
<Download size={18} className="text-accent" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Export CSV</p>
|
||||
<p className="text-xs text-tertiary">For manual METRC upload</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost justify-start h-auto py-3" onClick={handleSync}>
|
||||
<RefreshCw size={18} className="text-accent" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Sync All Plants</p>
|
||||
<p className="text-xs text-tertiary">Update all locations</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost justify-start h-auto py-3" onClick={() => setActiveTab('audit')}>
|
||||
<FileText size={18} className="text-accent" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">View Audit</p>
|
||||
<p className="text-xs text-tertiary">Compliance report</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://ca.metrc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost justify-start h-auto py-3"
|
||||
>
|
||||
<ExternalLink size={18} className="text-accent" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Open METRC</p>
|
||||
<p className="text-xs text-tertiary">State portal</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Moves */}
|
||||
{audit && audit.recentMoves.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h3 className="font-medium text-primary">Recent Plant Moves</h3>
|
||||
<p className="text-xs text-tertiary">Location changes requiring METRC update</p>
|
||||
</div>
|
||||
<div className="divide-y divide-subtle">
|
||||
{audit.recentMoves.slice(0, 5).map((move: any, i: number) => (
|
||||
<div key={i} className="p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<ArrowUpDown size={18} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-primary text-sm">{move.plantTag}</p>
|
||||
<p className="text-xs text-tertiary truncate">
|
||||
{move.from} → {move.to}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-tertiary">
|
||||
{new Date(move.movedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plants Tab */}
|
||||
{activeTab === 'plants' && (
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by tag or room..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plant List */}
|
||||
{filteredPlants.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
title="No plants found"
|
||||
description="No plant location data available."
|
||||
/>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-secondary">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-secondary">Tag</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">METRC Location</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">Room</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">Section</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">Position</th>
|
||||
<th className="text-center p-3 font-medium text-secondary">Status</th>
|
||||
<th className="text-right p-3 font-medium text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{filteredPlants.slice(0, 50).map((plant: any) => (
|
||||
<tr key={plant.plantId} className="hover:bg-secondary/50">
|
||||
<td className="p-3 font-mono text-xs font-bold">{plant.tagNumber}</td>
|
||||
<td className="p-3 font-mono text-xs text-accent">{plant.location || '-'}</td>
|
||||
<td className="p-3">{plant.room}</td>
|
||||
<td className="p-3">{plant.section || '-'}</td>
|
||||
<td className="p-3">{plant.position || '-'}</td>
|
||||
<td className="p-3 text-center">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
key: 'location',
|
||||
header: 'METRC Location',
|
||||
cell: (plant) => <span className="text-xs font-mono text-slate-500">{plant.location || '—'}</span>
|
||||
},
|
||||
{
|
||||
key: 'room',
|
||||
header: 'Local Room',
|
||||
cell: (plant) => <span className="text-sm text-slate-700 dark:text-slate-300">{plant.room}</span>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
className: 'text-center',
|
||||
cell: () => (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-500">
|
||||
<CheckCircle size={10} />
|
||||
Synced
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'text-right',
|
||||
cell: (plant) => (
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setSelectedHistoryPlant(plant)}
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
title="View History"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedHistoryPlant(plant); }}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md text-slate-500"
|
||||
>
|
||||
<Clock size={16} className="text-tertiary" />
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<Link
|
||||
to={`/facility/3d?plant=${plant.tagNumber}`}
|
||||
className="btn btn-ghost btn-sm btn-square text-accent"
|
||||
title="View in 3D"
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md text-indigo-500"
|
||||
>
|
||||
<Box size={16} />
|
||||
<Box size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredPlants.length > 50 && (
|
||||
<div className="p-4 bg-secondary text-center text-sm text-tertiary">
|
||||
Showing 50 of {filteredPlants.length} plants
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Tab */}
|
||||
{activeTab === 'audit' && audit && (
|
||||
<div className="space-y-6">
|
||||
{/* Audit Summary */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-primary">METRC Compliance Audit</h3>
|
||||
<p className="text-xs text-tertiary">
|
||||
{new Date(audit.dateRange.start).toLocaleDateString()} - {new Date(audit.dateRange.end).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-success">
|
||||
<CheckCircle size={12} />
|
||||
Compliant
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-3 bg-secondary rounded-lg">
|
||||
<p className="text-2xl font-bold text-primary">{audit.summary.totalPlants}</p>
|
||||
<p className="text-xs text-tertiary">Total Plants</p>
|
||||
</div>
|
||||
<div className="p-3 bg-secondary rounded-lg">
|
||||
<p className="text-2xl font-bold text-primary">{audit.summary.totalMoves}</p>
|
||||
<p className="text-xs text-tertiary">Location Changes</p>
|
||||
</div>
|
||||
<div className="p-3 bg-secondary rounded-lg">
|
||||
<p className="text-2xl font-bold text-primary">{audit.summary.uniquePlantsMoved}</p>
|
||||
<p className="text-xs text-tertiary">Plants Moved</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Moves Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle">
|
||||
<h3 className="font-medium text-primary">Location Change History</h3>
|
||||
</div>
|
||||
{audit.recentMoves.length === 0 ? (
|
||||
<div className="p-8 text-center text-tertiary">
|
||||
<Clock size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>No location changes in this period</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-secondary">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-secondary">Plant Tag</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">From</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">To</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">Date</th>
|
||||
<th className="text-left p-3 font-medium text-secondary">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{audit.recentMoves.map((move: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-secondary/50">
|
||||
<td className="p-3 font-mono text-xs">{move.plantTag}</td>
|
||||
<td className="p-3 text-tertiary">{move.from}</td>
|
||||
<td className="p-3 text-accent">{move.to}</td>
|
||||
<td className="p-3">
|
||||
{new Date(move.movedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-3 text-tertiary">{move.reason || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{/* Plant History Modal */}
|
||||
];
|
||||
|
||||
const auditColumns: Column<any>[] = [
|
||||
{
|
||||
selectedHistoryPlant && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
|
||||
<div className="card w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95">
|
||||
<div className="p-4 border-b border-subtle flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-primary">Plant History</h3>
|
||||
<p className="text-sm text-accent font-mono">{selectedHistoryPlant.tagNumber}</p>
|
||||
key: 'plantTag',
|
||||
header: 'Plant Tag',
|
||||
cell: (move) => <span className="font-mono text-xs font-bold">{move.plantTag}</span>
|
||||
},
|
||||
{
|
||||
key: 'from',
|
||||
header: 'Previous Room',
|
||||
cell: (move) => <span className="text-slate-500">{move.from}</span>
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
header: 'New Room',
|
||||
cell: (move) => <span className="text-indigo-500 font-medium">{move.to}</span>
|
||||
},
|
||||
{
|
||||
key: 'movedAt',
|
||||
header: 'Timestamp',
|
||||
cell: (move) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs">{new Date(move.movedAt).toLocaleDateString()}</span>
|
||||
<span className="text-[10px] text-slate-400">{new Date(move.movedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<PageHeader
|
||||
title="METRC Compliance"
|
||||
subtitle="The authoritative state sync interface for facility compliance."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedHistoryPlant(null)}
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="btn btn-secondary shadow-sm"
|
||||
>
|
||||
<XCircle size={20} />
|
||||
<RefreshCw size={16} className={syncing ? "animate-spin" : ""} />
|
||||
Sync All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="btn btn-primary shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Download size={16} />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-0 overflow-y-auto flex-1">
|
||||
{/* Current Status */}
|
||||
<div className="p-4 bg-secondary border-b border-subtle grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-tertiary text-xs uppercase tracking-wider">Current Room</p>
|
||||
<p className="font-medium text-primary">{selectedHistoryPlant.room}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tertiary text-xs uppercase tracking-wider">Current Section</p>
|
||||
<p className="font-medium text-primary">{selectedHistoryPlant.section || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-sm font-medium text-tertiary mb-4 uppercase tracking-wider">Movement Log</h4>
|
||||
{audit?.recentMoves.filter((m: any) => m.plantTag === selectedHistoryPlant.tagNumber).length === 0 ? (
|
||||
<div className="text-center py-8 text-tertiary bg-secondary/30 rounded-lg">
|
||||
<Clock className="mx-auto mb-2 opacity-30" />
|
||||
<p>No recorded movements found for this plant.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="relative border-s border-gray-200 dark:border-gray-700 ms-3 space-y-6">
|
||||
{audit?.recentMoves
|
||||
.filter((m: any) => m.plantTag === selectedHistoryPlant.tagNumber)
|
||||
.sort((a: any, b: any) => new Date(b.movedAt).getTime() - new Date(a.movedAt).getTime())
|
||||
.map((move: any, i: number) => (
|
||||
<li key={i} className="mb-10 ms-4">
|
||||
<div className="absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700"></div>
|
||||
<time className="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500">
|
||||
{new Date(move.movedAt).toLocaleString()}
|
||||
</time>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mt-1">
|
||||
Moved to <span className="text-accent">{move.to}</span>
|
||||
</h3>
|
||||
<p className="mb-4 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
From {move.from} • Reason: {move.reason || 'Routine Maintenance'}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-subtle bg-secondary/50 flex justify-end">
|
||||
<Link
|
||||
to={`/facility/3d?plant=${selectedHistoryPlant.tagNumber}`}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
<Box size={16} className="mr-2" />
|
||||
Locate in 3D
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Connection Banner */}
|
||||
<div className="p-4 rounded-xl border border-amber-500/20 bg-amber-500/5 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500">
|
||||
<CloudOff size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-bold text-amber-900 dark:text-amber-200">Sandbox / Demo Environment</h4>
|
||||
<p className="text-xs text-amber-500">The METRC production API is disabled. You are viewing simulated system data.</p>
|
||||
</div>
|
||||
<a href="https://api-ca.metrc.com" target="_blank" rel="noreferrer" className="text-xs font-bold text-amber-600 hover:underline flex items-center gap-1">
|
||||
API Docs <ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{report && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard icon={Box} label="Active Tags" value={report.plantCount.toLocaleString()} accent="accent" />
|
||||
<MetricCard icon={ArrowUpDown} label="Total Moves" value={audit?.summary.totalMoves || 0} accent="accent" />
|
||||
<MetricCard icon={CheckCircle} label="Success Rate" value="100%" accent="success" />
|
||||
<MetricCard icon={AlertTriangle} label="Issues" value="0" accent="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-slate-900 rounded-xl w-fit">
|
||||
{['overview', 'plants', 'audit'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as any)}
|
||||
className={cn(
|
||||
"px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all",
|
||||
activeTab === tab
|
||||
? "bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest px-2">Recent Compliance Events</h3>
|
||||
<DataTable
|
||||
data={audit?.recentMoves.slice(0, 5) || []}
|
||||
columns={auditColumns}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="card p-6 flex flex-col justify-center items-center text-center space-y-4 border-dashed border-2">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">System in Sync</h3>
|
||||
<p className="text-sm text-slate-500">No discrepancies detected between local state and METRC tracking ID logs.</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm">Download Full Reconciliation Report</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'plants' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input
|
||||
placeholder="Filter by Tag ID or Room Name..."
|
||||
className="input pl-10 h-10 w-full"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredPlants}
|
||||
columns={plantColumns}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<EmptyState icon={MapPin} title="No plants found" description="Try adjusting your search or filters." />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-4">
|
||||
<DataTable
|
||||
data={audit?.recentMoves || []}
|
||||
columns={auditColumns}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Modal placeholder (Styled) */}
|
||||
{selectedHistoryPlant && (
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-md z-[100] flex items-center justify-center p-4" onClick={() => setSelectedHistoryPlant(null)}>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-lg w-full p-8 animate-in zoom-in" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-500 mx-auto">
|
||||
<Clock size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Plant Lifecycle History</h3>
|
||||
<p className="text-sm text-slate-500">Tracking history for <span className="font-mono font-bold text-slate-900 dark:text-slate-100">{selectedHistoryPlant.tagNumber}</span></p>
|
||||
<div className="py-8 border-y border-dashed">
|
||||
{/* Simplified timeline */}
|
||||
<p className="text-xs text-slate-400 italic">Historical chain-of-custody logs are being processed...</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedHistoryPlant(null)} className="btn btn-primary w-full">Close View</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue