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

View file

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

View file

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

View file

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

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 {
Shield, Search, Filter, Download, ChevronLeft, ChevronRight,
User, Clock, Eye, X, Loader2, FileText,
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown
Shield, Search, Filter, Download,
LogIn, LogOut, Edit, Trash2, FileDown, ThumbsUp, ThumbsDown,
Eye, Clock, User
} from 'lucide-react';
import { auditApi, AuditLog, AuditLogSummary, AuditLogFilters } from '../lib/auditApi';
import { PageHeader, MetricCard, EmptyState } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { cn } from '../lib/utils';
const ACTION_CONFIG: Record<string, { icon: React.ElementType; badge: string; label: string }> = {
CREATE: { icon: FileText, badge: 'badge-success', label: 'Created' },
UPDATE: { icon: Edit, badge: 'badge-accent', label: 'Updated' },
DELETE: { icon: Trash2, badge: 'badge-destructive', label: 'Deleted' },
LOGIN: { icon: LogIn, badge: 'badge-accent', label: 'Login' },
LOGOUT: { icon: LogOut, badge: 'badge', label: 'Logout' },
ACCESS: { icon: Eye, badge: 'badge-accent', label: 'Accessed' },
EXPORT: { icon: FileDown, badge: 'badge-warning', label: 'Exported' },
APPROVE: { icon: ThumbsUp, badge: 'badge-success', label: 'Approved' },
REJECT: { icon: ThumbsDown, badge: 'badge-destructive', label: 'Rejected' }
// --- Configuration ---
const ACTION_CONFIG: Record<string, { icon: any; color: string; label: string }> = {
CREATE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-500/10', label: 'Create' },
UPDATE: { icon: Edit, color: 'text-blue-500 bg-blue-500/10', label: 'Update' },
DELETE: { icon: Trash2, color: 'text-rose-500 bg-rose-500/10', label: 'Delete' },
LOGIN: { icon: LogIn, color: 'text-cyan-500 bg-cyan-500/10', label: 'Login' },
LOGOUT: { icon: LogOut, color: 'text-slate-500 bg-slate-500/10', label: 'Logout' },
ACCESS: { icon: Eye, color: 'text-indigo-500 bg-indigo-500/10', label: 'Access' },
EXPORT: { icon: FileDown, color: 'text-amber-500 bg-amber-500/10', label: 'Export' },
APPROVE: { icon: ThumbsUp, color: 'text-emerald-500 bg-emerald-500/10', label: 'Approve' },
REJECT: { icon: ThumbsDown, color: 'text-rose-500 bg-rose-500/10', label: 'Reject' }
};
const ENTITY_LABELS: Record<string, string> = {
@ -25,6 +29,8 @@ const ENTITY_LABELS: Record<string, string> = {
Plant: 'Plant', Supply: 'Supply'
};
// --- Page Component ---
export default function AuditLogPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [summary, setSummary] = useState<AuditLogSummary | null>(null);
@ -33,8 +39,6 @@ export default function AuditLogPage() {
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [filters, setFilters] = useState<AuditLogFilters>({ page: 1, limit: 25 });
const [showFilters, setShowFilters] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 0 });
useEffect(() => {
@ -81,328 +85,207 @@ export default function AuditLogPage() {
}
}
function formatTimestamp(ts: string) {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
// --- Table Definition ---
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
const columns: Column<AuditLog>[] = [
{
key: 'timestamp',
header: 'Timestamp',
cell: (log) => (
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="text-[10px] text-slate-500">
{new Date(log.timestamp).toLocaleDateString()}
</span>
</div>
)
},
{
key: 'userName',
header: 'User',
cell: (log) => (
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<User size={12} className="text-slate-500" />
</div>
<span className="text-sm text-slate-700 dark:text-slate-300">
{log.userName || 'System'}
</span>
</div>
)
},
{
key: 'action',
header: 'Action',
cell: (log) => {
const config = ACTION_CONFIG[log.action] || { icon: Shield, color: 'text-slate-500 bg-slate-100', label: log.action };
const Icon = config.icon;
return (
<div className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider",
config.color
)}>
<Icon size={10} />
{config.label}
</div>
);
}
function getActionConfig(action: string) {
return ACTION_CONFIG[action] || { icon: FileText, badge: 'badge', label: action };
},
{
key: 'entity',
header: 'Target',
cell: (log) => (
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{ENTITY_LABELS[log.entity] || log.entity}
</span>
{log.entityName && (
<span className="text-[10px] text-slate-500 truncate max-w-[150px]">
{log.entityName}
</span>
)}
</div>
)
},
{
key: 'details',
header: 'Details',
hideOnMobile: true,
cell: (log) => (
<span className="text-xs text-slate-500 truncate max-w-[200px] block">
{log.changes ? `Updated ${Object.keys(log.changes).join(', ')}` :
log.ipAddress ? `IP: ${log.ipAddress}` : '—'}
</span>
)
}
];
return (
<div className="space-y-6 pb-20 animate-in">
<div className="space-y-8 animate-in fade-in duration-500">
<PageHeader
title="Audit Log"
subtitle="Complete activity trail for compliance"
title="System Audit"
subtitle="High-fidelity activity trail for compliance and security."
actions={
<div className="flex gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`btn ${showFilters ? 'btn-primary' : 'btn-secondary'}`}
>
<Filter size={16} />
<span className="hidden sm:inline">Filters</span>
</button>
<button
onClick={handleExport}
disabled={exporting}
className="btn btn-primary"
className="btn btn-primary shadow-lg shadow-indigo-500/20"
>
<Download size={16} />
{exporting ? 'Exporting...' : 'Export'}
{exporting ? 'Exporting...' : 'Export CSV'}
</button>
</div>
}
/>
{/* Summary Cards */}
{/* Metrics Row */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard icon={Shield} label="Total Events" value={summary.totalLogs.toLocaleString()} accent="accent" />
<MetricCard icon={FileText} label="Creates" value={summary.byAction['CREATE'] || 0} accent="success" />
<MetricCard icon={ThumbsUp} label="Creates" value={summary.byAction['CREATE'] || 0} accent="success" />
<MetricCard icon={Edit} label="Updates" value={summary.byAction['UPDATE'] || 0} accent="accent" />
<MetricCard icon={Trash2} label="Deletes" value={summary.byAction['DELETE'] || 0} accent="destructive" />
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div className="card p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input w-full pl-9"
/>
</div>
<select
value={filters.entity || ''}
onChange={(e) => setFilters({ ...filters, entity: e.target.value || undefined, page: 1 })}
className="input"
>
<option value="">All Entities</option>
{summary?.byEntity.map(e => (
<option key={e.entity} value={e.entity}>
{ENTITY_LABELS[e.entity] || e.entity} ({e.count})
</option>
))}
</select>
<select
value={filters.action || ''}
onChange={(e) => setFilters({ ...filters, action: e.target.value || undefined, page: 1 })}
className="input"
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
</select>
<div className="flex gap-2">
<input
type="date"
value={filters.startDate?.split('T')[0] || ''}
onChange={(e) => setFilters({ ...filters, startDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
className="input flex-1 h-10 text-xs"
/>
<input
type="date"
value={filters.endDate?.split('T')[0] || ''}
onChange={(e) => setFilters({ ...filters, endDate: e.target.value ? new Date(e.target.value).toISOString() : undefined, page: 1 })}
className="input flex-1 h-10 text-xs"
/>
</div>
</div>
{(filters.entity || filters.action || filters.startDate || filters.endDate) && (
<button
onClick={() => setFilters({ page: 1, limit: 25 })}
className="mt-3 text-sm text-accent hover:underline flex items-center gap-1"
>
<X size={14} /> Clear filters
</button>
)}
</div>
)}
{/* Logs Table */}
<div className="card overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin text-tertiary" size={24} />
</div>
) : logs.length === 0 ? (
{/* Unified Table View */}
<DataTable
data={logs}
columns={columns}
isLoading={loading}
onRowClick={(log) => setSelectedLog(log)}
emptyState={
<EmptyState
icon={Shield}
title="No audit logs found"
description="Activity will appear here as users interact with the system."
title="No activity recorded"
description="Audit logs will appear here once users begin interacting with the system."
/>
}
/>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary border-b border-subtle">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Time</th>
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">User</th>
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Action</th>
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Entity</th>
<th className="px-4 py-3 text-left text-xs font-medium text-tertiary uppercase tracking-wider">Details</th>
<th className="px-4 py-3 text-center text-xs font-medium text-tertiary uppercase tracking-wider">View</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{logs.map(log => {
const config = getActionConfig(log.action);
return (
<tr
key={log.id}
className="hover:bg-tertiary cursor-pointer transition-colors duration-fast"
onClick={() => setSelectedLog(log)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2 text-xs text-secondary">
<Clock size={12} className="text-tertiary" />
<span>{formatTimestamp(log.timestamp)}</span>
</div>
<p className="text-[10px] text-tertiary mt-0.5">
{new Date(log.timestamp).toLocaleString()}
</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-tertiary flex items-center justify-center">
<User size={12} className="text-tertiary" />
</div>
<span className="text-sm font-medium text-primary">
{log.userName || 'System'}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`${config.badge} text-[10px]`}>
{config.label}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm font-medium text-primary">
{ENTITY_LABELS[log.entity] || log.entity}
</span>
{log.entityName && (
<p className="text-xs text-tertiary truncate max-w-[120px]">{log.entityName}</p>
)}
</td>
<td className="px-4 py-3">
<p className="text-xs text-tertiary truncate max-w-[150px]">
{log.changes ? `Changed: ${Object.keys(log.changes).join(', ')}` :
log.ipAddress ? `IP: ${log.ipAddress}` : '—'}
</p>
</td>
<td className="px-4 py-3 text-center">
<button className="p-1.5 rounded hover:bg-tertiary text-tertiary hover:text-primary transition-colors">
<Eye size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-subtle">
<p className="text-xs text-tertiary">
{((pagination.page - 1) * pagination.limit) + 1}{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}
{/* Simplified Pagination Footer */}
<div className="flex items-center justify-between px-2">
<p className="text-xs text-slate-500">
Showing <span className="font-medium text-slate-700 dark:text-slate-300">{((pagination.page - 1) * pagination.limit) + 1}{Math.min(pagination.page * pagination.limit, pagination.total)}</span> of {pagination.total} events
</p>
<div className="flex items-center gap-1">
<div className="flex gap-2">
<button
onClick={() => setFilters({ ...filters, page: pagination.page - 1 })}
disabled={pagination.page <= 1}
className="btn btn-ghost p-2 disabled:opacity-50"
className="btn btn-secondary btn-sm h-8 px-2 disabled:opacity-30"
>
<ChevronLeft size={16} />
Prev
</button>
<span className="text-xs text-secondary px-2">{pagination.page}/{pagination.pages}</span>
<div className="flex items-center px-3 text-xs font-medium text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 rounded-md">
{pagination.page} / {pagination.pages}
</div>
<button
onClick={() => setFilters({ ...filters, page: pagination.page + 1 })}
disabled={pagination.page >= pagination.pages}
className="btn btn-ghost p-2 disabled:opacity-50"
className="btn btn-secondary btn-sm h-8 px-2 disabled:opacity-30"
>
<ChevronRight size={16} />
Next
</button>
</div>
</div>
</>
)}
</div>
{/* Detail Modal */}
{/* Detail View Modal (Keep existing logic but styled) */}
{selectedLog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 animate-fade-in" onClick={() => setSelectedLog(null)}>
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm flex items-center justify-center p-4 z-[100] animate-in fade-in" onClick={() => setSelectedLog(null)}>
<div
className="card max-w-2xl w-full max-h-[80vh] overflow-hidden animate-scale-in"
className="bg-white dark:bg-[#0A0A0A] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col scale-in"
onClick={e => e.stopPropagation()}
>
<div className="p-4 border-b border-subtle flex justify-between items-start">
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-900/50">
<div>
<h3 className="text-lg font-semibold text-primary">Audit Log Detail</h3>
<p className="text-xs text-tertiary font-mono">ID: {selectedLog.id.slice(0, 8)}...</p>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">Event Details</h3>
<p className="text-xs text-indigo-500 font-mono mt-1 uppercase tracking-widest">{selectedLog.id}</p>
</div>
<button
onClick={() => setSelectedLog(null)}
className="p-2 rounded-md hover:bg-tertiary transition-colors"
>
<X size={16} className="text-tertiary" />
<button onClick={() => setSelectedLog(null)} className="p-2 hover:bg-slate-200 dark:hover:bg-slate-800 rounded-full transition-colors">
<Search className="rotate-45" size={18} />
</button>
</div>
<div className="p-4 overflow-y-auto max-h-[60vh] space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Timestamp</p>
<p className="text-sm text-primary">{new Date(selectedLog.timestamp).toLocaleString()}</p>
</div>
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">User</p>
<p className="text-sm text-primary">{selectedLog.userName || 'System'}</p>
</div>
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Action</p>
<span className={`${getActionConfig(selectedLog.action).badge} text-[10px]`}>
{getActionConfig(selectedLog.action).label}
</span>
</div>
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Entity</p>
<p className="text-sm text-primary">
{ENTITY_LABELS[selectedLog.entity] || selectedLog.entity}
{selectedLog.entityName && <span className="text-tertiary ml-1">({selectedLog.entityName})</span>}
</p>
</div>
</div>
{selectedLog.ipAddress && (
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">IP Address</p>
<p className="text-sm font-mono text-primary">{selectedLog.ipAddress}</p>
<div className="p-6 overflow-y-auto space-y-6">
{/* Change set visualization */}
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 ? (
<div className="space-y-3">
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Property Changes</h4>
<div className="space-y-2">
{Object.entries(selectedLog.changes).map(([key, val]: [string, any]) => (
<div key={key} className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg border border-slate-100 dark:border-slate-800/50">
<div className="text-xs font-bold text-slate-500 mb-2">{key}</div>
<div className="flex items-center gap-3 text-sm">
<div className="flex-1 p-2 bg-rose-500/5 border border-rose-500/10 rounded text-rose-500 line-through truncate">
{JSON.stringify(val.from)}
</div>
<Clock size={14} className="text-slate-300" />
<div className="flex-1 p-2 bg-emerald-500/5 border border-emerald-500/10 rounded text-emerald-500 truncate">
{JSON.stringify(val.to)}
</div>
</div>
)}
{selectedLog.changes && Object.keys(selectedLog.changes).length > 0 && (
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">Changes</p>
<div className="bg-tertiary rounded-md p-3 space-y-2">
{Object.entries(selectedLog.changes).map(([key, value]: [string, any]) => (
<div key={key} className="flex items-start gap-2 text-xs">
<span className="font-medium text-secondary min-w-[80px]">{key}:</span>
<span className="text-destructive line-through">{JSON.stringify(value.from)}</span>
<span className="text-tertiary"></span>
<span className="text-success">{JSON.stringify(value.to)}</span>
</div>
))}
</div>
</div>
) : (
<div className="p-12 text-center bg-slate-50 dark:bg-slate-900 rounded-2xl border border-dashed border-slate-200 dark:border-slate-800">
<Clock className="mx-auto mb-3 text-slate-300" size={32} />
<p className="text-sm text-slate-500">No attribute-level changes recorded for this event.</p>
</div>
)}
{(selectedLog.before || selectedLog.after) && (
<div className="grid grid-cols-2 gap-4">
{selectedLog.before && (
<div className="grid grid-cols-2 gap-6 bg-slate-50 dark:bg-slate-900/50 p-4 rounded-xl">
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">Before</p>
<pre className="text-[10px] bg-destructive-muted p-2 rounded-md overflow-x-auto max-h-32 text-destructive font-mono">
{JSON.stringify(selectedLog.before, null, 2)}
</pre>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Actor</p>
<p className="text-sm font-medium">{selectedLog.userName || 'System Processor'}</p>
</div>
)}
{selectedLog.after && (
<div>
<p className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-1">After</p>
<pre className="text-[10px] bg-success-muted p-2 rounded-md overflow-x-auto max-h-32 text-success font-mono">
{JSON.stringify(selectedLog.after, null, 2)}
</pre>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Source IP</p>
<p className="text-sm font-mono">{selectedLog.ipAddress || 'Internal'}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>

View file

@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Shield, ArrowRight, Loader2, Key, ChevronRight, Lock } from 'lucide-react';
import api from '../lib/api';
import { useAuth } from '../context/AuthContext';
import { DevTools } from '../components/dev/DevTools';
import { Loader2, ArrowRight } from 'lucide-react';
import { pageVariants, itemVariants } from '../lib/animations';
export default function LoginPage() {
const [email, setEmail] = useState('');
@ -13,9 +15,8 @@ export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
// Set page title
useEffect(() => {
document.title = '777 Wolfpack - Login';
document.title = 'Ersen OS | Authentication';
}, []);
const handleSubmit = async (e: React.FormEvent) => {
@ -26,127 +27,156 @@ export default function LoginPage() {
try {
const { data } = await api.post('/auth/login', { email, password });
login(data.accessToken, data.refreshToken, data.user);
navigate('/');
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
setError(err.response?.data?.message || 'Authentication failed. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
{/* Background subtle pattern */}
<div
className="fixed inset-0 opacity-[0.02] pointer-events-none"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
backgroundSize: '24px 24px',
}}
/>
<div className="min-h-screen bg-[#050505] text-slate-100 flex overflow-hidden font-sans selection:bg-indigo-500/30">
{/* Left Side: Brand/Visual */}
<div className="hidden lg:flex flex-1 relative items-center justify-center border-r border-slate-800/50 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-slate-900 via-[#050505] to-[#050505]">
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(#1e293b 1px, transparent 1px)', backgroundSize: '32px 32px' }} />
{/* Main container */}
<div className="w-full max-w-[380px] animate-in">
{/* Logo */}
<div className="flex justify-center mb-8">
<div className="relative group">
<img
src="/assets/logo-777-wolfpack.jpg"
alt="777 Wolfpack"
className="w-20 h-20 rounded-2xl shadow-lg transition-transform duration-slow ease-out-expo group-hover:scale-105"
/>
{/* Subtle glow on hover */}
<div className="absolute inset-0 rounded-2xl bg-accent opacity-0 group-hover:opacity-10 transition-opacity duration-normal blur-xl" />
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, ease: "easeOut" }}
className="relative z-10 flex flex-col items-center text-center p-12 space-y-8"
>
<div className="w-24 h-24 rounded-3xl bg-indigo-500 flex items-center justify-center shadow-2xl shadow-indigo-500/20 rotate-3">
<Lock className="text-white -rotate-3" size={48} strokeWidth={2.5} />
</div>
</div>
{/* Card */}
<div className="card p-8 animate-slide-up" style={{ animationDelay: '50ms' }}>
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-primary tracking-tight">
Welcome back
</h1>
<p className="text-secondary text-sm mt-2">
Sign in to 777 Wolfpack
<div className="space-y-2">
<h2 className="text-5xl font-bold tracking-tighter bg-gradient-to-b from-white to-slate-500 bg-clip-text text-transparent">
ERSEN OS
</h2>
<p className="text-slate-500 font-mono text-sm tracking-[0.3em] uppercase">
Operational Infrastructure
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Error message */}
{error && (
<div className="flex items-center gap-3 p-3 bg-destructive-muted rounded-md animate-scale-in">
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
<span className="text-sm text-destructive">{error}</span>
<div className="flex items-center gap-6 mt-12 bg-slate-900/50 backdrop-blur-md px-6 py-3 rounded-full border border-slate-800/50 shadow-xl">
<div className="flex -space-x-3">
{[1, 2, 3].map(i => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-[#050505] bg-slate-800" />
))}
</div>
)}
<p className="text-xs text-slate-400 font-medium">Security Verified Nodes Active</p>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
</div>
</motion.div>
{/* Email */}
{/* Decorative elements */}
<div className="absolute bottom-12 left-12 text-[10px] font-mono text-slate-700 tracking-tighter uppercase leading-none">
Distributed ledger validated<br />
Secure tunnel engaged<br />
v4.2.0-stable
</div>
</div>
{/* Right Side: Form */}
<div className="flex-1 flex flex-col items-center justify-center p-6 lg:p-24 relative">
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
className="w-full max-w-sm space-y-12"
>
<div className="space-y-3">
<motion.div variants={itemVariants} className="lg:hidden w-12 h-12 rounded-xl bg-indigo-500 flex items-center justify-center mb-8 shadow-lg shadow-indigo-500/20">
<Lock className="text-white" size={24} />
</motion.div>
<motion.h1 variants={itemVariants} className="text-3xl font-bold tracking-tight text-white lg:text-4xl">
Authenticate
</motion.h1>
<motion.p variants={itemVariants} className="text-slate-400 text-sm">
Access your mission-critical operations portal.
</motion.p>
</div>
<motion.form variants={itemVariants} onSubmit={handleSubmit} className="space-y-6">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-start gap-3"
>
<Shield className="text-rose-500 flex-shrink-0 mt-0.5" size={16} />
<p className="text-sm text-rose-500 leading-snug">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-secondary">
Email
</label>
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Secure Email</label>
<div className="relative group">
<input
type="email"
required
className="input w-full"
placeholder="you@example.com"
className="w-full bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500 transition-all placeholder:text-slate-600"
placeholder="terminal@ersen.xyz"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
autoComplete="email"
/>
<div className="absolute inset-0 rounded-xl bg-indigo-500/5 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity" />
</div>
</div>
{/* Password */}
<div className="space-y-2">
<label className="block text-sm font-medium text-secondary">
Password
</label>
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest ml-1">Access Key</label>
<div className="relative group">
<input
type="password"
required
className="input w-full"
placeholder="••••••••"
className="w-full bg-slate-900/50 border border-slate-800 rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500 transition-all placeholder:text-slate-600"
placeholder="••••••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete="current-password"
/>
<div className="absolute inset-0 rounded-xl bg-indigo-500/5 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity" />
</div>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full h-11 text-sm font-medium group"
className="group relative w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl h-14 font-bold tracking-tight shadow-xl shadow-indigo-600/10 transition-all active:scale-[0.98] disabled:opacity-50 flex items-center justify-center overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-[100%] group-hover:translate-x-[100%] transition-transform duration-1000" />
{isLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Signing in...</span>
</>
<Loader2 className="animate-spin" size={20} />
) : (
<>
<span>Continue</span>
<ArrowRight
size={16}
className="transition-transform duration-fast group-hover:translate-x-0.5"
/>
</>
<div className="flex items-center gap-2">
<span>Initiate Protocol</span>
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
</div>
)}
</button>
</form>
</div>
</motion.form>
{/* Footer */}
<p className="text-center mt-6 text-xs text-tertiary animate-in" style={{ animationDelay: '150ms' }}>
Authorized personnel only
<motion.div variants={itemVariants} className="pt-24 border-t border-slate-800 space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-600 uppercase tracking-widest">Global Security</p>
<div className="h-px flex-1 bg-slate-800 mx-4" />
<Shield className="text-slate-700" size={14} />
</div>
<p className="text-[11px] text-slate-500 leading-relaxed text-center">
This system is for the exclusive use of authorized Ersen personnel. All activity is logged and monitored for compliance integrity.
</p>
</motion.div>
</motion.div>
</div>
{/* Dev Tools */}
<DevTools />
</div>
);

View file

@ -2,26 +2,24 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Cloud, CloudOff, RefreshCw, Download, AlertTriangle,
CheckCircle, XCircle, Clock, MapPin, ArrowUpDown,
FileText, Loader2, ExternalLink, Filter, Box
CheckCircle, Clock, MapPin, ArrowUpDown,
FileText, ExternalLink, Filter, Box
} from 'lucide-react';
import { metrcApi, MetrcLocation, MetrcDiscrepancy, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi';
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
import Hero1 from '../components/aura/Hero';
import { metrcApi, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi';
import { PageHeader, EmptyState, MetricCard } from '../components/ui/LinearPrimitives';
import { DataTable, Column } from '../components/ui/DataTable';
import { cn } from '../lib/utils';
// --- Configuration ---
const SEVERITY_CONFIG = {
CRITICAL: { color: 'text-red-600 bg-red-100 dark:bg-red-900/30', label: 'Critical' },
HIGH: { color: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30', label: 'High' },
MEDIUM: { color: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30', label: 'Medium' },
LOW: { color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30', label: 'Low' }
CRITICAL: { color: 'text-rose-500 bg-rose-500/10', label: 'Critical' },
HIGH: { color: 'text-amber-500 bg-amber-500/10', label: 'High' },
MEDIUM: { color: 'text-yellow-500 bg-yellow-500/10', label: 'Medium' },
LOW: { color: 'text-blue-500 bg-blue-500/10', label: 'Low' }
};
const DISCREPANCY_TYPE_LABELS = {
MISSING_IN_METRC: 'Missing in METRC',
MISSING_LOCALLY: 'Missing Locally',
LOCATION_MISMATCH: 'Location Mismatch',
STATUS_MISMATCH: 'Status Mismatch'
};
// --- Page Component ---
export default function MetrcDashboardPage() {
const [loading, setLoading] = useState(true);
@ -83,451 +81,236 @@ export default function MetrcDashboardPage() {
p.room.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
// METRC connection status (simulated - would check actual API in production)
const metrcEnabled = false; // Set via env var in production
const connectionStatus = metrcEnabled ? 'connected' : 'demo';
// --- Table Column Definitions ---
return (
<div className="space-y-6 pb-20 animate-in">
<Hero1 />
<div className="px-6 space-y-6">
{/* Connection Status Banner */}
<div className={`card p-4 ${connectionStatus === 'demo' ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800' : 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'}`}>
<div className="flex items-center gap-3">
{connectionStatus === 'demo' ? (
<>
<CloudOff className="text-amber-600 dark:text-amber-400" size={24} />
<div className="flex-1">
<p className="font-medium text-amber-800 dark:text-amber-200">Demo Mode</p>
<p className="text-sm text-amber-600 dark:text-amber-400">
METRC API not connected. Data shown is for demonstration purposes.
</p>
</div>
<a
href="https://api-ca.metrc.com/Documentation"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost text-amber-700 dark:text-amber-300 text-sm"
>
<ExternalLink size={14} />
API Docs
</a>
</>
) : (
<>
<Cloud className="text-emerald-600 dark:text-emerald-400" size={24} />
<div className="flex-1">
<p className="font-medium text-emerald-800 dark:text-emerald-200">Connected to METRC</p>
<p className="text-sm text-emerald-600 dark:text-emerald-400">
Last sync: {lastSync ? lastSync.toLocaleString() : 'Never'}
</p>
</div>
<CheckCircle className="text-emerald-600" size={20} />
</>
)}
</div>
</div>
{/* Tab Navigation */}
<div className="flex gap-1 p-1 bg-secondary rounded-lg">
const plantColumns: Column<any>[] = [
{
[
{ id: 'overview', label: 'Overview' },
{ id: 'plants', label: 'Plant Locations' },
{ id: 'audit', label: 'Audit Trail' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${activeTab === tab.id
? 'bg-primary text-primary shadow-sm'
: 'text-secondary hover:text-primary'
}`}
>
{tab.label}
</button>
))
}
</div >
key: 'tagNumber',
header: 'Plant Tag',
cell: (plant) => (
<div className="flex flex-col">
<span className="font-mono text-xs font-bold text-slate-800 dark:text-slate-200">{plant.tagNumber}</span>
<span className="text-[10px] text-indigo-500 font-medium">METRC ID</span>
</div>
)
},
{
loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" >
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<MapPin className="text-emerald-600 dark:text-emerald-400" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-primary">{report?.plantCount || 0}</p>
<p className="text-xs text-tertiary">Total Plants</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<ArrowUpDown className="text-blue-600 dark:text-blue-400" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-primary">{audit?.summary.totalMoves || 0}</p>
<p className="text-xs text-tertiary">Plant Moves (30d)</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="text-green-600 dark:text-green-400" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-primary">100%</p>
<p className="text-xs text-tertiary">Sync Rate</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertTriangle className="text-amber-600 dark:text-amber-400" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-primary">0</p>
<p className="text-xs text-tertiary">Discrepancies</p>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="card p-4">
<h3 className="font-medium text-primary mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<button className="btn btn-ghost justify-start h-auto py-3" onClick={handleExportCsv}>
<Download size={18} className="text-accent" />
<div className="text-left">
<p className="font-medium">Export CSV</p>
<p className="text-xs text-tertiary">For manual METRC upload</p>
</div>
</button>
<button className="btn btn-ghost justify-start h-auto py-3" onClick={handleSync}>
<RefreshCw size={18} className="text-accent" />
<div className="text-left">
<p className="font-medium">Sync All Plants</p>
<p className="text-xs text-tertiary">Update all locations</p>
</div>
</button>
<button className="btn btn-ghost justify-start h-auto py-3" onClick={() => setActiveTab('audit')}>
<FileText size={18} className="text-accent" />
<div className="text-left">
<p className="font-medium">View Audit</p>
<p className="text-xs text-tertiary">Compliance report</p>
</div>
</button>
<a
href="https://ca.metrc.com"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost justify-start h-auto py-3"
>
<ExternalLink size={18} className="text-accent" />
<div className="text-left">
<p className="font-medium">Open METRC</p>
<p className="text-xs text-tertiary">State portal</p>
</div>
</a>
</div>
</div>
{/* Recent Moves */}
{audit && audit.recentMoves.length > 0 && (
<div className="card">
<div className="p-4 border-b border-subtle">
<h3 className="font-medium text-primary">Recent Plant Moves</h3>
<p className="text-xs text-tertiary">Location changes requiring METRC update</p>
</div>
<div className="divide-y divide-subtle">
{audit.recentMoves.slice(0, 5).map((move: any, i: number) => (
<div key={i} className="p-4 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<ArrowUpDown size={18} className="text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-primary text-sm">{move.plantTag}</p>
<p className="text-xs text-tertiary truncate">
{move.from} {move.to}
</p>
</div>
<div className="text-right">
<p className="text-xs text-tertiary">
{new Date(move.movedAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Plants Tab */}
{activeTab === 'plants' && (
<div className="space-y-4">
{/* Search */}
<div className="flex gap-3">
<div className="relative flex-1">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary" size={16} />
<input
type="text"
placeholder="Search by tag or room..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input w-full pl-9"
/>
</div>
</div>
{/* Plant List */}
{filteredPlants.length === 0 ? (
<EmptyState
icon={MapPin}
title="No plants found"
description="No plant location data available."
/>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-secondary">
<tr>
<th className="text-left p-3 font-medium text-secondary">Tag</th>
<th className="text-left p-3 font-medium text-secondary">METRC Location</th>
<th className="text-left p-3 font-medium text-secondary">Room</th>
<th className="text-left p-3 font-medium text-secondary">Section</th>
<th className="text-left p-3 font-medium text-secondary">Position</th>
<th className="text-center p-3 font-medium text-secondary">Status</th>
<th className="text-right p-3 font-medium text-secondary">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{filteredPlants.slice(0, 50).map((plant: any) => (
<tr key={plant.plantId} className="hover:bg-secondary/50">
<td className="p-3 font-mono text-xs font-bold">{plant.tagNumber}</td>
<td className="p-3 font-mono text-xs text-accent">{plant.location || '-'}</td>
<td className="p-3">{plant.room}</td>
<td className="p-3">{plant.section || '-'}</td>
<td className="p-3">{plant.position || '-'}</td>
<td className="p-3 text-center">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
key: 'location',
header: 'METRC Location',
cell: (plant) => <span className="text-xs font-mono text-slate-500">{plant.location || '—'}</span>
},
{
key: 'room',
header: 'Local Room',
cell: (plant) => <span className="text-sm text-slate-700 dark:text-slate-300">{plant.room}</span>
},
{
key: 'status',
header: 'Status',
className: 'text-center',
cell: () => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-500">
<CheckCircle size={10} />
Synced
</span>
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-2">
)
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (plant) => (
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setSelectedHistoryPlant(plant)}
className="btn btn-ghost btn-sm btn-square"
title="View History"
onClick={(e) => { e.stopPropagation(); setSelectedHistoryPlant(plant); }}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md text-slate-500"
>
<Clock size={16} className="text-tertiary" />
<Clock size={14} />
</button>
<Link
to={`/facility/3d?plant=${plant.tagNumber}`}
className="btn btn-ghost btn-sm btn-square text-accent"
title="View in 3D"
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md text-indigo-500"
>
<Box size={16} />
<Box size={14} />
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredPlants.length > 50 && (
<div className="p-4 bg-secondary text-center text-sm text-tertiary">
Showing 50 of {filteredPlants.length} plants
</div>
)}
</div>
)}
</div>
)}
{/* Audit Tab */}
{activeTab === 'audit' && audit && (
<div className="space-y-6">
{/* Audit Summary */}
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium text-primary">METRC Compliance Audit</h3>
<p className="text-xs text-tertiary">
{new Date(audit.dateRange.start).toLocaleDateString()} - {new Date(audit.dateRange.end).toLocaleDateString()}
</p>
</div>
<span className="badge badge-success">
<CheckCircle size={12} />
Compliant
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-secondary rounded-lg">
<p className="text-2xl font-bold text-primary">{audit.summary.totalPlants}</p>
<p className="text-xs text-tertiary">Total Plants</p>
</div>
<div className="p-3 bg-secondary rounded-lg">
<p className="text-2xl font-bold text-primary">{audit.summary.totalMoves}</p>
<p className="text-xs text-tertiary">Location Changes</p>
</div>
<div className="p-3 bg-secondary rounded-lg">
<p className="text-2xl font-bold text-primary">{audit.summary.uniquePlantsMoved}</p>
<p className="text-xs text-tertiary">Plants Moved</p>
</div>
</div>
</div>
{/* Recent Moves Table */}
<div className="card overflow-hidden">
<div className="p-4 border-b border-subtle">
<h3 className="font-medium text-primary">Location Change History</h3>
</div>
{audit.recentMoves.length === 0 ? (
<div className="p-8 text-center text-tertiary">
<Clock size={32} className="mx-auto mb-2 opacity-50" />
<p>No location changes in this period</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-secondary">
<tr>
<th className="text-left p-3 font-medium text-secondary">Plant Tag</th>
<th className="text-left p-3 font-medium text-secondary">From</th>
<th className="text-left p-3 font-medium text-secondary">To</th>
<th className="text-left p-3 font-medium text-secondary">Date</th>
<th className="text-left p-3 font-medium text-secondary">Reason</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{audit.recentMoves.map((move: any, i: number) => (
<tr key={i} className="hover:bg-secondary/50">
<td className="p-3 font-mono text-xs">{move.plantTag}</td>
<td className="p-3 text-tertiary">{move.from}</td>
<td className="p-3 text-accent">{move.to}</td>
<td className="p-3">
{new Date(move.movedAt).toLocaleDateString()}
</td>
<td className="p-3 text-tertiary">{move.reason || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</>
)
}
{/* Plant History Modal */}
];
const auditColumns: Column<any>[] = [
{
selectedHistoryPlant && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="card w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl animate-in zoom-in-95">
<div className="p-4 border-b border-subtle flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-primary">Plant History</h3>
<p className="text-sm text-accent font-mono">{selectedHistoryPlant.tagNumber}</p>
key: 'plantTag',
header: 'Plant Tag',
cell: (move) => <span className="font-mono text-xs font-bold">{move.plantTag}</span>
},
{
key: 'from',
header: 'Previous Room',
cell: (move) => <span className="text-slate-500">{move.from}</span>
},
{
key: 'to',
header: 'New Room',
cell: (move) => <span className="text-indigo-500 font-medium">{move.to}</span>
},
{
key: 'movedAt',
header: 'Timestamp',
cell: (move) => (
<div className="flex flex-col">
<span className="text-xs">{new Date(move.movedAt).toLocaleDateString()}</span>
<span className="text-[10px] text-slate-400">{new Date(move.movedAt).toLocaleTimeString()}</span>
</div>
)
}
];
return (
<div className="space-y-8 animate-in fade-in duration-500">
<PageHeader
title="METRC Compliance"
subtitle="The authoritative state sync interface for facility compliance."
actions={
<div className="flex gap-2">
<button
onClick={() => setSelectedHistoryPlant(null)}
className="btn btn-ghost btn-sm btn-square"
onClick={handleSync}
disabled={syncing}
className="btn btn-secondary shadow-sm"
>
<XCircle size={20} />
<RefreshCw size={16} className={syncing ? "animate-spin" : ""} />
Sync All
</button>
<button
onClick={handleExportCsv}
className="btn btn-primary shadow-lg shadow-indigo-500/20"
>
<Download size={16} />
Export CSV
</button>
</div>
<div className="p-0 overflow-y-auto flex-1">
{/* Current Status */}
<div className="p-4 bg-secondary border-b border-subtle grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-tertiary text-xs uppercase tracking-wider">Current Room</p>
<p className="font-medium text-primary">{selectedHistoryPlant.room}</p>
</div>
<div>
<p className="text-tertiary text-xs uppercase tracking-wider">Current Section</p>
<p className="font-medium text-primary">{selectedHistoryPlant.section || 'N/A'}</p>
</div>
</div>
{/* Timeline */}
<div className="p-4">
<h4 className="text-sm font-medium text-tertiary mb-4 uppercase tracking-wider">Movement Log</h4>
{audit?.recentMoves.filter((m: any) => m.plantTag === selectedHistoryPlant.tagNumber).length === 0 ? (
<div className="text-center py-8 text-tertiary bg-secondary/30 rounded-lg">
<Clock className="mx-auto mb-2 opacity-30" />
<p>No recorded movements found for this plant.</p>
</div>
) : (
<ol className="relative border-s border-gray-200 dark:border-gray-700 ms-3 space-y-6">
{audit?.recentMoves
.filter((m: any) => m.plantTag === selectedHistoryPlant.tagNumber)
.sort((a: any, b: any) => new Date(b.movedAt).getTime() - new Date(a.movedAt).getTime())
.map((move: any, i: number) => (
<li key={i} className="mb-10 ms-4">
<div className="absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700"></div>
<time className="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500">
{new Date(move.movedAt).toLocaleString()}
</time>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mt-1">
Moved to <span className="text-accent">{move.to}</span>
</h3>
<p className="mb-4 text-sm font-normal text-gray-500 dark:text-gray-400">
From {move.from} Reason: {move.reason || 'Routine Maintenance'}
</p>
</li>
))}
</ol>
)}
</div>
</div>
<div className="p-4 border-t border-subtle bg-secondary/50 flex justify-end">
<Link
to={`/facility/3d?plant=${selectedHistoryPlant.tagNumber}`}
className="btn btn-primary btn-sm"
>
<Box size={16} className="mr-2" />
Locate in 3D
</Link>
</div>
</div>
</div>
)
}
/>
{/* Connection Banner */}
<div className="p-4 rounded-xl border border-amber-500/20 bg-amber-500/5 flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500">
<CloudOff size={20} />
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-amber-900 dark:text-amber-200">Sandbox / Demo Environment</h4>
<p className="text-xs text-amber-500">The METRC production API is disabled. You are viewing simulated system data.</p>
</div>
<a href="https://api-ca.metrc.com" target="_blank" rel="noreferrer" className="text-xs font-bold text-amber-600 hover:underline flex items-center gap-1">
API Docs <ExternalLink size={12} />
</a>
</div>
{/* Metrics */}
{report && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard icon={Box} label="Active Tags" value={report.plantCount.toLocaleString()} accent="accent" />
<MetricCard icon={ArrowUpDown} label="Total Moves" value={audit?.summary.totalMoves || 0} accent="accent" />
<MetricCard icon={CheckCircle} label="Success Rate" value="100%" accent="success" />
<MetricCard icon={AlertTriangle} label="Issues" value="0" accent="success" />
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-slate-900 rounded-xl w-fit">
{['overview', 'plants', 'audit'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={cn(
"px-6 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all",
activeTab === tab
? "bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm"
: "text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
)}
>
{tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest px-2">Recent Compliance Events</h3>
<DataTable
data={audit?.recentMoves.slice(0, 5) || []}
columns={auditColumns}
isLoading={loading}
/>
</div>
<div className="card p-6 flex flex-col justify-center items-center text-center space-y-4 border-dashed border-2">
<div className="w-16 h-16 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<CheckCircle size={32} />
</div>
<div>
<h3 className="text-lg font-bold">System in Sync</h3>
<p className="text-sm text-slate-500">No discrepancies detected between local state and METRC tracking ID logs.</p>
</div>
<button className="btn btn-secondary btn-sm">Download Full Reconciliation Report</button>
</div>
</div>
)}
{activeTab === 'plants' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
placeholder="Filter by Tag ID or Room Name..."
className="input pl-10 h-10 w-full"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
<DataTable
data={filteredPlants}
columns={plantColumns}
isLoading={loading}
emptyState={
<EmptyState icon={MapPin} title="No plants found" description="Try adjusting your search or filters." />
}
/>
</div>
)}
{activeTab === 'audit' && (
<div className="space-y-4">
<DataTable
data={audit?.recentMoves || []}
columns={auditColumns}
isLoading={loading}
/>
</div>
)}
{/* History Modal placeholder (Styled) */}
{selectedHistoryPlant && (
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-md z-[100] flex items-center justify-center p-4" onClick={() => setSelectedHistoryPlant(null)}>
<div className="bg-white dark:bg-[#0A0A0A] border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-lg w-full p-8 animate-in zoom-in" onClick={e => e.stopPropagation()}>
<div className="text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-500 mx-auto">
<Clock size={24} />
</div>
<h3 className="text-xl font-bold">Plant Lifecycle History</h3>
<p className="text-sm text-slate-500">Tracking history for <span className="font-mono font-bold text-slate-900 dark:text-slate-100">{selectedHistoryPlant.tagNumber}</span></p>
<div className="py-8 border-y border-dashed">
{/* Simplified timeline */}
<p className="text-xs text-slate-400 italic">Historical chain-of-custody logs are being processed...</p>
</div>
<button onClick={() => setSelectedHistoryPlant(null)} className="btn btn-primary w-full">Close View</button>
</div>
</div>
</div>
)}
</div>
);
}