first commit

This commit is contained in:
TuanVT
2026-05-23 09:48:07 +07:00
parent 03b9101bd5
commit b6ccde2c74
134 changed files with 7178 additions and 110 deletions
@@ -0,0 +1,71 @@
"use client";
import React from "react";
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
import clsx from "clsx";
import Header from "./componets/Header";
import Navbar from "./componets/Navbar";
import RequireAuth from "@/components/protected/RequiredAuth";
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
const [showFull, setShowFull] = React.useState(false);
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
return (
// <RequireAuth>
<ContextBaseLayout
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
>
<div className="min-h-screen bg-[#f8f8f8]">
{/* OVERLAY cho Mobile */}
<div
className={clsx(
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
)}
onClick={() => setOpenMenuMobile(false)}
/>
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
<nav
className={clsx(
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
// Desktop logic
showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
// Mobile logic
openMenuMobile ? "translate-x-0" : "-translate-x-full",
)}
>
<Navbar />
</nav>
{/* HEADER */}
<header
className={clsx(
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
showFull
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
: "left-0 w-full",
"left-0 w-full", // Mặc định full width trên mobile
)}
>
<Header title={title} breadcrumb={breadcrumb} />
</header>
{/* MAIN CONTENT */}
<main
className={clsx(
"pt-[92px] pb-6 px-6 transition-all duration-300",
showFull ? "xl:pl-[264px]" : "xl:pl-6",
"pl-6", // Mặc định padding trên mobile
)}
>
{children}
</main>
</div>
</ContextBaseLayout>
// </RequireAuth>
);
};
export default BaseLayout;
@@ -0,0 +1,101 @@
"use client";
import React, { useContext, useEffect, useState } from "react";
import { PropsHeader } from "./interface";
import { usePathname } from "next/dist/client/components/navigation";
import { useSelector } from "react-redux";
import { RootState } from "@/redux/store";
import { ContextBaseLayout } from "../../BaseLayout";
import Image from "next/image";
import icons from "@/constant/images/icons";
import { ChevronDown } from "lucide-react";
import clsx from "clsx";
import MenuProfile from "../MenuProfile";
const Header = ({ title, breadcrumb }: PropsHeader) => {
const pathname = usePathname();
const { infoUser } = useSelector((state: RootState) => state.user);
const context = useContext(ContextBaseLayout);
const [openProfile, setOpenProfile] = useState(false);
//Đóng menu mobile khi đổi route
useEffect(() => {
context.setOpenMenuMobile?.(false);
}, [pathname, context]);
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
return (
<div className="h-full w-full px-6 flex items-center justify-between bg-white relatives">
<div className="flex items-center gap-4">
{/* Nút bấm Mobile: Mở menu mobile */}
<div
className="xl:hidden cursor-pointer"
onClick={() => context?.setOpenMenuMobile?.(true)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
</div>
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
<div
className="hidden xl:block cursor-pointer"
onClick={() => context?.setShowFull?.(!context?.showFull)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
</div>
{breadcrumb ? (
breadcrumb
) : (
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
)}
</div>
<div className="flex items-center gap-7">
<div className="cursor-pointer" onClick={toggleFullScreen}>
<Image src={icons.full_screen} alt="" width={24} height={24} />
</div>
<div
className="relative flex items-center gap-2 cursor-pointer"
onClick={() => setOpenProfile(!openProfile)}
>
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden">
<Image
src={
infoUser?.avatar
? `${process.env.NEXT_PUBLIC_IMAGE}/${infoUser.avatar}`
: icons.avatar
}
alt="avatar"
width={40}
height={40}
/>
</div>
<p className="hidden md:block text-sm font-semibold text-[#171832]">
{infoUser?.fullname || "User admin"}
</p>
<ChevronDown
size={16}
className={clsx(
"transition-transform",
openProfile && "rotate-180",
)}
/>
{openProfile && (
<div className="absolute right-0 top-[120%] z-50">
<MenuProfile onClose={() => setOpenProfile(false)} />
</div>
)}
</div>
</div>
</div>
);
};
export default Header;
@@ -0,0 +1 @@
export { default } from "./Header";
@@ -0,0 +1,4 @@
export interface PropsHeader {
title: string;
breadcrumb?: React.ReactNode;
}
@@ -0,0 +1,120 @@
import React, { use, useCallback, useState } from "react";
import { PropsMenuProfile } from "./interface";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import authServices from "@/services/authServices";
import { store } from "@/redux/store";
import { logout } from "@/redux/reducer/auth";
import { setInfoUser } from "@/redux/reducer/user";
import { PATH } from "@/constant/config";
import Link from "next/link";
import clsx from "clsx";
import { LogOut, ShieldCog, ShieldUser } from "lucide-react";
import CustomDialog from "@/components/customs/custom-dialog";
const MenuProfile = ({ onClose }: PropsMenuProfile) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [openLogout, setOpenLogout] = useState(false);
/* ================== CHECK ACTIVE ================== */
const checkActive = useCallback(
(path: string) => {
return pathname === path;
},
[pathname],
);
/* ================== LOGOUT ================== */
const logoutMutation = useMutation({
mutationFn: () =>
httpRequest({
showMessageFailed: true,
showMessageSuccess: false,
http: authServices.logout(),
}),
onSuccess(data) {
if (data) {
store.dispatch(logout());
store.dispatch(setInfoUser(null));
router.push(PATH.LOGIN);
}
},
});
const handleLogout = () => {
logoutMutation.mutate();
};
return (
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
{/* PROFILE */}
<Link
href={PATH.PROFILE}
onClick={onClose}
className={clsx(
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
checkActive(PATH.PROFILE) && "bg-gray-100",
)}
>
<ShieldUser size={20} />
<div>
<p className="text-sm font-medium">Thông tin nhân</p>
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" />
{/* CHANGE PASSWORD */}
<Link
href={`${PATH.PROFILE}?_action=change-password`}
onClick={onClose}
className={clsx(
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
pathname === PATH.PROFILE &&
searchParams.get("_action") === "change-password" &&
"bg-gray-100",
)}
>
<ShieldCog size={20} />
<div>
<p className="text-sm font-medium">Đi mật khẩu</p>
<p className="text-xs text-gray-500">Thay đi mật khẩu</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" />
{/* LOGOUT */}
<div
onClick={() => {
setOpenLogout(true);
onClose();
}}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
>
<LogOut size={20} className="text-red-500" />
<div>
<p className="text-sm font-medium text-red-500">Đăng xuất</p>
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
</div>
</div>
{/* DIALOG */}
<CustomDialog
open={openLogout}
onClose={() => setOpenLogout(false)}
onSubmit={handleLogout}
title="Đăng xuất"
note="Bạn có muốn đăng xuất khỏi hệ thống không?"
titleCancel="Không"
titleSubmit="Đăng xuất"
type="error"
/>
</div>
);
};
export default MenuProfile;
@@ -0,0 +1 @@
export { default } from "./MenuProfile";
@@ -0,0 +1,3 @@
export interface PropsMenuProfile {
onClose: () => void;
}
@@ -0,0 +1,71 @@
"use client";
import React, { useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Menus, PATH } from "@/constant/config";
import Image from "next/image";
import icons from "@/constant/images/icons";
import clsx from "clsx";
const Navbar = () => {
const pathname = usePathname();
const checkActive = useCallback(
(pathActive: string) => {
const currentRoute = pathname.split("/")[1];
return pathActive === `/${currentRoute}`;
},
[pathname],
);
return (
<div className="h-full w-full flex flex-col items-center p-3 bg-white">
<Link href={PATH.HOME} className="flex items-center justify-center py-2">
<Image alt="Logo" src={icons.avatar} width={40} height={40} />
<h4 className="ml-2 text-sm font-semibold text-blue-900 select-none">
Quản
</h4>
</Link>
<div className="flex-1 w-full overflow-auto pt-3">
{Menus.map((menu, i) => (
<div key={i} className="mb-4">
{/* TITLE */}
<h5 className="text-xs font-semibold text-gray-400 uppercase mb-2">
{menu.title}
</h5>
<div className="space-y-1">
{menu.group.map((tab, y) => {
const isActive = checkActive(tab.pathActive);
return (
<Link
key={y}
href={tab.path}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg transition",
isActive
? "bg-indigo-100 text-blue-700"
: "text-gray-800 hover:bg-indigo-100 hover:text-blue-700",
)}
>
<tab.icon
size={22}
className={clsx(
isActive ? "text-blue-700" : "text-gray-800",
)}
/>
<p className="text-base font-semibold">{tab.title}</p>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default Navbar;
@@ -0,0 +1 @@
export { default } from "./Navbar";
@@ -0,0 +1 @@
export { default } from "./BaseLayout";
@@ -0,0 +1,12 @@
export interface PropsBaseLayout {
children: React.ReactNode;
title: string;
breadcrumb?: React.ReactNode;
}
export interface TContextBaseLayout {
showFull?: boolean;
setShowFull?: (show: boolean) => void;
openMenuMobile?: boolean;
setOpenMenuMobile?: (show: boolean) => void;
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { PropsChartWrapper } from "./interface";
import clsx from "clsx";
import Image from "next/image";
import icons from "@/constant/images/icons";
const ChartWrapper = ({
children,
isEmpty,
message = "Không có dữ liệu",
}: PropsChartWrapper) => {
return (
<div
className={clsx(
"w-full min-h-[500px]",
isEmpty && "flex items-center justify-center",
)}
>
{isEmpty ? (
<div className="flex flex-col items-center justify-center">
<Image
alt="Ảnh dữ liệu trống"
src={icons.emptyTable}
width={180}
height={180}
/>
{/* nếu muốn hiện message thì bật dòng dưới */}
{/* <p className="mt-3 text-sm text-gray-500">{message}</p> */}
</div>
) : (
children
)}
</div>
);
};
export default ChartWrapper;
@@ -0,0 +1,5 @@
export interface PropsChartWrapper {
children: React.ReactNode;
isEmpty?: boolean;
message?: string;
}
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { GridColumnProps } from "./interface";
import clsx from "clsx";
const GridColumn = ({
children,
col = 3,
sm,
tabletCol3,
mobile2,
scroll20,
scrollMobile85,
className,
}: GridColumnProps) => {
return (
<div
className={clsx(
"grid w-full",
/* ===== GAP ===== */
sm ? "gap-2" : "gap-4",
/* ===== DEFAULT COL ===== */
{
"grid-cols-1": col === 1,
"grid-cols-2": col === 2,
"grid-cols-3": col === 3,
"grid-cols-4": col === 4,
"grid-cols-5": col === 5,
"grid-cols-6": col === 6,
"grid-cols-8": col === 8,
"grid-cols-12": col === 12,
},
/* ===== RESPONSIVE ===== */
"xl:grid-cols-3", // giống default SCSS fallback
"lg:grid-cols-2",
"md:grid-cols-2",
"sm:grid-cols-1",
/* ===== CUSTOM RULES ===== */
tabletCol3 && "lg:grid-cols-3",
mobile2 && "sm:grid-cols-2",
/* ===== COL 4 SPECIAL ===== */
col === 4 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
/* ===== COL 5 ===== */
col === 5 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
/* ===== SCROLL ===== */
scroll20 &&
"grid-flow-col auto-cols-[20%] overflow-x-auto snap-x snap-mandatory gap-2",
scrollMobile85 &&
"md:grid-cols-none md:grid-flow-col md:auto-cols-[85%] md:overflow-x-auto md:snap-x md:snap-mandatory",
className,
)}
>
{children}
</div>
);
};
export default GridColumn;
@@ -0,0 +1 @@
export { default } from "./GridColumn";
@@ -0,0 +1,14 @@
export interface GridColumnProps {
children: React.ReactNode;
col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12;
sm?: boolean;
tabletCol3?: boolean;
mobile2?: boolean;
scroll20?: boolean;
scrollMobile85?: boolean;
className?: string;
}
@@ -0,0 +1,40 @@
"use client";
import RequiredLogout from "@/components/protected/RequiredLogout";
import React from "react";
interface PropsLayoutAuth {
children: React.ReactNode;
}
const LayoutAuth = ({ children }: PropsLayoutAuth) => {
return (
<RequiredLogout>
<div className="h-screen grid grid-cols-2 max-[1200px]:grid-cols-1">
{/* BACKGROUND */}
<div
className="
bg-no-repeat
bg-cover
bg-center
max-[1200px]:hidden bg-[url('/static/images/background_auth.jpg')]
"
/>
{/* MAIN */}
<main
className="
flex items-center bg-white
px-[200px]
max-[1600px]:px-[100px]
max-[768px]:px-[40px]
"
>
{children}
</main>
</div>
</RequiredLogout>
);
};
export default LayoutAuth;
@@ -0,0 +1 @@
export { default } from "./LayoutAuth";
@@ -0,0 +1,57 @@
"use client";
import React, { useCallback } from "react";
import { PropsLayoutPages } from "./interface";
import { usePathname } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
const LayoutPages = ({ children, listPages }: PropsLayoutPages) => {
const pathname = usePathname();
const checkActive = useCallback(
(url: string) => {
return pathname === url;
},
[pathname],
);
return (
<>
<div className="overflow-x-auto whitespace-nowrap pb-1 mb-5 scrollbar-thin">
{listPages.map((item, i) => {
const isActive = checkActive(item.url);
return (
<Link
key={i}
href={item.url}
onClick={(e) => {
if (isActive) e.preventDefault();
}}
className={clsx(
"inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer",
"text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]",
isActive
? "bg-[#3772ff] text-white border-[#3772ff]"
: "bg-white text-[#202939] border-[#eaedf2]",
i !== 0 && "ml-2",
)}
>
{" "}
<div className="flex items-center gap-2 justify-center">
<div
className="w-3 h-3 rounded-full"
style={{
background: isActive ? "#fff" : item.color,
}}
/>
<p>{item.title}</p>
</div>
</Link>
);
})}
</div>
{/* MAIN */}
<div>{children}</div>
</>
);
};
export default LayoutPages;
@@ -0,0 +1 @@
export { default } from "./LayoutPages";
@@ -0,0 +1,8 @@
export interface PropsLayoutPages {
children: React.ReactNode;
listPages: {
title: string;
url: string;
color: string;
}[];
}