mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
First passenger and back office portal commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
color?: 'blue' | 'green' | 'purple' | 'orange';
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
green: 'bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400',
|
||||
purple: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
orange: 'bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400',
|
||||
};
|
||||
|
||||
export default function StatCard({ title, value, icon: Icon, color = 'blue' }: StatCardProps) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{value}</p>
|
||||
</div>
|
||||
<div className={cn('rounded-full p-3', colorClasses[color])}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { Bell, LogOut, Moon, Sun, ChevronDown } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-6 shadow-sm">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<h2 className="text-xl font-semibold text-[rgb(20,113,76)] dark:text-[rgb(20,113,76)]"></h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Bell className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-[rgb(20,113,76)]"></span>
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-80 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-foreground">Notifications</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors border-b border-gray-200 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-foreground">New booking received</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Booking #BK-2024-001 created</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">2 minutes ago</p>
|
||||
</Link>
|
||||
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors border-b border-gray-200 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-foreground">Payment confirmed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Payment of 1,250 ETB received</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">15 minutes ago</p>
|
||||
</Link>
|
||||
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<p className="text-sm font-medium text-foreground">System update</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">New features available</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">1 hour ago</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-3 border-t border-gray-200 dark:border-slate-700">
|
||||
<Link href="/notifications" className="text-sm text-primary hover:underline">
|
||||
View all notifications
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title={isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||
>
|
||||
{isDark ? <Sun className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" /> : <Moon className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgb(20,113,76)] text-white font-semibold text-sm shadow-md">
|
||||
{user?.fullName?.toUpperCase().charAt(0) || 'A'}
|
||||
</div>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-sm font-medium text-foreground">{user?.fullName || 'Full Name'}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.role || 'User Role'}</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-[rgb(20,113,76)] dark:text-slate-400" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-56 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-foreground">{user?.fullName || 'Full Name'}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email || 'user@email.com'}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20,113,76)] dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
Users,
|
||||
Route,
|
||||
DollarSign,
|
||||
Bell,
|
||||
BarChart3,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Train,
|
||||
MapPin,
|
||||
CreditCard,
|
||||
Shield,
|
||||
UserCheck,
|
||||
Wallet,
|
||||
Gift,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Calendar,
|
||||
Utensils,
|
||||
Package,
|
||||
Moon,
|
||||
Sun,
|
||||
Armchair,
|
||||
Grid3x3
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
|
||||
const navigationSections = [
|
||||
{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
items: [
|
||||
{ name: 'Bookings', href: '/bookings', icon: Ticket },
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Master Data',
|
||||
items: [
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin },
|
||||
{ name: 'Routes', href: '/routes', icon: Route },
|
||||
{ name: 'Trains', href: '/trains', icon: Train },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
||||
{ name: 'Seat Classes', href: '/seat-classes', icon: Settings },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Financial',
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||
{ name: 'Wallet Management', href: '/wallet', icon: Wallet },
|
||||
{ name: 'Promotions', href: '/promotions', icon: Gift },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Customer Services',
|
||||
items: [
|
||||
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift },
|
||||
{ name: 'Support Center', href: '/support', icon: MessageSquare },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell },
|
||||
{ name: 'Food & Dining', href: '/food', icon: Utensils },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Security & Compliance',
|
||||
items: [
|
||||
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
|
||||
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
|
||||
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Reports', href: '/reports', icon: BarChart3 },
|
||||
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ name: 'Agent Operations', href: '/agents', icon: Briefcase },
|
||||
{ name: 'User Management', href: '/settings/users', icon: Users },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-screen flex-col transition-all duration-300',
|
||||
'bg-[rgb(20,113,76)] dark:bg-gradient-to-b dark:from-slate-900 dark:via-slate-800 dark:to-slate-900',
|
||||
'border-r border-[rgb(16,90,61)] dark:border-slate-700/50',
|
||||
isCollapsed ? 'w-16' : 'w-72'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-[rgb(16,90,61)] dark:border-slate-700/50">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/10 shadow-lg">
|
||||
<Train className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">EDR</h1>
|
||||
<p className="text-xs text-white/70">Back-office Passenger Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="rounded-lg p-1.5 text-white/70 hover:bg-white/10 hover:text-white dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||
{navigationSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!isCollapsed && (
|
||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
// Special handling for Settings to avoid conflict with User Management
|
||||
let isActive;
|
||||
if (item.href === '/settings') {
|
||||
// Settings is active only for exact match or non-users sub-routes
|
||||
isActive = pathname === '/settings' ||
|
||||
(pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users'));
|
||||
} else {
|
||||
// Standard matching for other items
|
||||
isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
'hover:bg-white/10 dark:hover:bg-slate-700/50',
|
||||
isActive
|
||||
? 'bg-white/20 text-white shadow-md hover:bg-white/25'
|
||||
: 'text-white/80 hover:text-white dark:text-slate-300 dark:hover:text-white',
|
||||
isCollapsed && 'justify-center'
|
||||
)}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!isCollapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { LucideIcon, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ActionButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void | Promise<void>;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'export';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: LucideIcon;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
|
||||
secondary: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
|
||||
danger: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
|
||||
success: 'bg-green-600 text-white hover:bg-green-700 shadow-sm',
|
||||
export: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export default function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
type = 'button',
|
||||
}: ActionButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!onClick || disabled || loading || isLoading) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await onClick();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = disabled || loading || isLoading;
|
||||
const showLoading = loading || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
Icon && <Icon className="h-4 w-4" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn, getStatusColor } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'status';
|
||||
status?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Badge({ children, variant = 'default', status, className }: BadgeProps) {
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium';
|
||||
|
||||
if (variant === 'status' && status) {
|
||||
return (
|
||||
<span className={cn(baseClasses, getStatusColor(status), className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn(baseClasses, 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const variants = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
danger: 'btn-danger',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn('btn', variants[variant], sizes[size], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ChevronUp, ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (item: T) => ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface Action<T> {
|
||||
label: string;
|
||||
onClick: (item: T) => void | Promise<void>;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
icon?: any;
|
||||
show?: (item: T) => boolean;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
actions?: Action<T>[];
|
||||
onRowClick?: (item: T) => void;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
actions,
|
||||
onRowClick,
|
||||
loading = false,
|
||||
emptyMessage = 'No data available',
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
const [expandedActions, setExpandedActions] = useState<string | null>(null);
|
||||
|
||||
// Ensure data is always an array
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
let direction: 'asc' | 'desc' = 'asc';
|
||||
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedData = [...safeData].sort((a, b) => {
|
||||
if (!sortConfig) return 0;
|
||||
|
||||
const aValue = a[sortConfig.key];
|
||||
const bValue = b[sortConfig.key];
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('card', className)}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('card p-0 overflow-visible', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400',
|
||||
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
column.width && `w-${column.width}`
|
||||
)}
|
||||
onClick={() => column.sortable && handleSort(column.key)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{column.label}
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
sortConfig?.key === column.key && sortConfig.direction === 'asc'
|
||||
? 'text-edr-blue-600'
|
||||
: 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 -mt-1',
|
||||
sortConfig?.key === column.key && sortConfig.direction === 'desc'
|
||||
? 'text-edr-blue-600'
|
||||
: 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedData.map((item, index) => (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{column.render ? column.render(item) : item[column.key]}
|
||||
</td>
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="relative">
|
||||
{(() => {
|
||||
const visibleActions = actions.filter(action => !action.show || action.show(item));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visibleActions.length === 1) {
|
||||
const action = visibleActions[0];
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={() => action.onClick(item)}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{expandedActions === item.id && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<div className="py-1">
|
||||
{visibleActions.map((action, actionIndex) => (
|
||||
<button
|
||||
key={actionIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(item);
|
||||
setExpandedActions(null);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
)}
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{sortedData.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn('input', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
|
||||
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 hover:bg-muted"
|
||||
>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Page <span className="font-medium">{currentPage}</span> of{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
onRowClick?: (item: T) => void;
|
||||
}
|
||||
|
||||
export default function Table<T extends Record<string, any>>({ data, columns, onRowClick }: TableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
||||
{data.map((item, index) => (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||
{column.render ? column.render(item) : item[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500 dark:text-gray-400">No data available</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user