feat(theme): Universal Ersen OS Refactor
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Implemented 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:
fullsizemalt 2025-12-19 19:01:09 -08:00
parent 56948c20ec
commit 4fd7aed250
8 changed files with 808 additions and 1079 deletions

View file

@ -1,5 +1,6 @@
import { useState } from 'react'; 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 { useAuth } from '../context/AuthContext';
import { Navbar } from './aura/Navbar'; import { Navbar } from './aura/Navbar';
import { MobileNav } from './layout/MobileNav'; import { MobileNav } from './layout/MobileNav';
@ -10,14 +11,15 @@ import { PageTitleUpdater } from '../hooks/usePageTitle';
import AnnouncementBanner from './AnnouncementBanner'; import AnnouncementBanner from './AnnouncementBanner';
import { DevTools } from './dev/DevTools'; import { DevTools } from './dev/DevTools';
import { Breadcrumbs } from './ui/Breadcrumbs'; import { Breadcrumbs } from './ui/Breadcrumbs';
import { pageVariants } from '../lib/animations';
export default function Layout() { export default function Layout() {
const { user } = useAuth(); const location = useLocation();
const [mobileSheetOpen, setMobileSheetOpen] = useState(false); const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
return ( return (
<div className="flex flex-col min-h-screen bg-slate-50 dark:bg-slate-900 transition-colors duration-300"> <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">
{/* Skip to main content link (accessibility) */} {/* Accessibility: Skip to main content */}
<a <a
href="#main-content" 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" 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 Skip to main content
</a> </a>
{/* Top Navigation Bar */} {/* Global Navbar */}
<Navbar onOpenMobileMenu={() => setMobileSheetOpen(true)} /> <Navbar onOpenMobileMenu={() => setMobileSheetOpen(true)} />
<div className="flex flex-1 relative"> <div className="flex flex-1 relative overflow-hidden">
{/* Main Content */}
<main <main
id="main-content" 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" role="main"
> >
<PageTitleUpdater /> <PageTitleUpdater />
<AnnouncementBanner /> <AnnouncementBanner />
<div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-8 space-y-6"> <div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-10 space-y-8">
{/* Global Breadcrumbs - appears on all pages */}
<Breadcrumbs /> <Breadcrumbs />
<Outlet />
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
className="w-full"
>
<Outlet />
</motion.div>
</AnimatePresence>
</div> </div>
</main> </main>
</div> </div>
{/* Mobile Bottom Navigation */} {/* Mobile Interface Components */}
<MobileNav onMoreClick={() => setMobileSheetOpen(true)} /> <MobileNav onMoreClick={() => setMobileSheetOpen(true)} />
{/* Mobile Navigation Sheet */}
<MobileNavSheet <MobileNavSheet
isOpen={mobileSheetOpen} isOpen={mobileSheetOpen}
onClose={() => setMobileSheetOpen(false)} onClose={() => setMobileSheetOpen(false)}
/> />
{/* Command Palette */} {/* System Utilities */}
<CommandPalette /> <CommandPalette />
{/* Session Timeout Warning */}
<SessionTimeoutWarning /> <SessionTimeoutWarning />
{/* Dev Tools */}
<DevTools /> <DevTools />
</div> </div>
); );

View file

@ -3,14 +3,13 @@ import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Menu, Menu,
X,
Search,
ChevronDown, ChevronDown,
Command, Command,
Bell, Bell,
LogOut, LogOut,
User, Settings,
Settings Shield,
Terminal
} from 'lucide-react'; } from 'lucide-react';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import { getFilteredNavSections, type NavSection } from '../../lib/navigation'; import { getFilteredNavSections, type NavSection } from '../../lib/navigation';
@ -27,11 +26,8 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
const location = useLocation(); const location = useLocation();
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
// Handle scroll effect
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => setScrolled(window.scrollY > 20);
setScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
@ -39,39 +35,36 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
return ( return (
<header <header
className={` 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 ${scrolled
? 'bg-white/90 border-slate-200 shadow-sm dark:bg-slate-950/90 dark:border-slate-800' ? 'bg-white/80 border-slate-200 dark:bg-[#050505]/80 dark:border-slate-800 shadow-2xl shadow-black/5'
: 'bg-white/50 border-transparent dark:bg-slate-950/50' : '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"> <div className="flex items-center justify-between h-16">
{/* Left: Logo & Desktop Nav */} {/* Left: Branding & Navigation */}
<div className="flex items-center gap-8"> <div className="flex items-center gap-12">
{/* Logo */} <Link to="/dashboard" className="flex items-center gap-3 group relative z-50">
<Link to="/" className="flex items-center gap-3 group relative z-50">
<div className="relative"> <div className="relative">
<img <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">
src="/assets/logo-777-wolfpack.jpg" <Shield className="text-white" size={20} strokeWidth={2.5} />
alt="777 Wolfpack" </div>
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-1 -right-1 w-3 h-3 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
/>
<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> </div>
<div className="hidden sm:block"> <div className="hidden sm:block">
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tight"> <h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
777 Wolfpack ERSEN OS
</h1> </h1>
<p className="text-[10px] font-medium text-slate-500 dark:text-slate-400 uppercase tracking-widest"> <p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
Operations Operational Node
</p> </p>
</div> </div>
</Link> </Link>
{/* Desktop Navigation - Sections Dropdowns */} {/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1"> <nav className="hidden lg:flex items-center gap-2">
{sections.map(section => ( {sections.map(section => (
<NavDropdown <NavDropdown
key={section.id} key={section.id}
@ -83,24 +76,24 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
{/* Global Search Trigger */} {/* Search Mock */}
<button <button
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))} 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" /> <Terminal size={12} className="group-hover:text-indigo-500 transition-colors" />
<span>Search...</span> <span className="uppercase tracking-widest">Execute...</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"> <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">
<span className="text-xs"></span>K K
</kbd> </kbd>
</button> </button>
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" /> <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"> <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={20} /> <Bell size={18} />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full ring-2 ring-white dark:ring-slate-950" /> <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> </button>
<ThemeToggle /> <ThemeToggle />
@ -109,7 +102,6 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
<UserDropdown /> <UserDropdown />
</div> </div>
{/* Mobile Menu Button */}
<button <button
onClick={onOpenMobileMenu} onClick={onOpenMobileMenu}
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white" 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}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setIsOpen(!isOpen)} 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"> <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() || 'U'} {user?.email?.[0]?.toUpperCase() || 'E'}
</div> </div>
</button> </button>
@ -156,13 +148,14 @@ function UserDropdown() {
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.1 }} 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"> <div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
<p className="text-sm font-medium text-slate-900 dark:text-white truncate"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated As</p>
{user?.name || 'User'} <p className="text-sm font-bold text-slate-900 dark:text-white truncate">
{user?.name || 'Authorized Operator'}
</p> </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} {user?.email}
</p> </p>
</div> </div>
@ -170,10 +163,10 @@ function UserDropdown() {
<Link <Link
to="/settings" to="/settings"
onClick={() => setIsOpen(false)} 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 size={14} />
Settings Terminal Config
</Link> </Link>
<button <button
@ -181,10 +174,10 @@ function UserDropdown() {
setIsOpen(false); setIsOpen(false);
logout(); 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} /> <LogOut size={14} />
Sign Out Terminate Session
</button> </button>
</motion.div> </motion.div>
)} )}
@ -198,74 +191,62 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isActive = section.items.some(item => item.path === currentPath); 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 ( return (
<div <div
className="relative px-1" className="relative px-0.5"
onMouseEnter={handleMouseEnter} onMouseEnter={() => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsOpen(true); }}
onMouseLeave={handleMouseLeave} onMouseLeave={() => { timeoutRef.current = setTimeout(() => setIsOpen(false), 150); }}
> >
<button <button
className={` 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 ${isActive || isOpen
? 'text-cyan-700 bg-cyan-50/80 dark:text-cyan-400 dark:bg-cyan-900/20' ? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
: '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-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
} }
`} `}
> >
{section.label} {section.label}
<ChevronDown <ChevronDown
size={14} size={10}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180 text-cyan-500' : 'text-slate-400'}`} className={`transition-transform duration-300 ${isOpen ? 'rotate-180 text-indigo-500' : 'opacity-40'}`}
/> />
</button> </button>
<AnimatePresence> <AnimatePresence mode="wait">
{isOpen && ( {isOpen && (
<motion.div <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 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.15, ease: "easeOut" }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
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" 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"> <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 => ( {section.items.map(item => (
<Link <Link
key={item.id} key={item.id}
to={item.path} to={item.path}
className={` 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 ${item.path === currentPath
? 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/20 dark:text-cyan-400' ? '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-200' : '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={` <div className={cn(
p-1.5 rounded-lg transition-colors "p-2 rounded-lg transition-all group-hover:scale-110",
${item.path === currentPath item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
? '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={14} />
}
`}>
<item.icon size={16} />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium leading-none">{item.label}</div> <div className="text-xs font-bold leading-none tracking-tight">{item.label}</div>
{/* Optional Description if we had it */}
</div> </div>
{item.path === currentPath && ( {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> </Link>
))} ))}

View file

@ -1,39 +1,38 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react'; import { ChevronRight, Home } from 'lucide-react';
import { motion } from 'framer-motion';
interface BreadcrumbItem { interface BreadcrumbItem {
label: string; label: string;
path: string; path: string;
} }
// Route configuration for breadcrumbs // Route mapping for Ersen System
const ROUTE_CONFIG: Record<string, { label: string; parent?: string }> = { const ROUTE_CONFIG: Record<string, { label: string; parent?: string }> = {
'/': { label: 'Dashboard' }, '/': { label: 'Command Center' },
'/dashboard': { label: 'Operational Overview', parent: '/' },
'/walkthrough': { label: 'Daily Walkthrough', parent: '/' }, '/walkthrough': { label: 'Daily Walkthrough', parent: '/' },
'/tasks': { label: 'Tasks', parent: '/' }, '/tasks': { label: 'Tasks', parent: '/' },
'/tasks/templates': { label: 'SOP Templates', parent: '/tasks' }, '/tasks/templates': { label: 'SOP Templates', parent: '/tasks' },
'/batches': { label: 'Batches', parent: '/' }, '/batches': { label: 'Batches', parent: '/' },
'/rooms': { label: 'Rooms', parent: '/' }, '/rooms': { label: 'Rooms', parent: '/' },
'/timeclock': { label: 'Timeclock', parent: '/' }, '/timeclock': { label: 'Time & Attendance', parent: '/' },
'/supplies': { label: 'Inventory', parent: '/' }, '/supplies': { label: 'Inventory', parent: '/' },
'/reports': { label: 'Reports', parent: '/' }, '/reports': { label: 'Advanced Analytics', parent: '/' },
'/ipm': { label: 'IPM Dashboard', parent: '/' }, '/ipm': { label: 'IPM Dashboard', parent: '/' },
'/touch-points': { label: 'Quick Actions', parent: '/' }, '/touch-points': { label: 'Action Logs', parent: '/' },
'/visitors': { label: 'Visitors', parent: '/' }, '/visitors': { label: 'Visitor Management', parent: '/' },
// Compliance section '/compliance/audit': { label: 'Audit Trail', parent: '/' },
'/compliance/audit': { label: 'Audit Log', parent: '/' }, '/compliance/documents': { label: 'Compliance Docs', parent: '/' },
'/compliance/documents': { label: 'SOP Library', parent: '/' }, '/metrc': { label: 'METRC Sync', parent: '/' },
// Advanced dashboards '/environment': { label: 'Environmental Controls', parent: '/' },
'/environment': { label: 'Environment', parent: '/' }, '/financial': { label: 'Financials', parent: '/' },
'/financial': { label: 'Financial', parent: '/' },
'/insights': { label: 'AI Insights', parent: '/' }, '/insights': { label: 'AI Insights', parent: '/' },
// Settings '/roles': { label: 'Access Control', parent: '/settings' },
'/roles': { label: 'Roles', parent: '/settings' }, '/settings': { label: 'System Settings', parent: '/' },
'/settings': { label: 'Settings', parent: '/' }, '/settings/walkthrough': { label: 'Walkthrough Config', parent: '/settings' },
'/settings/walkthrough': { label: 'Walkthrough Settings', parent: '/settings' },
}; };
// Dynamic route patterns
const DYNAMIC_ROUTES: { pattern: RegExp; getLabel: (match: RegExpMatchArray) => string; parent: string }[] = [ const DYNAMIC_ROUTES: { pattern: RegExp; getLabel: (match: RegExpMatchArray) => string; parent: string }[] = [
{ pattern: /^\/batches\/(.+)$/, getLabel: () => 'Batch Details', parent: '/batches' }, { pattern: /^\/batches\/(.+)$/, getLabel: () => 'Batch Details', parent: '/batches' },
{ pattern: /^\/rooms\/(.+)$/, getLabel: () => 'Room Details', parent: '/rooms' }, { pattern: /^\/rooms\/(.+)$/, getLabel: () => 'Room Details', parent: '/rooms' },
@ -44,7 +43,6 @@ function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
const crumbs: BreadcrumbItem[] = []; const crumbs: BreadcrumbItem[] = [];
let currentPath = pathname; let currentPath = pathname;
// Check for dynamic routes first
for (const route of DYNAMIC_ROUTES) { for (const route of DYNAMIC_ROUTES) {
const match = pathname.match(route.pattern); const match = pathname.match(route.pattern);
if (match) { if (match) {
@ -54,10 +52,8 @@ function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
} }
} }
// Build breadcrumb chain from static routes
while (currentPath && ROUTE_CONFIG[currentPath]) { while (currentPath && ROUTE_CONFIG[currentPath]) {
const config = 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)) { if (!crumbs.find(c => c.path === currentPath)) {
crumbs.unshift({ label: config.label, path: currentPath }); crumbs.unshift({ label: config.label, path: currentPath });
} }
@ -71,41 +67,43 @@ export function Breadcrumbs() {
const location = useLocation(); const location = useLocation();
const crumbs = getBreadcrumbs(location.pathname); const crumbs = getBreadcrumbs(location.pathname);
// Don't show breadcrumbs on dashboard // Filter home
if (location.pathname === '/' || crumbs.length <= 1) { if (location.pathname === '/' || location.pathname === '/dashboard' || crumbs.length <= 1) {
return null; return null;
} }
return ( return (
<nav aria-label="Breadcrumb" className="mb-4"> <nav aria-label="Breadcrumb" className="mb-0">
<ol className="flex items-center gap-1 text-sm flex-wrap"> <ol className="flex items-center gap-1 text-[11px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500 transition-all">
<li> <li className="flex items-center">
<Link <Link
to="/" to="/dashboard"
className="p-1.5 rounded-md hover:bg-tertiary text-tertiary hover:text-primary transition-colors flex items-center" className="p-1 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Home" aria-label="Home"
> >
<Home size={16} /> <Home size={12} />
</Link> </Link>
</li> </li>
{crumbs.map((crumb, index) => { {crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1; const isLast = index === crumbs.length - 1;
if (crumb.path === '/' || crumb.path === '/dashboard') return null;
// Skip dashboard in the crumb list (we have the home icon)
if (crumb.path === '/') return null;
return ( return (
<li key={crumb.path} className="flex items-center gap-1"> <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 ? ( {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} {crumb.label}
</span> </motion.span>
) : ( ) : (
<Link <Link
to={crumb.path} 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} {crumb.label}
</Link> </Link>
@ -118,20 +116,17 @@ export function Breadcrumbs() {
); );
} }
// Page title helper
export function getPageTitle(pathname: string): string { export function getPageTitle(pathname: string): string {
// Check dynamic routes first
for (const route of DYNAMIC_ROUTES) { for (const route of DYNAMIC_ROUTES) {
const match = pathname.match(route.pattern); const match = pathname.match(route.pattern);
if (match) { if (match) {
return `${route.getLabel(match)} | 777 Wolfpack`; return `${route.getLabel(match)} | Ersen OS`;
} }
} }
const config = ROUTE_CONFIG[pathname]; const config = ROUTE_CONFIG[pathname];
if (config) { if (config) {
return `${config.label} | 777 Wolfpack`; return `${config.label} | Ersen OS`;
} }
return '777 Wolfpack - Grow Ops Manager'; return 'Ersen OS - Operational Management';
} }

View file

@ -1,9 +1,11 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
/** /**
* Linear-inspired UI primitives * Ersen UI Primitives
* Consistent components for the new design system * High-performance, high-density components for operational management.
*/ */
// Page header with title and optional actions // Page header with title and optional actions
@ -11,21 +13,24 @@ interface PageHeaderProps {
title: string; title: string;
subtitle?: string; subtitle?: string;
actions?: ReactNode; actions?: ReactNode;
className?: string;
} }
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { export function PageHeader({ title, subtitle, actions, className }: PageHeaderProps) {
return ( return (
<header className="flex justify-between items-start mb-6"> <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> <div className="space-y-1">
<h1 className="text-2xl font-semibold text-primary tracking-tight"> <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} {title}
</h1> </h1>
{subtitle && ( {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> </div>
{actions && ( {actions && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 md:self-end">
{actions} {actions}
</div> </div>
)} )}
@ -43,25 +48,27 @@ interface SectionHeaderProps {
export function SectionHeader({ icon: Icon, title, count, accent = 'default' }: SectionHeaderProps) { export function SectionHeader({ icon: Icon, title, count, accent = 'default' }: SectionHeaderProps) {
const accentClasses = { const accentClasses = {
default: 'bg-tertiary text-secondary', default: 'text-slate-500 bg-slate-100 dark:bg-slate-800/50',
accent: 'bg-accent-muted text-accent', accent: 'text-indigo-500 bg-indigo-500/10',
success: 'bg-success-muted text-success', success: 'text-emerald-500 bg-emerald-500/10',
warning: 'bg-warning-muted text-warning', warning: 'text-amber-500 bg-amber-500/10',
destructive: 'bg-destructive-muted text-destructive', destructive: 'text-rose-500 bg-rose-500/10',
}; };
return ( return (
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2.5 mb-4 group">
{Icon && ( {Icon && (
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${accentClasses[accent]}`}> <div className={cn("w-6 h-6 rounded-md flex items-center justify-center transition-transform group-hover:scale-110", accentClasses[accent])}>
<Icon size={14} /> <Icon size={12} />
</div> </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} {title}
</h3> </h3>
{count !== undefined && ( {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> </div>
); );
@ -77,15 +84,15 @@ interface EmptyStateProps {
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="text-center py-12 px-4"> <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-12 h-12 mx-auto bg-tertiary rounded-xl flex items-center justify-center mb-4"> <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={24} className="text-tertiary" /> <Icon size={32} className="text-slate-300 dark:text-slate-700" />
</div> </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 && ( {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> </div>
); );
} }
@ -101,30 +108,42 @@ interface MetricCardProps {
} }
export function MetricCard({ icon: Icon, label, value, subtitle, accent = 'default', trend }: MetricCardProps) { export function MetricCard({ icon: Icon, label, value, subtitle, accent = 'default', trend }: MetricCardProps) {
const accentClasses = { const accentColor = {
default: 'bg-tertiary text-secondary', default: 'text-slate-500 dark:text-slate-400',
accent: 'bg-accent-muted text-accent', accent: 'text-indigo-500',
success: 'bg-success-muted text-success', success: 'text-emerald-500',
warning: 'bg-warning-muted text-warning', warning: 'text-amber-500',
destructive: 'bg-destructive-muted text-destructive', destructive: 'text-rose-500',
}; };
return ( return (
<div className="card card-interactive p-4"> <motion.div
<div className={`w-8 h-8 rounded-md flex items-center justify-center mb-3 ${accentClasses[accent]}`}> whileHover={{ y: -2 }}
<Icon size={16} /> 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> >
<p className="text-2xl font-semibold text-primary tracking-tight">{value}</p> <div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2 mt-1"> <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])}>
<p className="text-xs text-tertiary">{label}</p> <Icon size={18} />
</div>
{trend && ( {trend && (
<span className={`text-xs font-medium ${trend.positive ? 'text-success' : 'text-destructive'}`}> <div className={cn(
{trend.positive ? '+' : ''}{trend.value}% "text-[10px] font-bold px-2 py-0.5 rounded-full",
</span> trend.positive ? "text-emerald-600 bg-emerald-500/10" : "text-rose-600 bg-rose-500/10"
)}>
{trend.positive ? '↑' : '↓'} {trend.value}%
</div>
)} )}
</div> </div>
{subtitle && <p className="text-[10px] text-tertiary mt-1">{subtitle}</p>} <div className="space-y-0.5">
</div> <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; className?: string;
} }
export function ListItem({ children, onClick, active, className = '' }: ListItemProps) { export function ListItem({ children, onClick, active, className }: ListItemProps) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className={` className={cn(
flex items-center gap-3 p-3 rounded-md "flex items-center gap-3 p-4 rounded-xl transition-all duration-300",
transition-colors duration-fast onClick && "cursor-pointer",
${onClick ? 'cursor-pointer' : ''} active
${active ? "bg-indigo-500/10 ring-1 ring-indigo-500/20"
? 'bg-accent-muted border border-accent/20' : "hover:bg-slate-50 dark:hover:bg-slate-900/50",
: 'hover:bg-tertiary' className
} )}
${className}
`}
> >
{children} {children}
</div> </div>
@ -160,24 +177,24 @@ export function ListItem({ children, onClick, active, className = '' }: ListItem
interface ActionButtonProps { interface ActionButtonProps {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
onClick: () => void; onClick: (e: React.MouseEvent) => void;
variant?: 'default' | 'accent' | 'success' | 'warning' | 'destructive'; variant?: 'default' | 'accent' | 'success' | 'warning' | 'destructive';
} }
export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }: ActionButtonProps) { export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }: ActionButtonProps) {
const variantClasses = { const variants = {
default: 'text-secondary hover:text-primary hover:bg-tertiary', default: 'text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
accent: 'text-secondary hover:text-accent hover:bg-accent-muted', accent: 'text-indigo-500 hover:bg-indigo-500/10',
success: 'text-secondary hover:text-success hover:bg-success-muted', success: 'text-emerald-500 hover:bg-emerald-500/10',
warning: 'text-secondary hover:text-warning hover:bg-warning-muted', warning: 'text-amber-500 hover:bg-amber-500/10',
destructive: 'text-secondary hover:text-destructive hover:bg-destructive-muted', destructive: 'text-rose-500 hover:bg-rose-500/10',
}; };
return ( return (
<button <button
onClick={onClick} onClick={onClick}
title={label} 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} /> <Icon size={16} />
</button> </button>
@ -188,42 +205,39 @@ export function ActionButton({ icon: Icon, label, onClick, variant = 'default' }
interface StatusBadgeProps { interface StatusBadgeProps {
status: 'active' | 'pending' | 'completed' | 'error' | 'default'; status: 'active' | 'pending' | 'completed' | 'error' | 'default';
label?: string; label?: string;
className?: string;
} }
export function StatusBadge({ status, label }: StatusBadgeProps) { export function StatusBadge({ status, label, className }: StatusBadgeProps) {
const statusClasses = { const variants = {
active: 'badge-success', active: 'text-emerald-600 bg-emerald-500/10 border-emerald-500/20',
pending: 'badge-warning', pending: 'text-amber-600 bg-amber-500/10 border-amber-500/20',
completed: 'badge-accent', completed: 'text-indigo-600 bg-indigo-500/10 border-indigo-500/20',
error: 'badge-destructive', error: 'text-rose-600 bg-rose-500/10 border-rose-500/20',
default: 'badge', default: 'text-slate-500 bg-slate-100 border-slate-200 dark:bg-slate-800 dark:border-slate-700',
};
const statusLabels = {
active: 'Active',
pending: 'Pending',
completed: 'Completed',
error: 'Error',
default: label || 'Unknown',
}; };
return ( return (
<span className={`badge ${statusClasses[status]}`}> <span className={cn(
{label || statusLabels[status]} "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> </span>
); );
} }
// Skeleton loader // Skeleton loader
export function Skeleton({ className = '' }: { className?: string }) { export function Skeleton({ className }: { className?: string }) {
return <div className={`skeleton ${className}`} />; return <div className={cn("animate-pulse bg-slate-100 dark:bg-slate-800 rounded", className)} />;
} }
// Card skeleton // Card skeleton
export function CardSkeleton() { export function CardSkeleton() {
return ( return (
<div className="card p-4 space-y-3"> <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-md" /> <Skeleton className="w-8 h-8 rounded-lg" />
<Skeleton className="w-3/4 h-5" /> <Skeleton className="w-3/4 h-5" />
<Skeleton className="w-1/2 h-4" /> <Skeleton className="w-1/2 h-4" />
</div> </div>
@ -231,6 +245,6 @@ export function CardSkeleton() {
} }
// Divider // Divider
export function Divider({ className = '' }: { className?: string }) { export function Divider({ className }: { className?: string }) {
return <div className={`divider my-4 ${className}`} />; return <div className={cn("h-px w-full bg-slate-100 dark:bg-slate-800 my-4", className)} />;
} }

View 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],
},
},
};

View file

@ -1,22 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Shield, Search, Filter, Download, ChevronLeft, ChevronRight, Shield, Search, Filter, Download,
User, Clock, Eye, X, Loader2, FileText, LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown,
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown Eye, Clock, User
} from 'lucide-react'; } from 'lucide-react';
import { auditApi, AuditLog, AuditLogSummary, AuditLogFilters } from '../lib/auditApi'; import { auditApi, AuditLog, AuditLogSummary, AuditLogFilters } from '../lib/auditApi';
import { PageHeader, MetricCard, EmptyState } from '../components/ui/LinearPrimitives'; 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 }> = { // --- Configuration ---
CREATE: { icon: FileText, badge: 'badge-success', label: 'Created' },
UPDATE: { icon: Edit, badge: 'badge-accent', label: 'Updated' }, const ACTION_CONFIG: Record<string, { icon: any; color: string; label: string }> = {
DELETE: { icon: Trash2, badge: 'badge-destructive', label: 'Deleted' }, CREATE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-500/10', label: 'Create' },
LOGIN: { icon: LogIn, badge: 'badge-accent', label: 'Login' }, UPDATE: { icon: Edit, color: 'text-blue-500 bg-blue-500/10', label: 'Update' },
LOGOUT: { icon: LogOut, badge: 'badge', label: 'Logout' }, DELETE: { icon: Trash2, color: 'text-rose-500 bg-rose-500/10', label: 'Delete' },
ACCESS: { icon: Eye, badge: 'badge-accent', label: 'Accessed' }, LOGIN: { icon: LogIn, color: 'text-cyan-500 bg-cyan-500/10', label: 'Login' },
EXPORT: { icon: FileDown, badge: 'badge-warning', label: 'Exported' }, LOGOUT: { icon: LogOut, color: 'text-slate-500 bg-slate-500/10', label: 'Logout' },
APPROVE: { icon: ThumbsUp, badge: 'badge-success', label: 'Approved' }, ACCESS: { icon: Eye, color: 'text-indigo-500 bg-indigo-500/10', label: 'Access' },
REJECT: { icon: ThumbsDown, badge: 'badge-destructive', label: 'Rejected' } 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> = { const ENTITY_LABELS: Record<string, string> = {
@ -25,6 +29,8 @@ const ENTITY_LABELS: Record<string, string> = {
Plant: 'Plant', Supply: 'Supply' Plant: 'Plant', Supply: 'Supply'
}; };
// --- Page Component ---
export default function AuditLogPage() { export default function AuditLogPage() {
const [logs, setLogs] = useState<AuditLog[]>([]); const [logs, setLogs] = useState<AuditLog[]>([]);
const [summary, setSummary] = useState<AuditLogSummary | null>(null); const [summary, setSummary] = useState<AuditLogSummary | null>(null);
@ -33,8 +39,6 @@ export default function AuditLogPage() {
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null); const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [filters, setFilters] = useState<AuditLogFilters>({ page: 1, limit: 25 }); 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 }); const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 0 });
useEffect(() => { useEffect(() => {
@ -81,328 +85,207 @@ export default function AuditLogPage() {
} }
} }
function formatTimestamp(ts: string) { // --- Table Definition ---
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);
if (diffMins < 1) return 'Just now'; const columns: Column<AuditLog>[] = [
if (diffMins < 60) return `${diffMins}m ago`; {
if (diffHours < 24) return `${diffHours}h ago`; key: 'timestamp',
if (diffDays < 7) return `${diffDays}d ago`; header: 'Timestamp',
return date.toLocaleDateString(); cell: (log) => (
} <div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
function getActionConfig(action: string) { {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
return ACTION_CONFIG[action] || { icon: FileText, badge: 'badge', label: action }; </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>
);
}
},
{
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 ( return (
<div className="space-y-6 pb-20 animate-in"> <div className="space-y-8 animate-in fade-in duration-500">
<PageHeader <PageHeader
title="Audit Log" title="System Audit"
subtitle="Complete activity trail for compliance" subtitle="High-fidelity activity trail for compliance and security."
actions={ actions={
<div className="flex gap-2"> <button
<button onClick={handleExport}
onClick={() => setShowFilters(!showFilters)} disabled={exporting}
className={`btn ${showFilters ? 'btn-primary' : 'btn-secondary'}`} className="btn btn-primary shadow-lg shadow-indigo-500/20"
> >
<Filter size={16} /> <Download size={16} />
<span className="hidden sm:inline">Filters</span> {exporting ? 'Exporting...' : 'Export CSV'}
</button> </button>
<button
onClick={handleExport}
disabled={exporting}
className="btn btn-primary"
>
<Download size={16} />
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
} }
/> />
{/* Summary Cards */} {/* Metrics Row */}
{summary && ( {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={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={Edit} label="Updates" value={summary.byAction['UPDATE'] || 0} accent="accent" />
<MetricCard icon={Trash2} label="Deletes" value={summary.byAction['DELETE'] || 0} accent="destructive" /> <MetricCard icon={Trash2} label="Deletes" value={summary.byAction['DELETE'] || 0} accent="destructive" />
</div> </div>
)} )}
{/* Filters Panel */} {/* Unified Table View */}
{showFilters && ( <DataTable
<div className="card p-4"> data={logs}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3"> columns={columns}
<div className="relative"> isLoading={loading}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} /> onRowClick={(log) => setSelectedLog(log)}
<input emptyState={
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 ? (
<EmptyState <EmptyState
icon={Shield} icon={Shield}
title="No audit logs found" title="No activity recorded"
description="Activity will appear here as users interact with the system." 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 */} {/* Simplified Pagination Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-subtle"> <div className="flex items-center justify-between px-2">
<p className="text-xs text-tertiary"> <p className="text-xs text-slate-500">
{((pagination.page - 1) * pagination.limit) + 1}{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} 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> </p>
<div className="flex items-center gap-1"> <div className="flex gap-2">
<button <button
onClick={() => setFilters({ ...filters, page: pagination.page - 1 })} onClick={() => setFilters({ ...filters, page: pagination.page - 1 })}
disabled={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> </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">
<button {pagination.page} / {pagination.pages}
onClick={() => setFilters({ ...filters, page: pagination.page + 1 })} </div>
disabled={pagination.page >= pagination.pages} <button
className="btn btn-ghost p-2 disabled:opacity-50" onClick={() => setFilters({ ...filters, page: pagination.page + 1 })}
> disabled={pagination.page >= pagination.pages}
<ChevronRight size={16} /> className="btn btn-secondary btn-sm h-8 px-2 disabled:opacity-30"
</button> >
</div> Next
</div> </button>
</> </div>
)}
</div> </div>
{/* Detail Modal */} {/* Detail View Modal (Keep existing logic but styled) */}
{selectedLog && ( {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 <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()} 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> <div>
<h3 className="text-lg font-semibold text-primary">Audit Log Detail</h3> <h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">Event Details</h3>
<p className="text-xs text-tertiary font-mono">ID: {selectedLog.id.slice(0, 8)}...</p> <p className="text-xs text-indigo-500 font-mono mt-1 uppercase tracking-widest">{selectedLog.id}</p>
</div> </div>
<button <button onClick={() => setSelectedLog(null)} className="p-2 hover:bg-slate-200 dark:hover:bg-slate-800 rounded-full transition-colors">
onClick={() => setSelectedLog(null)} <Search className="rotate-45" size={18} />
className="p-2 rounded-md hover:bg-tertiary transition-colors"
>
<X size={16} className="text-tertiary" />
</button> </button>
</div> </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 className="p-6 overflow-y-auto space-y-6">
<div> {/* Change set visualization */}
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">IP Address</p> {selectedLog.changes && Object.keys(selectedLog.changes).length > 0 ? (
<p className="text-sm font-mono text-primary">{selectedLog.ipAddress}</p> <div className="space-y-3">
</div> <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]) => (
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 && ( <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> <div className="text-xs font-bold text-slate-500 mb-2">{key}</div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">Changes</p> <div className="flex items-center gap-3 text-sm">
<div className="bg-tertiary rounded-md p-3 space-y-2"> <div className="flex-1 p-2 bg-rose-500/5 border border-rose-500/10 rounded text-rose-500 line-through truncate">
{Object.entries(selectedLog.changes).map(([key, value]: [string, any]) => ( {JSON.stringify(val.from)}
<div key={key} className="flex items-start gap-2 text-xs"> </div>
<span className="font-medium text-secondary min-w-[80px]">{key}:</span> <Clock size={14} className="text-slate-300" />
<span className="text-destructive line-through">{JSON.stringify(value.from)}</span> <div className="flex-1 p-2 bg-emerald-500/5 border border-emerald-500/10 rounded text-emerald-500 truncate">
<span className="text-tertiary"></span> {JSON.stringify(val.to)}
<span className="text-success">{JSON.stringify(value.to)}</span> </div>
</div>
</div> </div>
))} ))}
</div> </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">
{(selectedLog.before || selectedLog.after) && ( <Clock className="mx-auto mb-3 text-slate-300" size={32} />
<div className="grid grid-cols-2 gap-4"> <p className="text-sm text-slate-500">No attribute-level changes recorded for this event.</p>
{selectedLog.before && (
<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>
</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>
</div>
)}
</div> </div>
)} )}
<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-bold text-slate-400 uppercase mb-1">Actor</p>
<p className="text-sm font-medium">{selectedLog.userName || 'System Processor'}</p>
</div>
<div>
<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> </div>
</div> </div>

View file

@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; 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 api from '../lib/api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { DevTools } from '../components/dev/DevTools'; import { DevTools } from '../components/dev/DevTools';
import { Loader2, ArrowRight } from 'lucide-react'; import { pageVariants, itemVariants } from '../lib/animations';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -13,9 +15,8 @@ export default function LoginPage() {
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
// Set page title
useEffect(() => { useEffect(() => {
document.title = '777 Wolfpack - Login'; document.title = 'Ersen OS | Authentication';
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -26,127 +27,156 @@ export default function LoginPage() {
try { try {
const { data } = await api.post('/auth/login', { email, password }); const { data } = await api.post('/auth/login', { email, password });
login(data.accessToken, data.refreshToken, data.user); login(data.accessToken, data.refreshToken, data.user);
navigate('/'); navigate('/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Login failed'); setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen bg-primary flex items-center justify-center p-4"> <div className="min-h-screen bg-[#050505] text-slate-100 flex overflow-hidden font-sans selection:bg-indigo-500/30">
{/* Background subtle pattern */} {/* Left Side: Brand/Visual */}
<div <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]">
className="fixed inset-0 opacity-[0.02] pointer-events-none" <div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(#1e293b 1px, transparent 1px)', backgroundSize: '32px 32px' }} />
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
backgroundSize: '24px 24px',
}}
/>
{/* Main container */} <motion.div
<div className="w-full max-w-[380px] animate-in"> initial={{ opacity: 0, scale: 0.9 }}
{/* Logo */} animate={{ opacity: 1, scale: 1 }}
<div className="flex justify-center mb-8"> transition={{ duration: 1, ease: "easeOut" }}
<div className="relative group"> className="relative z-10 flex flex-col items-center text-center p-12 space-y-8"
<img >
src="/assets/logo-777-wolfpack.jpg" <div className="w-24 h-24 rounded-3xl bg-indigo-500 flex items-center justify-center shadow-2xl shadow-indigo-500/20 rotate-3">
alt="777 Wolfpack" <Lock className="text-white -rotate-3" size={48} strokeWidth={2.5} />
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" />
</div> </div>
</div> <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">
{/* Card */} ERSEN OS
<div className="card p-8 animate-slide-up" style={{ animationDelay: '50ms' }}> </h2>
{/* Header */} <p className="text-slate-500 font-mono text-sm tracking-[0.3em] uppercase">
<div className="text-center mb-8"> Operational Infrastructure
<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
</p> </p>
</div> </div>
{/* Form */} <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">
<form onSubmit={handleSubmit} className="space-y-5"> <div className="flex -space-x-3">
{/* Error message */} {[1, 2, 3].map(i => (
{error && ( <div key={i} className="w-8 h-8 rounded-full border-2 border-[#050505] bg-slate-800" />
<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" /> </div>
<span className="text-sm text-destructive">{error}</span> <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>
{/* 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="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Secure Email</label>
<div className="relative group">
<input
type="email"
required
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}
/>
<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>
)}
{/* Email */} <div className="space-y-2">
<div className="space-y-2"> <label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Access Key</label>
<label className="block text-sm font-medium text-secondary"> <div className="relative group">
Email <input
</label> type="password"
<input required
type="email" 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"
required placeholder="••••••••••••"
className="input w-full" value={password}
placeholder="you@example.com" onChange={(e) => setPassword(e.target.value)}
value={email} disabled={isLoading}
onChange={(e) => setEmail(e.target.value)} />
disabled={isLoading} <div className="absolute inset-0 rounded-xl bg-indigo-500/5 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity" />
autoComplete="email" </div>
/> </div>
</div> </div>
{/* Password */}
<div className="space-y-2">
<label className="block text-sm font-medium text-secondary">
Password
</label>
<input
type="password"
required
className="input w-full"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete="current-password"
/>
</div>
{/* Submit */}
<button <button
type="submit" type="submit"
disabled={isLoading} 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 ? ( {isLoading ? (
<> <Loader2 className="animate-spin" size={20} />
<Loader2 size={16} className="animate-spin" />
<span>Signing in...</span>
</>
) : ( ) : (
<> <div className="flex items-center gap-2">
<span>Continue</span> <span>Initiate Protocol</span>
<ArrowRight <ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
size={16} </div>
className="transition-transform duration-fast group-hover:translate-x-0.5"
/>
</>
)} )}
</button> </button>
</form> </motion.form>
</div>
{/* Footer */} <motion.div variants={itemVariants} className="pt-24 border-t border-slate-800 space-y-4">
<p className="text-center mt-6 text-xs text-tertiary animate-in" style={{ animationDelay: '150ms' }}> <div className="flex items-center justify-between">
Authorized personnel only <p className="text-[10px] font-bold text-slate-600 uppercase tracking-widest">Global Security</p>
</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> </div>
{/* Dev Tools */}
<DevTools /> <DevTools />
</div> </div>
); );

View file

@ -2,26 +2,24 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Cloud, CloudOff, RefreshCw, Download, AlertTriangle, Cloud, CloudOff, RefreshCw, Download, AlertTriangle,
CheckCircle, XCircle, Clock, MapPin, ArrowUpDown, CheckCircle, Clock, MapPin, ArrowUpDown,
FileText, Loader2, ExternalLink, Filter, Box FileText, ExternalLink, Filter, Box
} from 'lucide-react'; } from 'lucide-react';
import { metrcApi, MetrcLocation, MetrcDiscrepancy, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi'; import { metrcApi, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi';
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; import { PageHeader, EmptyState, MetricCard } from '../components/ui/LinearPrimitives';
import Hero1 from '../components/aura/Hero'; import { DataTable, Column } from '../components/ui/DataTable';
import { cn } from '../lib/utils';
// --- Configuration ---
const SEVERITY_CONFIG = { const SEVERITY_CONFIG = {
CRITICAL: { color: 'text-red-600 bg-red-100 dark:bg-red-900/30', label: 'Critical' }, CRITICAL: { color: 'text-rose-500 bg-rose-500/10', label: 'Critical' },
HIGH: { color: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30', label: 'High' }, HIGH: { color: 'text-amber-500 bg-amber-500/10', label: 'High' },
MEDIUM: { color: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30', label: 'Medium' }, MEDIUM: { color: 'text-yellow-500 bg-yellow-500/10', label: 'Medium' },
LOW: { color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30', label: 'Low' } LOW: { color: 'text-blue-500 bg-blue-500/10', label: 'Low' }
}; };
const DISCREPANCY_TYPE_LABELS = { // --- Page Component ---
MISSING_IN_METRC: 'Missing in METRC',
MISSING_LOCALLY: 'Missing Locally',
LOCATION_MISMATCH: 'Location Mismatch',
STATUS_MISMATCH: 'Status Mismatch'
};
export default function MetrcDashboardPage() { export default function MetrcDashboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -83,451 +81,236 @@ export default function MetrcDashboardPage() {
p.room.toLowerCase().includes(searchTerm.toLowerCase()) p.room.toLowerCase().includes(searchTerm.toLowerCase())
) || []; ) || [];
// METRC connection status (simulated - would check actual API in production) // --- Table Column Definitions ---
const metrcEnabled = false; // Set via env var in production
const connectionStatus = metrcEnabled ? 'connected' : 'demo'; const plantColumns: Column<any>[] = [
{
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>
)
},
{
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>
)
},
{
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={(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={14} />
</button>
<Link
to={`/facility/3d?plant=${plant.tagNumber}`}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md text-indigo-500"
>
<Box size={14} />
</Link>
</div>
)
}
];
const auditColumns: Column<any>[] = [
{
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 ( return (
<div className="space-y-6 pb-20 animate-in"> <div className="space-y-8 animate-in fade-in duration-500">
<Hero1 /> <PageHeader
title="METRC Compliance"
subtitle="The authoritative state sync interface for facility compliance."
actions={
<div className="flex gap-2">
<button
onClick={handleSync}
disabled={syncing}
className="btn btn-secondary shadow-sm"
>
<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="px-6 space-y-6"> {/* Connection Banner */}
{/* Connection Status Banner */} <div className="p-4 rounded-xl border border-amber-500/20 bg-amber-500/5 flex items-center gap-4">
<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="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500">
<div className="flex items-center gap-3"> <CloudOff size={20} />
{connectionStatus === 'demo' ? ( </div>
<> <div className="flex-1">
<CloudOff className="text-amber-600 dark:text-amber-400" size={24} /> <h4 className="text-sm font-bold text-amber-900 dark:text-amber-200">Sandbox / Demo Environment</h4>
<div className="flex-1"> <p className="text-xs text-amber-500">The METRC production API is disabled. You are viewing simulated system data.</p>
<p className="font-medium text-amber-800 dark:text-amber-200">Demo Mode</p> </div>
<p className="text-sm text-amber-600 dark:text-amber-400"> <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">
METRC API not connected. Data shown is for demonstration purposes. API Docs <ExternalLink size={12} />
</p> </a>
</div> </div>
<a
href="https://api-ca.metrc.com/Documentation" {/* Metrics */}
target="_blank" {report && (
rel="noopener noreferrer" <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
className="btn btn-ghost text-amber-700 dark:text-amber-300 text-sm" <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" />
<ExternalLink size={14} /> <MetricCard icon={CheckCircle} label="Success Rate" value="100%" accent="success" />
API Docs <MetricCard icon={AlertTriangle} label="Issues" value="0" accent="success" />
</a> </div>
</> )}
) : (
<> {/* Tabs */}
<Cloud className="text-emerald-600 dark:text-emerald-400" size={24} /> <div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-slate-900 rounded-xl w-fit">
<div className="flex-1"> {['overview', 'plants', 'audit'].map(tab => (
<p className="font-medium text-emerald-800 dark:text-emerald-200">Connected to METRC</p> <button
<p className="text-sm text-emerald-600 dark:text-emerald-400"> key={tab}
Last sync: {lastSync ? lastSync.toLocaleString() : 'Never'} onClick={() => setActiveTab(tab as any)}
</p> className={cn(
</div> "px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all",
<CheckCircle className="text-emerald-600" size={20} /> 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>
</div> </div>
)}
{/* Tab Navigation */} {activeTab === 'plants' && (
<div className="flex gap-1 p-1 bg-secondary rounded-lg"> <div className="space-y-4">
{ <div className="flex gap-4">
[ <div className="relative flex-1">
{ id: 'overview', label: 'Overview' }, <Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
{ id: 'plants', label: 'Plant Locations' }, <input
{ id: 'audit', label: 'Audit Trail' } placeholder="Filter by Tag ID or Room Name..."
].map(tab => ( className="input pl-10 h-10 w-full"
<button value={searchTerm}
key={tab.id} onChange={e => setSearchTerm(e.target.value)}
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>
))
}
</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> </div>
) : ( </div>
<> <DataTable
{/* Overview Tab */} data={filteredPlants}
{activeTab === 'overview' && ( columns={plantColumns}
<div className="space-y-6"> isLoading={loading}
{/* Stats Grid */} emptyState={
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <EmptyState icon={MapPin} title="No plants found" description="Try adjusting your search or filters." />
<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"> </div>
<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"> {activeTab === 'audit' && (
<div className="flex items-center gap-3"> <div className="space-y-4">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> <DataTable
<ArrowUpDown className="text-blue-600 dark:text-blue-400" size={20} /> data={audit?.recentMoves || []}
</div> columns={auditColumns}
<div> isLoading={loading}
<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>
<div className="card p-4"> {/* History Modal placeholder (Styled) */}
<div className="flex items-center gap-3"> {selectedHistoryPlant && (
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center"> <div className="fixed inset-0 bg-slate-950/60 backdrop-blur-md z-[100] flex items-center justify-center p-4" onClick={() => setSelectedHistoryPlant(null)}>
<CheckCircle className="text-green-600 dark:text-green-400" size={20} /> <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> <div className="text-center space-y-4">
<div> <div className="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-500 mx-auto">
<p className="text-2xl font-bold text-primary">100%</p> <Clock size={24} />
<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">
<CheckCircle size={10} />
Synced
</span>
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setSelectedHistoryPlant(plant)}
className="btn btn-ghost btn-sm btn-square"
title="View History"
>
<Clock size={16} className="text-tertiary" />
</button>
<Link
to={`/facility/3d?plant=${plant.tagNumber}`}
className="btn btn-ghost btn-sm btn-square text-accent"
title="View in 3D"
>
<Box size={16} />
</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 */}
{
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>
</div>
<button
onClick={() => setSelectedHistoryPlant(null)}
className="btn btn-ghost btn-sm btn-square"
>
<XCircle size={20} />
</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>
<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>
</div> )}
</div> </div>
); );
} }