feat(admin): collapsible sidebar and responsive data tables with custom scroll
This commit is contained in:
parent
b98c6bb457
commit
8a3adacd38
@ -1,17 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import Topbar from './Topbar';
|
||||
|
||||
export default function Layout() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('canina_admin_sidebar_collapsed') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem('canina_admin_sidebar_collapsed', String(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 text-gray-900 font-vazir relative overflow-hidden" dir="rtl">
|
||||
<Sidebar isOpen={isMobileMenuOpen} setIsOpen={setIsMobileMenuOpen} />
|
||||
<div className="flex-1 flex flex-col min-h-screen w-full lg:w-auto lg:pr-64">
|
||||
<Topbar toggleMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<div className="flex min-h-screen bg-gray-50 text-gray-900 font-vazir relative overflow-x-hidden" dir="rtl">
|
||||
<Sidebar
|
||||
isOpen={isMobileMenuOpen}
|
||||
setIsOpen={setIsMobileMenuOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<div className={`flex-1 flex flex-col min-h-screen w-full transition-all duration-300 ease-in-out ${
|
||||
isCollapsed ? 'lg:pr-20' : 'lg:pr-64'
|
||||
}`}>
|
||||
<Topbar
|
||||
toggleMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
isCollapsed={isCollapsed}
|
||||
toggleCollapse={toggleCollapse}
|
||||
/>
|
||||
<main className="flex-1 p-3 sm:p-5 md:p-6 overflow-y-auto overflow-x-hidden max-w-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -57,9 +57,10 @@ interface MenuGroup {
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (val: boolean) => void;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
export default function Sidebar({ isOpen, setIsOpen, isCollapsed = false }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@ -239,34 +240,97 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`w-64 bg-white border-l border-gray-200 h-screen h-[100dvh] fixed lg:fixed lg:right-0 lg:top-0 lg:bottom-0 flex flex-col font-vazir shadow-sm z-50 transform transition-transform duration-300 ease-in-out overscroll-contain ${
|
||||
className={`${
|
||||
isCollapsed ? 'w-64 lg:w-20' : 'w-64'
|
||||
} bg-white border-l border-gray-200 h-screen h-[100dvh] fixed lg:fixed lg:right-0 lg:top-0 lg:bottom-0 flex flex-col font-vazir shadow-sm z-50 transform transition-all duration-300 ease-in-out overscroll-contain ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-16 flex items-center justify-between px-5 border-b border-gray-200 shrink-0 bg-white">
|
||||
<div className={`h-16 flex items-center ${isCollapsed ? 'justify-center lg:px-2 px-5' : 'justify-between px-5'} border-b border-gray-200 shrink-0 bg-white`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-xl bg-purple-600 flex items-center justify-center text-white font-black text-sm italic shadow-md">
|
||||
<div className="w-8 h-8 rounded-xl bg-purple-600 flex items-center justify-center text-white font-black text-sm italic shadow-md shrink-0">
|
||||
C
|
||||
</div>
|
||||
<span className="text-base font-black text-gray-900">کنینا | ادمینپنل</span>
|
||||
{!isCollapsed && (
|
||||
<span className="text-base font-black text-gray-900 truncate">کنینا | ادمینپنل</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] bg-purple-50 text-purple-700 font-bold px-2 py-0.5 rounded-md border border-purple-200">
|
||||
{!isCollapsed && (
|
||||
<span className="text-[10px] bg-purple-50 text-purple-700 font-bold px-2 py-0.5 rounded-md border border-purple-200 shrink-0">
|
||||
v2.4
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu Navigation - touch friendly & full scrolling */}
|
||||
<nav className="flex-1 overflow-y-auto overscroll-contain py-3 px-3 space-y-2 pb-16 lg:pb-6">
|
||||
<nav className="flex-1 overflow-y-auto overscroll-contain py-3 px-2 space-y-2 pb-16 lg:pb-6 custom-scrollbar">
|
||||
{menuGroups.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
const isExpanded = activeGroupId === group.id;
|
||||
const isExpanded = isCollapsed ? false : activeGroupId === group.id;
|
||||
const hasActiveChild = group.items.some(
|
||||
(item) =>
|
||||
location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path))
|
||||
);
|
||||
|
||||
if (isCollapsed) {
|
||||
// In collapsed mode, render compact group buttons with first active item link or dropdown
|
||||
const firstItem = group.items[0];
|
||||
return (
|
||||
<div key={group.id} className="relative group/collapsed flex flex-col items-center">
|
||||
<Link
|
||||
to={firstItem.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
title={group.title}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-2xl transition-all ${
|
||||
hasActiveChild
|
||||
? 'bg-purple-600 text-white shadow-md shadow-purple-600/30'
|
||||
: 'text-gray-500 hover:bg-purple-50 hover:text-purple-700'
|
||||
}`}
|
||||
>
|
||||
<GroupIcon className="w-5 h-5 shrink-0" />
|
||||
</Link>
|
||||
|
||||
{/* Flyout Submenu on Hover in Collapsed Mode */}
|
||||
<div className="hidden lg:group-hover/collapsed:block absolute right-full top-0 mr-2 w-56 bg-white rounded-2xl shadow-xl border border-gray-100 p-2 z-50">
|
||||
<div className="text-[11px] font-black text-purple-700 px-3 py-1.5 border-b border-gray-100 mb-1">
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((subItem) => {
|
||||
const isSubActive =
|
||||
location.pathname === subItem.path ||
|
||||
(subItem.path !== '/' && location.pathname.startsWith(subItem.path));
|
||||
const SubIcon = subItem.icon;
|
||||
return (
|
||||
<Link
|
||||
key={subItem.path}
|
||||
to={subItem.path}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
isSubActive
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-700 hover:bg-purple-50 hover:text-purple-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SubIcon className={`w-4 h-4 ${isSubActive ? 'text-white' : 'text-gray-400'}`} />
|
||||
<span>{subItem.label}</span>
|
||||
</div>
|
||||
{subItem.badge !== undefined && (
|
||||
<span className="text-[10px] font-black px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-700">
|
||||
{subItem.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.id} className="rounded-2xl overflow-hidden bg-gray-50/50 border border-gray-100 transition-colors">
|
||||
{/* Group Accordion Header */}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Search, UserCircle, Menu, Activity, ShieldCheck, MessageSquare, Calendar, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Search, UserCircle, Menu, Activity, ShieldCheck, MessageSquare, Calendar, Clock, RefreshCw, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
|
||||
interface TopbarProps {
|
||||
toggleMenu: () => void;
|
||||
isCollapsed?: boolean;
|
||||
toggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
const SEARCHABLE_PAGES = [
|
||||
@ -57,7 +59,7 @@ interface LiveMonitoringSummary {
|
||||
};
|
||||
}
|
||||
|
||||
export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
export default function Topbar({ toggleMenu, isCollapsed, toggleCollapse }: TopbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
@ -207,6 +209,18 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{toggleCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapse}
|
||||
className="hidden lg:flex p-2 text-gray-500 hover:text-purple-700 hover:bg-purple-50 rounded-xl transition-colors cursor-pointer shrink-0 border border-gray-200"
|
||||
title={isCollapsed ? 'باز کردن سایدبار' : 'کوچک کردن سایدبار'}
|
||||
aria-label={isCollapsed ? 'باز کردن سایدبار' : 'کوچک کردن سایدبار'}
|
||||
>
|
||||
{isCollapsed ? <PanelRightOpen className="w-5 h-5" /> : <PanelRightClose className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<Search className="w-4 h-4 text-gray-400" />
|
||||
|
||||
@ -56,5 +56,22 @@
|
||||
.font-vazir {
|
||||
font-family: "Vazirmatn", sans-serif !important;
|
||||
}
|
||||
|
||||
/* Custom visible styled scrollbar for tables & cards */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -710,9 +710,9 @@ export default function Orders() {
|
||||
</div>
|
||||
|
||||
{/* Orders Table */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden w-full">
|
||||
<div className="overflow-x-auto custom-scrollbar w-full select-none cursor-grab active:cursor-grabbing pb-2">
|
||||
<table className="w-full text-right min-w-[1050px]">
|
||||
<thead className="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-200 select-none">
|
||||
<tr>
|
||||
<th className="py-4 px-6 font-bold">شماره / کد پیگیری</th>
|
||||
|
||||
@ -595,8 +595,8 @@ export default function Transactions() {
|
||||
هیچ تراکنشی با فیلترهای مشخص شده یافت نشد.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right border-collapse">
|
||||
<div className="overflow-x-auto custom-scrollbar w-full select-none cursor-grab active:cursor-grabbing pb-2">
|
||||
<table className="w-full text-right border-collapse min-w-[1100px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/80 border-b border-gray-200 text-[11px] font-black text-gray-500">
|
||||
<th className="p-4">کاربر خریدار</th>
|
||||
|
||||
@ -378,21 +378,21 @@ export default function Users() {
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right font-vazir text-xs">
|
||||
<thead className="bg-gray-50 text-gray-500 border-b border-gray-200">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden w-full">
|
||||
<div className="overflow-x-auto custom-scrollbar w-full select-none cursor-grab active:cursor-grabbing pb-2">
|
||||
<table className="w-full text-right font-vazir text-xs min-w-[1050px]">
|
||||
<thead className="bg-gray-50 text-gray-500 border-b border-gray-200 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="py-4 px-6 font-bold">کاربر</th>
|
||||
<th className="py-4 px-6 font-bold">شماره موبایل</th>
|
||||
<th className="py-4 px-6 font-bold">نوع حساب</th>
|
||||
<th className="py-4 px-6 font-bold">مجموع خرید (LTV)</th>
|
||||
<th className="py-4 px-6 font-bold text-center">سفارشات</th>
|
||||
<th className="py-4 px-6 font-bold text-center">❤️ مهربانی</th>
|
||||
<th className="py-4 px-6 font-bold text-center">دیدگاهها</th>
|
||||
<th className="py-4 px-6 font-bold">کیف پول</th>
|
||||
<th className="py-4 px-6 font-bold">تاریخ عضویت</th>
|
||||
<th className="py-4 px-6 font-bold text-center">عملیات</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">کاربر</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">شماره موبایل</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">نوع حساب</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">مجموع خرید (LTV)</th>
|
||||
<th className="py-4 px-6 font-bold text-center whitespace-nowrap">سفارشات</th>
|
||||
<th className="py-4 px-6 font-bold text-center whitespace-nowrap">❤️ مهربانی</th>
|
||||
<th className="py-4 px-6 font-bold text-center whitespace-nowrap">دیدگاهها</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">کیف پول</th>
|
||||
<th className="py-4 px-6 font-bold whitespace-nowrap">تاریخ عضویت</th>
|
||||
<th className="py-4 px-6 font-bold text-center whitespace-nowrap">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-xs font-vazir">
|
||||
@ -418,21 +418,21 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50/70 transition-colors">
|
||||
<td className="py-4 px-6 font-bold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center font-black text-xs">
|
||||
<td className="py-4 px-6 font-bold text-gray-900 flex items-center gap-2 whitespace-nowrap">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center font-black text-xs shrink-0">
|
||||
{fullName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="whitespace-nowrap">
|
||||
<div>{fullName}</div>
|
||||
{isMainAdmin && (
|
||||
<span className="text-[10px] text-purple-600 font-black">حساب اصلی مدیریت</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-mono text-gray-700" dir="ltr">
|
||||
<td className="py-4 px-6 font-mono text-gray-700 whitespace-nowrap" dir="ltr">
|
||||
{user.mobile || '---'}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<td className="py-4 px-6 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold ${
|
||||
user.role === 'User_Wholesale'
|
||||
@ -453,27 +453,27 @@ export default function Users() {
|
||||
: 'مشتری'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-mono font-bold text-emerald-700">
|
||||
<td className="py-4 px-6 font-mono font-bold text-emerald-700 whitespace-nowrap">
|
||||
{spent > 0 ? `${spent.toLocaleString('fa-IR')} ت` : '۰'}
|
||||
</td>
|
||||
<td className="py-4 px-6 font-bold text-gray-800">
|
||||
<td className="py-4 px-6 font-bold text-gray-800 whitespace-nowrap text-center">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded-md font-mono">
|
||||
{user.ordersCount || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-mono font-bold text-rose-600">
|
||||
<td className="py-4 px-6 font-mono font-bold text-rose-600 whitespace-nowrap text-center">
|
||||
{charity > 0 ? `${charity.toLocaleString('fa-IR')} ت` : '---'}
|
||||
</td>
|
||||
<td className="py-4 px-6 font-bold text-purple-600 font-mono">
|
||||
<td className="py-4 px-6 font-bold text-purple-600 font-mono whitespace-nowrap text-center">
|
||||
{user.reviewsCount || 0}
|
||||
</td>
|
||||
<td className="py-4 px-6 font-mono font-bold text-gray-800">
|
||||
<td className="py-4 px-6 font-mono font-bold text-gray-800 whitespace-nowrap">
|
||||
{balance > 0 ? `${balance.toLocaleString('fa-IR')} ت` : '۰'}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-500 text-xs font-medium">
|
||||
<td className="py-4 px-6 text-gray-500 text-xs font-medium whitespace-nowrap">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString('fa-IR') : '---'}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
<td className="py-4 px-6 text-center whitespace-nowrap">
|
||||
<div className="flex justify-center items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setViewUser(user)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user