update: phan quyen
All checks were successful
Build and Release / release (push) Successful in 28s

This commit is contained in:
2026-04-20 02:46:15 +07:00
parent 1fe25c1944
commit 4018d8e135
2 changed files with 150 additions and 227 deletions

View File

@@ -118,6 +118,17 @@ export default function ApplicationDetailPage() {
}
}
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// console.log("Application Detail:", application);
@@ -247,7 +258,7 @@ export default function ApplicationDetailPage() {
</div>
</div>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
{application?.reviewed_at}
{formatDate(application?.reviewed_at)}
</span>
</div>

View File

@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useSidebar } from "../context/SidebarContext";
import {
BoxCubeIcon,
@@ -17,16 +19,24 @@ import {
TableIcon,
UserCircleIcon,
} from "../icons/index";
import SidebarWidget from "./SidebarWidget";
type RoleName = "ADMIN" | "MOD" | "USER" | "HISTORIAN";
type NavItem = {
name: string;
icon: React.ReactNode;
path?: string;
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
roles?: RoleName[];
subItems?: {
name: string;
path: string;
pro?: boolean;
new?: boolean;
roles?: RoleName[];
}[];
};
const navItems: NavItem[] = [
const ALL_NAV_ITEMS: NavItem[] = [
{
icon: <GridIcon />,
name: "Dashboard",
@@ -42,19 +52,21 @@ const navItems: NavItem[] = [
name: "User Profile",
path: "/profile",
},
{
name: "Forms",
icon: <ListIcon />,
subItems: [{ name: "Form Elements", path: "/form-elements", pro: false }, { name: "Role Upgrade", path: "/role-upgrade", pro: false }],
subItems: [
// { name: "Form Elements", path: "/form-elements", pro: false },
{ name: "Role Upgrade", path: "/role-upgrade", pro: false }
],
},
{
name: "Tables",
icon: <TableIcon />,
subItems: [
{ name: "Basic Tables", path: "/basic-tables", pro: false },
{ name: "User Tables", path: "/user-table", pro: false },
{ name: "Applications Tables", path: "/applications-tables", pro: false },
// { name: "Basic Tables", path: "/basic-tables", pro: false },
{ name: "User Tables", path: "/user-table", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Applications Tables", path: "/applications-tables", pro: false, roles: ["ADMIN", "MOD"] },
],
},
{
@@ -67,7 +79,7 @@ const navItems: NavItem[] = [
},
];
const othersItems: NavItem[] = [
const OTHERS_ITEMS: NavItem[] = [
{
icon: <PieChartIcon />,
name: "Charts",
@@ -102,64 +114,114 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname();
const renderMenuItems = (
navItems: NavItem[],
menuType: "main" | "others",
) => (
// Lấy data gốc từ Redux (không bị render lại vô cớ)
const rolesData = useSelector((state: RootState) => state.user.data?.roles);
// Chỉ tạo mảng map mới khi rolesData thực sự thay đổi
const userRoles = useMemo(() => {
return rolesData?.map((r: any) => r.name) || [];
}, [rolesData]);
// 2. Logic lọc Menu theo Role
const filterMenuByRole = useCallback((items: NavItem[]) => {
return items
.map((item) => ({
...item,
subItems: item.subItems?.filter((sub) => {
if (!sub.roles) return true; // Ai cũng xem được nếu không định nghĩa role
return sub.roles.some((role) => userRoles.includes(role));
}),
}))
.filter((item) => {
// Ẩn mục cha nếu nó yêu cầu role mà user không có
if (item.roles && !item.roles.some((role) => userRoles.includes(role))) return false;
// Ẩn mục cha nếu các mục con đã bị lọc sạch (đối với menu dạng dropdown)
if (item.subItems && item.subItems.length === 0 && !item.path) return false;
return true;
});
}, [userRoles]);
const filteredNavItems = useMemo(() => filterMenuByRole(ALL_NAV_ITEMS), [filterMenuByRole]);
const filteredOthersItems = useMemo(() => filterMenuByRole(OTHERS_ITEMS), [filterMenuByRole]);
// --- State quản lý đóng mở Submenu ---
const [openSubmenu, setOpenSubmenu] = useState<{
type: "main" | "others";
index: number;
} | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const isActive = useCallback((path: string) => path === pathname, [pathname]);
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
setOpenSubmenu((prev) =>
prev?.type === menuType && prev?.index === index ? null : { type: menuType, index }
);
};
useEffect(() => {
let submenuMatched = false;
[
{ items: filteredNavItems, type: "main" },
{ items: filteredOthersItems, type: "others" },
].forEach(({ items, type }) => {
items.forEach((nav, index) => {
nav.subItems?.forEach((sub) => {
if (isActive(sub.path)) {
setOpenSubmenu((prev) => {
if (prev?.type === type && prev?.index === index) return prev;
return { type: type as "main" | "others", index };
});
submenuMatched = true;
}
});
});
});
if (!submenuMatched) {
setOpenSubmenu((prev) => (prev !== null ? null : prev));
}
}, [pathname, isActive, filteredNavItems, filteredOthersItems]);
useEffect(() => {
if (openSubmenu !== null) {
const key = `${openSubmenu.type}-${openSubmenu.index}`;
if (subMenuRefs.current[key]) {
setSubMenuHeight((prev) => ({
...prev,
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
}));
}
}
}, [openSubmenu]);
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
<ul className="flex flex-col gap-4">
{navItems.map((nav, index) => (
{items.map((nav, index) => (
<li key={nav.name}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${
!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={` ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
? "menu-item-active" : "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
>
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<>
<span className={`menu-item-text`}>{nav.name}</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
<ChevronDownIcon
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
openSubmenu?.type === menuType &&
openSubmenu?.index === index
? "rotate-180 text-brand-500"
: ""
}`}
/>
<ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
</>
)}
</button>
) : (
nav.path && (
<Link
href={nav.path}
className={`menu-item group ${
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`${
isActive(nav.path)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
<Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
<span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
@@ -170,52 +232,18 @@ const AppSidebar: React.FC = () => {
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => {
subMenuRefs.current[`${menuType}-${index}`] = el;
}}
ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
className="overflow-hidden transition-all duration-300"
style={{
height:
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}}
style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
>
<ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => (
<li key={subItem.name}>
<Link
href={subItem.path}
className={`menu-dropdown-item ${
isActive(subItem.path)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
<Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
{subItem.name}
<span className="flex items-center gap-1 ml-auto">
{subItem.new && (
<span
className={`ml-auto ${
isActive(subItem.path)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge `}
>
new
</span>
)}
{subItem.pro && (
<span
className={`ml-auto ${
isActive(subItem.path)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge `}
>
pro
</span>
)}
{subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
{subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</span>}
</span>
</Link>
</li>
@@ -228,157 +256,41 @@ const AppSidebar: React.FC = () => {
</ul>
);
const [openSubmenu, setOpenSubmenu] = useState<{
type: "main" | "others";
index: number;
} | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{},
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
// const isActive = (path: string) => path === pathname;
const isActive = useCallback((path: string) => path === pathname, [pathname]);
useEffect(() => {
// Check if the current path matches any submenu item
let submenuMatched = false;
["main", "others"].forEach((menuType) => {
const items = menuType === "main" ? navItems : othersItems;
items.forEach((nav, index) => {
if (nav.subItems) {
nav.subItems.forEach((subItem) => {
if (isActive(subItem.path)) {
setOpenSubmenu({
type: menuType as "main" | "others",
index,
});
submenuMatched = true;
}
});
}
});
});
// If no submenu item matches, close the open submenu
if (!submenuMatched) {
setOpenSubmenu(null);
}
}, [pathname, isActive]);
useEffect(() => {
// Set the height of the submenu items when the submenu is opened
if (openSubmenu !== null) {
const key = `${openSubmenu.type}-${openSubmenu.index}`;
if (subMenuRefs.current[key]) {
setSubMenuHeight((prevHeights) => ({
...prevHeights,
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
}));
}
}
}, [openSubmenu]);
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
setOpenSubmenu((prevOpenSubmenu) => {
if (
prevOpenSubmenu &&
prevOpenSubmenu.type === menuType &&
prevOpenSubmenu.index === index
) {
return null;
}
return { type: menuType, index };
});
};
return (
<aside
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${
isExpanded || isMobileOpen
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
${isExpanded || isMobileOpen ? "w-[290px]" : isHovered ? "w-[290px]" : "w-[90px]"}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={`py-8 flex ${
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
<Link href="/">
{isExpanded || isHovered || isMobileOpen ? (
<>
<Image
className="dark:hidden"
src="/images/logo/logo.svg"
alt="Logo"
width={80}
height={50}
/>
<Image
className="hidden dark:block"
src="/images/logo/logo.svg"
alt="Logo"
width={150}
height={40}
/>
</>
) : (
<Image
src="/images/logo/logo.svg"
alt="Logo"
width={32}
height={32}
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
/>
)}
</Link>
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<HorizontaLDots />
)}
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
{isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
</h2>
{renderMenuItems(navItems, "main")}
{renderMenuItems(filteredNavItems, "main")}
</div>
<div className="">
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Others"
) : (
<HorizontaLDots />
)}
<div>
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
</h2>
{renderMenuItems(othersItems, "others")}
{renderMenuItems(filteredOthersItems, "others")}
</div>
</div>
</nav>
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
</div>
</aside>
);