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); // console.log("Application Detail:", application);
@@ -247,7 +258,7 @@ export default function ApplicationDetailPage() {
</div> </div>
</div> </div>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums"> <span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
{application?.reviewed_at} {formatDate(application?.reviewed_at)}
</span> </span>
</div> </div>

View File

@@ -1,8 +1,10 @@
"use client"; "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 Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { import {
BoxCubeIcon, BoxCubeIcon,
@@ -17,16 +19,24 @@ import {
TableIcon, TableIcon,
UserCircleIcon, UserCircleIcon,
} from "../icons/index"; } from "../icons/index";
import SidebarWidget from "./SidebarWidget";
type RoleName = "ADMIN" | "MOD" | "USER" | "HISTORIAN";
type NavItem = { type NavItem = {
name: string; name: string;
icon: React.ReactNode; icon: React.ReactNode;
path?: string; 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 />, icon: <GridIcon />,
name: "Dashboard", name: "Dashboard",
@@ -42,19 +52,21 @@ const navItems: NavItem[] = [
name: "User Profile", name: "User Profile",
path: "/profile", path: "/profile",
}, },
{ {
name: "Forms", name: "Forms",
icon: <ListIcon />, 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", name: "Tables",
icon: <TableIcon />, icon: <TableIcon />,
subItems: [ subItems: [
{ name: "Basic Tables", path: "/basic-tables", pro: false }, // { name: "Basic Tables", path: "/basic-tables", pro: false },
{ name: "User Tables", path: "/user-table", pro: false }, { name: "User Tables", path: "/user-table", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Applications Tables", path: "/applications-tables", pro: false }, { 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 />, icon: <PieChartIcon />,
name: "Charts", name: "Charts",
@@ -102,64 +114,114 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const renderMenuItems = ( // Lấy data gốc từ Redux (không bị render lại vô cớ)
navItems: NavItem[], const rolesData = useSelector((state: RootState) => state.user.data?.roles);
menuType: "main" | "others",
) => ( // 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"> <ul className="flex flex-col gap-4">
{navItems.map((nav, index) => ( {items.map((nav, index) => (
<li key={nav.name}> <li key={nav.name}>
{nav.subItems ? ( {nav.subItems ? (
<button <button
onClick={() => handleSubmenuToggle(index, menuType)} onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group ${ className={`menu-item group ${
openSubmenu?.type === menuType && openSubmenu?.index === index openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active" ? "menu-item-active" : "menu-item-inactive"
: "menu-item-inactive" } cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
} 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"
}`}
> >
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
{nav.icon} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {(isExpanded || isHovered || isMobileOpen) && (
<>
<span className={`menu-item-text`}>{nav.name}</span> <span className={`menu-item-text`}>{nav.name}</span>
)} <ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
{(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"
: ""
}`}
/>
)} )}
</button> </button>
) : ( ) : (
nav.path && ( nav.path && (
<Link <Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
href={nav.path} <span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
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} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {(isExpanded || isHovered || isMobileOpen) && (
@@ -170,52 +232,18 @@ const AppSidebar: React.FC = () => {
)} )}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && ( {nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div <div
ref={(el) => { ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
subMenuRefs.current[`${menuType}-${index}`] = el;
}}
className="overflow-hidden transition-all duration-300" className="overflow-hidden transition-all duration-300"
style={{ style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
height:
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}}
> >
<ul className="mt-2 space-y-1 ml-9"> <ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => ( {nav.subItems.map((subItem) => (
<li key={subItem.name}> <li key={subItem.name}>
<Link <Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
href={subItem.path}
className={`menu-dropdown-item ${
isActive(subItem.path)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{subItem.name} {subItem.name}
<span className="flex items-center gap-1 ml-auto"> <span className="flex items-center gap-1 ml-auto">
{subItem.new && ( {subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
<span {subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</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>
)}
</span> </span>
</Link> </Link>
</li> </li>
@@ -228,157 +256,41 @@ const AppSidebar: React.FC = () => {
</ul> </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 ( return (
<aside <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 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]"}
isExpanded || isMobileOpen ${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered(true)} onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div <div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
className={`py-8 flex ${
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<Link href="/"> <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 <Image
src="/images/logo/logo.svg" src="/images/logo/logo.svg"
alt="Logo" alt="Logo"
width={32} width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
height={32} height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
/> />
)}
</Link> </Link>
</div> </div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar"> <div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="mb-6"> <nav className="mb-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h2 <h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${ {isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<HorizontaLDots />
)}
</h2> </h2>
{renderMenuItems(navItems, "main")} {renderMenuItems(filteredNavItems, "main")}
</div> </div>
<div>
<div className=""> <h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
<h2 {isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
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> </h2>
{renderMenuItems(othersItems, "others")} {renderMenuItems(filteredOthersItems, "others")}
</div> </div>
</div> </div>
</nav> </nav>
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
</div> </div>
</aside> </aside>
); );