Files
car_mms/app/components/layout/Sidebar.tsx
2026-03-08 14:27:16 +03:00

348 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReactNode, useState, useEffect, useRef } from 'react';
import { Link, useLocation } from '@remix-run/react';
import { useFocusTrap } from '~/hooks/useFocusTrap';
import { designTokens } from '~/lib/design-tokens';
interface SidebarProps {
isCollapsed: boolean;
onToggle: () => void;
isMobile: boolean;
isOpen: boolean;
onClose: () => void;
userAuthLevel: number;
}
interface NavigationItem {
name: string;
href: string;
icon: ReactNode;
authLevel?: number; // Minimum auth level required
}
const navigationItems: NavigationItem[] = [
{
name: 'لوحة التحكم',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z" />
</svg>
),
},
{
name: 'العملاء',
href: '/customers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
},
{
name: 'المركبات',
href: '/vehicles',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6 0a1 1 0 001 1h4a1 1 0 001-1m-6 0V9a1 1 0 00-1-1v0a1 1 0 00-1 1v8a1 1 0 001 1z" />
</svg>
),
},
{
name: 'زيارات الصيانة',
href: '/maintenance-visits',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
name: 'المصروفات',
href: '/expenses',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'التقارير المالية',
href: '/financial-reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'إدارة المستخدمين',
href: '/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'إعدادات النظام',
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
];
export function Sidebar({ isCollapsed, onToggle, isMobile, isOpen, onClose, userAuthLevel }: SidebarProps) {
const location = useLocation();
const sidebarRef = useRef<HTMLElement>(null);
const [announcement, setAnnouncement] = useState('');
const [isClient, setIsClient] = useState(false);
// Set client-side flag
useEffect(() => {
setIsClient(true);
}, []);
// Filter navigation items based on user auth level
const filteredNavItems = navigationItems.filter(item =>
!item.authLevel || userAuthLevel <= item.authLevel
);
// Enable focus trap for mobile menu when open
useFocusTrap(sidebarRef, isOpen && isClient);
// Close sidebar on route change
useEffect(() => {
if (isOpen) {
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// Handle Escape key to close mobile menu
useEffect(() => {
if (!isClient) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
setAnnouncement('القائمة مغلقة');
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose, isClient]);
// Announce menu state changes for screen readers
useEffect(() => {
if (isOpen && isClient) {
setAnnouncement('القائمة مفتوحة');
}
}, [isOpen, isClient]);
// Render both mobile and desktop sidebars, use CSS to show/hide
return (
<>
{/* Screen reader announcement */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
{/* Mobile overlay - only show on mobile/tablet */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300 md:hidden"
style={{ zIndex: designTokens.zIndex.overlay }}
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Mobile Sidebar - hidden on desktop (md+) */}
<aside
ref={sidebarRef}
className={`
md:hidden
fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-white shadow-2xl
transform transition-transform duration-300 ease-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
style={{ zIndex: designTokens.zIndex.sidebar }}
dir="rtl"
role="navigation"
aria-label="القائمة الرئيسية"
aria-hidden={!isOpen}
>
{/* Mobile Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200 bg-white">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 className="text-lg font-bold text-gray-900">نظام الصيانة</h1>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
aria-label="إغلاق القائمة"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Mobile Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="القائمة الرئيسية">
<ul className="space-y-1" role="list">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<li key={item.name}>
<Link
to={item.href}
className={`
group flex items-center gap-3 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-150 relative
${isActive
? 'bg-blue-50 text-blue-900 shadow-sm'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}
`}
aria-current={isActive ? 'page' : undefined}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md" aria-hidden="true"></div>
)}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-600'}`}>
{item.icon}
</div>
<span className="flex-1">{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Mobile Footer */}
<div className="border-t border-gray-200 p-4 bg-gray-50">
<p className="text-xs text-gray-500 text-center">
نظام إدارة صيانة السيارات
</p>
</div>
</aside>
{/* Desktop Sidebar - hidden on mobile/tablet (< md) */}
<aside
className={`
hidden md:block
fixed top-0 right-0 h-full bg-white shadow-lg
transition-all duration-300 ease-out
${isCollapsed ? 'w-16' : 'w-64'}
`}
style={{ zIndex: designTokens.zIndex.sidebar }}
dir="rtl"
role="navigation"
aria-label="القائمة الرئيسية"
>
{/* Desktop Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200 bg-white">
<div className="flex items-center flex-1 min-w-0">
<div className="h-10 w-10 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
{!isCollapsed && (
<h1 className="mr-3 text-lg font-bold text-gray-900 truncate">نظام الصيانة</h1>
)}
</div>
<button
onClick={() => {
onToggle();
setAnnouncement(isCollapsed ? 'القائمة موسعة' : 'القائمة مطوية');
}}
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors flex-shrink-0"
aria-label={isCollapsed ? 'توسيع القائمة' : 'طي القائمة'}
aria-expanded={!isCollapsed}
>
<svg
className={`h-5 w-5 transform transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
{/* Desktop Navigation */}
<nav className="flex-1 overflow-y-auto px-2 py-4" aria-label="القائمة الرئيسية">
<ul className="space-y-1" role="list">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<li key={item.name}>
<Link
to={item.href}
className={`
group flex items-center gap-3 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-150 relative
${isActive
? 'bg-blue-50 text-blue-900 shadow-sm'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}
${isCollapsed ? 'justify-center' : ''}
`}
title={isCollapsed ? item.name : undefined}
aria-label={isCollapsed ? item.name : undefined}
aria-current={isActive ? 'page' : undefined}
>
{isActive && !isCollapsed && (
<div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md" aria-hidden="true"></div>
)}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-600'}`}>
{item.icon}
</div>
{!isCollapsed && (
<span className="flex-1 truncate">{item.name}</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Desktop Footer */}
{!isCollapsed && (
<div className="border-t border-gray-200 p-4 bg-gray-50">
<p className="text-xs text-gray-500 text-center">
نظام إدارة صيانة السيارات
</p>
</div>
)}
</aside>
</>
);
}