feat(admin): collapsible sidebar and responsive data tables with custom scroll
Some checks failed
Deploy Canina / deploy (push) Successful in 33s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 8s

This commit is contained in:
پارسا آقایی 2026-09-23 23:45:18 +03:30
parent b98c6bb457
commit 8a3adacd38
7 changed files with 171 additions and 49 deletions

View File

@ -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>

View File

@ -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 */}

View File

@ -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" />

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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)}