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

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

View File

@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@ -13,6 +13,12 @@ const eslintConfig = defineConfig([
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
{
rules: {
"no-var": "off",
"@typescript-eslint/no-explicit-any": "off", // tắt lỗi any
},
},
]); ]);
export default eslintConfig; export default eslintConfig;

View File

@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ turbopack: {
root: process.cwd(),
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -9,15 +9,29 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-query": "^5.100.11",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"lottie-react": "^2.4.1",
"lucide-react": "^1.16.0",
"md5": "^2.3.0",
"moment": "^2.30.1",
"next": "16.2.6", "next": "16.2.6",
"nprogress": "^0.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-redux": "^9.3.0",
"react-toastify": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/md5": "^2",
"@types/node": "^20", "@types/node": "^20",
"@types/nprogress": "^0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@ -0,0 +1,620 @@
{
"v": "5.5.2",
"fr": 60,
"ip": 20,
"op": 90,
"w": 200,
"h": 200,
"nm": "Comp 1",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "Shape Layer 1",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [99.944, 100.007, 0], "ix": 2 },
"a": { "a": 0, "k": [-2.909, 2.363, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [29.135, -29.73], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 40,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 55,
"s": [0, 0]
},
{ "t": 70, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 9",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [29.135, 2.337], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 35,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 50,
"s": [0, 0]
},
{ "t": 65, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 8",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 2,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [29.135, 34.455], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 30,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 45,
"s": [0, 0]
},
{ "t": 60, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 7",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 3,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-2.858, -29.73], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 35,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 50,
"s": [0, 0]
},
{ "t": 65, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 6",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 4,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-2.858, 2.337], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 30,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 45,
"s": [0, 0]
},
{ "t": 60, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 5",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 5,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-2.858, 34.455], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 25,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 40,
"s": [0, 0]
},
{ "t": 55, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 4",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 6,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-34.953, -29.73], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 30,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 45,
"s": [0, 0]
},
{ "t": 60, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 3",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 7,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-34.953, 2.337], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 25,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 40,
"s": [0, 0]
},
{ "t": 55, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 2",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 8,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "gr",
"it": [
{
"ty": "rc",
"d": 1,
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 4 },
"nm": "Rectangle Path 1",
"mn": "ADBE Vector Shape - Rect",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-34.953, 34.455], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 20,
"s": [100, 100]
},
{
"i": { "x": [0.667, 0.667], "y": [1, 1] },
"o": { "x": [0.333, 0.333], "y": [0, 0] },
"t": 35,
"s": [0, 0]
},
{ "t": 50, "s": [100, 100] }
],
"ix": 3
},
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Rectangle 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 9,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 141,
"st": 0,
"bm": 0
}
],
"markers": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.16992 7.43994L11.9999 12.5499L20.7699 7.46991" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 21.6099V12.5399" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.6101 9.17V14.83C21.6101 14.88 21.6101 14.92 21.6001 14.97C20.9001 14.36 20.0001 14 19.0001 14C18.0601 14 17.1901 14.33 16.5001 14.88C15.5801 15.61 15.0001 16.74 15.0001 18C15.0001 18.75 15.2101 19.46 15.5801 20.06C15.6701 20.22 15.7801 20.37 15.9001 20.51L14.0701 21.52C12.9301 22.16 11.0701 22.16 9.93011 21.52L4.59012 18.56C3.38012 17.89 2.39014 16.21 2.39014 14.83V9.17C2.39014 7.79 3.38012 6.11002 4.59012 5.44002L9.93011 2.48C11.0701 1.84 12.9301 1.84 14.0701 2.48L19.4101 5.44002C20.6201 6.11002 21.6101 7.79 21.6101 9.17Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 18C23 18.75 22.79 19.46 22.42 20.06C22.21 20.42 21.94 20.74 21.63 21C20.93 21.63 20.01 22 19 22C17.54 22 16.27 21.22 15.58 20.06C15.21 19.46 15 18.75 15 18C15 16.74 15.58 15.61 16.5 14.88C17.19 14.33 18.06 14 19 14C21.21 14 23 15.79 23 18Z" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0702 19.0399L17.9502 16.9299" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0502 16.96L17.9302 19.0699" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,39 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M97.125 96.1667C96.7416 96.7052 96.2356 97.1447 95.6487 97.449C95.0619 97.7533 94.411 97.9136 93.75 97.9167H6.25C5.58896 97.9136 4.93813 97.7533 4.35128 97.449C3.76443 97.1447 3.2584 96.7052 2.875 96.1667C19.3333 59.5625 15.9167 67.1667 20.8333 56.25H79.1667C84.4583 68.0208 80.5417 59.3333 97.125 96.1667Z" fill="#4795EA"/>
<path d="M20.8333 29.167V56.2503C15.5417 68.0212 19.4583 59.3337 2.87501 96.167C2.3525 95.4704 2.07424 94.621 2.08335 93.7503V64.5837L20.8333 29.167Z" fill="url(#paint0_linear_6600_59437)"/>
<path d="M97.9167 64.5837V93.7503C97.9258 94.621 97.6475 95.4704 97.125 96.167C80.6667 59.5628 84.0834 67.167 79.1667 56.2503V29.167L97.9167 64.5837Z" fill="url(#paint1_linear_6600_59437)"/>
<path d="M97.9167 64.5831V93.7498C97.9167 94.8549 97.4777 95.9147 96.6963 96.6961C95.9149 97.4775 94.8551 97.9165 93.75 97.9165H6.25004C5.14497 97.9165 4.08516 97.4775 3.30376 96.6961C2.52236 95.9147 2.08337 94.8549 2.08337 93.7498V64.5831H37.6875C38.19 64.5775 38.6775 64.7536 39.0604 65.079C39.4432 65.4044 39.6955 65.8572 39.7709 66.354C40.0921 68.2389 40.9259 69.999 42.181 71.4415C43.4361 72.884 45.0641 73.9532 46.8864 74.532C48.7088 75.1108 50.6554 75.1768 52.5128 74.7228C54.3702 74.2688 56.0668 73.3123 57.4167 71.9581C58.9314 70.4295 59.9419 68.4738 60.3125 66.354C60.3879 65.8572 60.6402 65.4044 61.0231 65.079C61.4059 64.7536 61.8935 64.5775 62.3959 64.5831H97.9167Z" fill="url(#paint2_linear_6600_59437)"/>
<path d="M20.8334 29.167H79.1667V56.2503H20.8334V29.167Z" fill="url(#paint3_linear_6600_59437)"/>
<path d="M68.75 20.8336C68.7547 17.264 67.7405 13.7673 65.8264 10.7543C63.9122 7.74132 61.1778 5.33733 57.9445 3.82488C54.7112 2.31242 51.1133 1.75434 47.5738 2.21624C44.0342 2.67814 40.7 4.14082 37.9631 6.43237C35.2262 8.72391 33.2003 11.7491 32.1235 15.1524C31.0467 18.5557 30.9637 22.1956 31.8843 25.6444C32.805 29.0932 34.6909 32.2076 37.3206 34.6215C39.9502 37.0353 43.2143 38.6484 46.7292 39.2711L49.0625 43.9794C49.1531 44.1494 49.2882 44.2915 49.4533 44.3905C49.6185 44.4896 49.8074 44.5419 50 44.5419C50.1926 44.5419 50.3815 44.4896 50.5466 44.3905C50.7118 44.2915 50.8469 44.1494 50.9375 43.9794L53.2708 39.2711C57.6046 38.5033 61.5302 36.2355 64.3602 32.8646C67.1901 29.4938 68.7441 25.2349 68.75 20.8336Z" fill="url(#paint4_linear_6600_59437)"/>
<path d="M52.9375 20.834L57.7292 16.0632C58.1215 15.6709 58.3419 15.1388 58.3419 14.584C58.3419 14.0293 58.1215 13.4972 57.7292 13.1049C57.3369 12.7126 56.8048 12.4922 56.25 12.4922C55.6952 12.4922 55.1631 12.7126 54.7708 13.1049L50 17.8965L45.2292 13.1049C45.0349 12.9106 44.8043 12.7565 44.5505 12.6514C44.2967 12.5463 44.0247 12.4922 43.75 12.4922C43.1952 12.4922 42.6631 12.7126 42.2708 13.1049C41.8785 13.4972 41.6581 14.0293 41.6581 14.584C41.6581 15.1388 41.8785 15.6709 42.2708 16.0632L47.0625 20.834L42.2708 25.6049C42.0766 25.7991 41.9225 26.0297 41.8174 26.2835C41.7122 26.5373 41.6581 26.8093 41.6581 27.084C41.6581 27.6388 41.8785 28.1709 42.2708 28.5632C42.6631 28.9555 43.1952 29.1759 43.75 29.1759C44.0247 29.1759 44.2967 29.1218 44.5505 29.0167C44.8043 28.9115 45.0349 28.7575 45.2292 28.5632L50 23.7715L54.7708 28.5632C55.1631 28.9555 55.6952 29.1759 56.25 29.1759C56.8048 29.1759 57.3369 28.9555 57.7292 28.5632C58.1215 28.1709 58.3419 27.6388 58.3419 27.084C58.3419 26.5292 58.1215 25.9972 57.7292 25.6049L52.9375 20.834Z" fill="#FFB369"/>
<path d="M22.9167 79.167H10.4167C9.26611 79.167 8.33337 80.0997 8.33337 81.2503V89.5837C8.33337 90.7343 9.26611 91.667 10.4167 91.667H22.9167C24.0673 91.667 25 90.7343 25 89.5837V81.2503C25 80.0997 24.0673 79.167 22.9167 79.167Z" fill="url(#paint5_linear_6600_59437)"/>
<path d="M18.75 87.5007H14.5833C14.0308 87.5007 13.5009 87.2812 13.1102 86.8905C12.7195 86.4998 12.5 85.9699 12.5 85.4173C12.5 84.8648 12.7195 84.3349 13.1102 83.9442C13.5009 83.5535 14.0308 83.334 14.5833 83.334H18.75C19.3025 83.334 19.8324 83.5535 20.2231 83.9442C20.6138 84.3349 20.8333 84.8648 20.8333 85.4173C20.8333 85.9699 20.6138 86.4998 20.2231 86.8905C19.8324 87.2812 19.3025 87.5007 18.75 87.5007Z" fill="#FFB369"/>
<path d="M35.4167 91.6667H31.25C30.6975 91.6667 30.1676 91.4472 29.7769 91.0565C29.3862 90.6658 29.1667 90.1359 29.1667 89.5833C29.1667 89.0308 29.3862 88.5009 29.7769 88.1102C30.1676 87.7195 30.6975 87.5 31.25 87.5H35.4167C35.9692 87.5 36.4991 87.7195 36.8898 88.1102C37.2805 88.5009 37.5 89.0308 37.5 89.5833C37.5 90.1359 37.2805 90.6658 36.8898 91.0565C36.4991 91.4472 35.9692 91.6667 35.4167 91.6667Z" fill="#4795EA"/>
<path d="M39.5834 83.3337H31.25C30.6975 83.3337 30.1676 83.1142 29.7769 82.7235C29.3862 82.3328 29.1667 81.8029 29.1667 81.2503C29.1667 80.6978 29.3862 80.1679 29.7769 79.7772C30.1676 79.3865 30.6975 79.167 31.25 79.167H39.5834C40.1359 79.167 40.6658 79.3865 41.0565 79.7772C41.4472 80.1679 41.6667 80.6978 41.6667 81.2503C41.6667 81.8029 41.4472 82.3328 41.0565 82.7235C40.6658 83.1142 40.1359 83.3337 39.5834 83.3337Z" fill="#4795EA"/>
<defs>
<linearGradient id="paint0_linear_6600_59437" x1="11.4583" y1="96.167" x2="11.4583" y2="29.167" gradientUnits="userSpaceOnUse">
<stop stop-color="#54A5FF"/>
<stop offset="1" stop-color="#8AD3FE"/>
</linearGradient>
<linearGradient id="paint1_linear_6600_59437" x1="88.5417" y1="96.167" x2="88.5417" y2="0.0003288" gradientUnits="userSpaceOnUse">
<stop stop-color="#54A5FF"/>
<stop offset="1" stop-color="#8AD3FE"/>
</linearGradient>
<linearGradient id="paint2_linear_6600_59437" x1="50" y1="97.9165" x2="50" y2="64.5831" gradientUnits="userSpaceOnUse">
<stop stop-color="#54A5FF"/>
<stop offset="1" stop-color="#8AD3FE"/>
</linearGradient>
<linearGradient id="paint3_linear_6600_59437" x1="50" y1="56.2503" x2="50" y2="0.000326126" gradientUnits="userSpaceOnUse">
<stop stop-color="#54A5FF"/>
<stop offset="1" stop-color="#8AD3FE"/>
</linearGradient>
<linearGradient id="paint4_linear_6600_59437" x1="50" y1="44.5419" x2="50" y2="2.08361" gradientUnits="userSpaceOnUse">
<stop stop-color="#D3E6F5"/>
<stop offset="1" stop-color="#F0F7FC"/>
</linearGradient>
<linearGradient id="paint5_linear_6600_59437" x1="8.33337" y1="85.417" x2="25" y2="85.417" gradientUnits="userSpaceOnUse">
<stop stop-color="#D3E6F5"/>
<stop offset="1" stop-color="#F0F7FC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 3H22V9H20V5H16V3H20ZM4 3H8V5H4V9H2V3H4ZM20 19V15H22V21H16V19H20ZM4 19H8V21H2V15H4V19Z" fill="#AFBED3"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1,5 @@
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.0293 1.88257L7.0293 1.88257" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.0293 8.94141L1.0293 8.94141" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.0293 16.0002L11.0293 16.0002" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@ -0,0 +1,5 @@
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.0293 1.88257L7.0293 1.88257" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.0293 8.94141L1.0293 8.94141" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.0293 16.0002L11.0293 16.0002" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.65 19.11V4.89C10.65 3.54 10.08 3 8.64 3H5.01C3.57 3 3 3.54 3 4.89V19.11C3 20.46 3.57 21 5.01 21H8.64C10.08 21 10.65 20.46 10.65 19.11Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.0001 19.11V4.89C21.0001 3.54 20.4301 3 18.9901 3H15.3601C13.9301 3 13.3501 3.54 13.3501 4.89V19.11C13.3501 20.46 13.9201 21 15.3601 21H18.9901C20.4301 21 21.0001 20.46 21.0001 19.11Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 11.9999V8.43989C4 4.01989 7.13 2.2099 10.96 4.4199L14.05 6.1999L17.14 7.9799C20.97 10.1899 20.97 13.8099 17.14 16.0199L14.05 17.7999L10.96 19.5799C7.13 21.7899 4 19.9799 4 15.5599V11.9999Z" stroke="#008545" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6C2.75 7.67 2 9.75 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2C10.57 2 9.2 2.3 7.97 2.85" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 10.38L16.12 9.25" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.87988 12L10.6199 14.75L13.1699 12.21" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 7.75V13" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.91992 8.58003C2.91992 7.46003 3.51993 6.41999 4.48993 5.84999L10.4299 2.42C11.3999 1.86 12.6 1.86 13.58 2.42L19.52 5.84999C20.49 6.40999 21.09 7.45003 21.09 8.58003V15.42C21.09 16.54 20.49 17.58 19.52 18.15L13.58 21.58C12.61 22.14 11.4099 22.14 10.4299 21.58L4.48993 18.15C3.51993 17.59 2.91992 16.55 2.91992 15.42V12.66" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16.2V16.3" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 732 B

View File

@ -0,0 +1,15 @@
"use client";
import LayoutAuth from "@/components/layouts/LayoutAuth";
import MainForgotPassword from "@/components/page/auth/MainForgotPassword/MainForgotPassword";
import React from "react";
const page = () => {
return (
<LayoutAuth>
<MainForgotPassword />
</LayoutAuth>
);
};
export default page;

View File

@ -0,0 +1,15 @@
"use client";
import LayoutAuth from "@/components/layouts/LayoutAuth";
import MainLogin from "@/components/page/auth/MainLogin";
import React from "react";
const page = () => {
return (
<LayoutAuth>
<MainLogin />
</LayoutAuth>
);
};
export default page;

View File

@ -0,0 +1,9 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import React from "react";
const page = () => {
return <BaseLayout title="Phiên khám">Phiên khám</BaseLayout>;
};
export default page;

View File

@ -0,0 +1,15 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
import MainPageHome from "@/components/page/Home/MainPageHome";
import React from "react";
const page = () => {
return (
<BaseLayout title="Dashboard">
<MainPageHome />
</BaseLayout>
);
};
export default page;

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

198
src/app/globals.css Normal file
View File

@ -0,0 +1,198 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* ===== JODIT CUSTOM UI ===== */
.editor-wrapper .jodit-container {
border: none !important;
background: transparent !important;
font-family: inherit !important;
}
.editor-wrapper .jodit-workplace {
background: transparent !important;
min-height: 120px !important;
}
.editor-wrapper .jodit-wysiwyg {
padding: 18px 20px !important;
color: #111827 !important;
font-size: 15px !important;
line-height: 1.7 !important;
}
.editor-wrapper .jodit-toolbar__box {
border: none !important;
background: transparent !important;
padding: 14px 14px 10px 14px !important;
}
.editor-wrapper .jodit-toolbar-button {
width: 34px !important;
height: 34px !important;
border-radius: 10px !important;
transition: all 0.2s ease !important;
}
.editor-wrapper .jodit-toolbar-button:hover {
background: #e5e7eb !important;
}
.editor-wrapper .jodit-toolbar-button__button {
min-width: 34px !important;
min-height: 34px !important;
}
.editor-wrapper .jodit-status-bar {
display: none !important;
}
.editor-wrapper .jodit-add-new-line {
display: none !important;
}
.editor-wrapper .jodit-ui-group {
gap: 6px !important;
}
.editor-wrapper .jodit-icon {
width: 18px !important;
height: 18px !important;
}
.editor-wrapper .jodit-toolbar-button_active {
background: #dbeafe !important;
}
.editor-wrapper .jodit-toolbar-button_active svg {
fill: #2563eb !important;
}
.editor-wrapper .jodit-wysiwyg:focus {
outline: none !important;
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import "react-toastify/dist/ReactToastify.css";
import Providers from "@/contexts/AppProvider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -23,11 +25,16 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html // <html
lang="en" // lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} // className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> // >
<body className="min-h-full flex flex-col">{children}</body> // <body className="min-h-full flex flex-col">{children}</body>
// </html>
<html lang="vi" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }

11
src/app/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
import MainPageHome from "@/components/page/Home/MainPageHome/MainPageHome";
import Image from "next/image";
export default function Home() {
return (
<BaseLayout title="Dashboard">
<MainPageHome />
</BaseLayout>
);
}

14
src/app/patient/page.tsx Normal file
View File

@ -0,0 +1,14 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import React from "react";
const page = () => {
return (
<div>
<BaseLayout title="Bệnh nhân">Bệnh nhân</BaseLayout>
</div>
);
};
export default page;

View File

@ -0,0 +1,9 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import React from "react";
const page = () => {
return <BaseLayout title="Đơn thuốc">Đơn thuốc</BaseLayout>;
};
export default page;

View File

@ -0,0 +1,14 @@
// Hàm chuyển đổi số coin thành chuỗi định dạng tiền tệ
export const convertCoin = (coin: number | null) => {
return coin
? coin.toLocaleString("de-DE", { maximumFractionDigits: 2 })
: "0";
};
// Hàm chuyển đổi chuỗi hoặc số thành giá trị số
export const price = (value: string | number): number => {
const numericValue = Number(
`${value}`.replace(/[^0-9,]/g, "").replace(",", "."),
);
return isNaN(numericValue) ? 0 : numericValue;
};

View File

@ -0,0 +1,3 @@
export async function delay(duration: number) {
return await new Promise((resolve) => setTimeout(resolve, duration));
}

View File

@ -0,0 +1,18 @@
export function getItemStorage(key: string) {
const value = localStorage.getItem(key)!;
if (value !== "undefined") {
return JSON.parse(value);
} else {
return null;
}
}
export function setItemStorage(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
}
export function deleteItemStorage(...keys: Array<any>) {
keys.forEach((key) => {
localStorage.removeItem(key);
});
}

View File

@ -0,0 +1,168 @@
import md5 from "md5";
import moment from "moment";
export function removeVietnameseTones(str: string): string {
if (!str || typeof str !== "string") {
return "";
}
return str
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D");
}
export function convertWeight(weight: number | null) {
if (!weight) {
return 0;
}
return (weight / 1000).toLocaleString("vi-VN", {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
}
export function obfuscateEmail(email: string) {
// Tách phần trước @ và phần tên miền
const [username, domain] = email.split("@");
// Giữ lại ký tự đầu tiên và cuối cùng của tên người dùng
const firstChar = username[0];
const lastChar = username[username.length - 1];
// Tạo phần che giấu giữa
const middleHidden = "...";
// Tạo tên người dùng mới với phần che giấu
const newUsername = firstChar + middleHidden + lastChar;
// Kết hợp với tên miền để tạo email đã che giấu
const obfuscatedEmail = newUsername + "@" + domain;
return obfuscatedEmail;
}
export default function fancyTimeFormat(duration: number) {
// Hours, minutes and seconds
var hrs = ~~(duration / 3600);
var mins = ~~((duration % 3600) / 60);
var secs = ~~duration % 60;
// Output like "1:01" or "4:03:59" or "123:03:59"
var ret = "";
if (hrs > 0) {
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
}
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
ret += "" + secs;
return ret;
}
export function getKeyCert(): {
time: string;
keyCert: string;
} {
const key: string = process.env.NEXT_PUBLIC_KEY_CERT!;
const time = moment(new Date()).format("MM/DD/YYYY HH:mm:ss");
return {
time: time,
keyCert: md5(`${key}${time}`),
};
}
export function getTextAddress(detailAddress: any, address?: string): string {
if (
!detailAddress?.province &&
!detailAddress?.district &&
!detailAddress?.town &&
!address
) {
return "---";
}
const parts = [
address,
detailAddress?.town?.name,
detailAddress?.district?.name,
detailAddress?.province?.name,
].filter(Boolean);
return parts.length ? parts.join(", ") : "---";
}
export function numberToWords(number: number) {
if (typeof number !== "number" || isNaN(number)) {
return "Không hợp lệ";
}
if (number === 0) {
return "Không đồng";
}
const units = [
"",
"một",
"hai",
"ba",
"bốn",
"năm",
"sáu",
"bảy",
"tám",
"chín",
];
const levels = ["", "nghìn", "triệu", "tỷ", "nghìn tỷ", "triệu tỷ", "tỷ tỷ"];
function readThreeDigits(num: number) {
let str = "";
const hundred = Math.floor(num / 100);
const ten = Math.floor((num % 100) / 10);
const unit = num % 10;
if (hundred > 0) {
str += units[hundred] + " trăm";
if (ten === 0 && unit > 0) str += " linh";
}
if (ten > 1) {
str += " " + units[ten] + " mươi";
if (unit === 1) str += " mốt";
else if (unit === 5) str += " lăm";
else if (unit > 0) str += " " + units[unit];
} else if (ten === 1) {
str += " mười";
if (unit > 0) str += " " + units[unit];
} else if (unit > 0) {
str += " " + units[unit];
}
return str.trim();
}
function splitIntoChunks(num: number) {
const chunks = [];
while (num > 0) {
chunks.push(num % 1000);
num = Math.floor(num / 1000);
}
return chunks;
}
const chunks = splitIntoChunks(number);
let result = "";
for (let i = chunks.length - 1; i >= 0; i--) {
if (chunks[i] > 0) {
result += readThreeDigits(chunks[i]) + " " + levels[i] + " ";
}
}
result = result.trim().replace(/\s+/g, " ");
result = result.charAt(0).toUpperCase() + result.slice(1) + " đồng";
return result;
}

View File

@ -0,0 +1,99 @@
import { TYPE_DATE } from "@/constant/config/enum";
export function getDateRange(range: number): {
from: Date | null;
to: Date | null;
} {
const today = new Date();
today.setHours(0, 0, 0, 0);
switch (range) {
case TYPE_DATE.ALL:
return { from: null, to: null };
// Tuần này
case TYPE_DATE.TODAY:
return { from: new Date(), to: new Date() };
// Hôm qua
case TYPE_DATE.YESTERDAY:
const firstYesterday = new Date();
firstYesterday.setDate(today.getDate() - 1);
const lastYesterday = new Date();
lastYesterday.setDate(today.getDate() - 1);
return {
from: firstYesterday,
to: lastYesterday,
};
// Tuần này
case TYPE_DATE.THIS_WEEK:
const firstDayOfWeek = new Date(today);
firstDayOfWeek.setDate(today.getDate() - today.getDay());
const lastDayOfWeek = new Date(today);
lastDayOfWeek.setDate(today.getDate() - today.getDay() + 6);
return { from: firstDayOfWeek, to: lastDayOfWeek };
// Tuần trước
case TYPE_DATE.LAST_WEEK:
const firstDayLastWeek = new Date(today);
firstDayLastWeek.setDate(today.getDate() - today.getDay() - 7);
const lastDayLastWeek = new Date(today);
lastDayLastWeek.setDate(today.getDate() - today.getDay() - 1);
return { from: firstDayLastWeek, to: lastDayLastWeek };
// Tháng này
case TYPE_DATE.THIS_MONTH:
const firstDayOfMonth = new Date(
today.getFullYear(),
today.getMonth(),
1,
);
const lastDayOfMonth = new Date(
today.getFullYear(),
today.getMonth() + 1,
0,
);
return { from: firstDayOfMonth, to: lastDayOfMonth };
// Tháng trước
case TYPE_DATE.LAST_MONTH:
const firstDayLastMonth = new Date(
today.getFullYear(),
today.getMonth() - 1,
1,
);
const lastDayLastMonth = new Date(
today.getFullYear(),
today.getMonth(),
0,
);
return { from: firstDayLastMonth, to: lastDayLastMonth };
// Năm nay
case TYPE_DATE.THIS_YEAR:
const firstDayOfYear = new Date(today.getFullYear(), 0, 1);
const lastDayOfYear = new Date(today.getFullYear(), 11, 31);
return { from: firstDayOfYear, to: lastDayOfYear };
// 7 ngày trước
case TYPE_DATE.LAST_7_DAYS:
const firstDayLast7Days = new Date(today);
firstDayLast7Days.setDate(today.getDate() - 7);
return { from: firstDayLast7Days, to: today };
default:
return { from: null, to: null };
}
}

52
src/common/funcs/toast.ts Normal file
View File

@ -0,0 +1,52 @@
import { toast } from "react-toastify";
import IconToastifyCustom from "@/components/customs/custom-toast";
export const toastText = ({ msg }: { msg: string }) =>
toast.info(msg, {
position: "top-center",
autoClose: 2000,
hideProgressBar: true,
closeButton: false,
className: "toastify-custom",
icon: IconToastifyCustom({ type: "info" }),
});
export const toastSuccess = ({ msg }: { msg: string }) =>
toast.success(msg, {
autoClose: 2000,
hideProgressBar: true,
position: "top-center",
closeButton: true,
className: "toastify-custom-success",
icon: IconToastifyCustom({ type: "success" }),
});
export const toastInfo = ({ msg }: { msg: string }) =>
toast.info(msg, {
autoClose: 2000,
hideProgressBar: true,
position: "top-center",
closeButton: true,
className: "toastify-custom-info",
icon: IconToastifyCustom({ type: "info" }),
});
export const toastWarn = ({ msg }: { msg: string }) =>
toast.warning(msg, {
autoClose: 2000,
hideProgressBar: true,
position: "top-center",
closeButton: true,
className: "toastify-custom-warn",
icon: IconToastifyCustom({ type: "warn" }),
});
export const toastError = ({ msg }: { msg: string }) =>
toast.error(msg, {
autoClose: 2000,
hideProgressBar: true,
position: "top-center",
closeButton: true,
className: "toastify-custom-error",
icon: IconToastifyCustom({ type: "error" }),
});

View File

@ -0,0 +1,14 @@
export function validateEmail(value: string) {
return !!value
?.trim()
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
);
}
export function validatePhoneNumber(phoneNumber: string) {
const vietnamPhoneRegex = /^(03|05|07|08|09)[0-9]{8}$/;
return vietnamPhoneRegex.test(phoneNumber);
}

View File

@ -0,0 +1,14 @@
export const useStyleClass = (props: any, style: any) => {
let styleProps: Array<any> = [];
for (let i in props) {
styleProps.push(i);
}
const convert: any = styleProps.map((item) => {
if (style[item] !== undefined) {
return { [style[item]]: !!props[item] };
}
});
return convert;
};

View File

@ -0,0 +1,64 @@
"use client";
import React, { ReactNode } from "react";
import Image from "next/image";
import clsx from "clsx";
import icons from "@/constant/images/icons";
interface PropsDataWrapper {
title?: string;
note?: string;
loading: boolean;
data: unknown[];
children: ReactNode;
button?: ReactNode;
}
function DataWrapper({
title = "Dữ liệu trống",
note = "Hiện tại dữ liệu đang trống!",
data,
loading,
button,
children,
}: PropsDataWrapper) {
// ================= LOADING =================
if (loading) {
return (
<div className="min-h-[55vh] flex flex-col items-center justify-center">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<h3 className="text-2xl text-[#303b48] mt-6">Đang tải...</h3>
<p className="text-sm text-[#515c69] mt-1">
Vui lòng chờ, đang tải dữ liệu!
</p>
</div>
);
}
// ================= EMPTY =================
if (!loading && (!data || data.length === 0)) {
return (
<div className="min-h-[55vh] flex flex-col items-center justify-center text-center">
<Image
src={icons.emptyTable}
alt="empty-data"
width={180}
height={180}
priority
/>
<h3 className="text-2xl text-[#303b48] mt-6">{title}</h3>
<p className="text-sm text-[#515c69] mt-1">{note}</p>
{button && <div className="mt-5">{button}</div>}
</div>
);
}
// ================= HAS DATA =================
return <>{children}</>;
}
export default DataWrapper;

View File

@ -0,0 +1,64 @@
"use client";
import React, { useMemo } from "react";
interface StateItem {
state: number | string;
text: string;
backgroundColor?: string;
textColor?: string;
}
interface PropsStateActive {
isBox?: boolean;
stateActive: number | string;
listState: StateItem[];
}
function StateActive({
isBox = true,
stateActive,
listState,
}: PropsStateActive) {
// memo trạng thái hiện tại
const current = useMemo(() => {
return listState.find((item) => item.state === stateActive);
}, [stateActive, listState]);
if (isBox) {
return (
<div
className="
w-fit rounded-full text-sm font-normal
"
style={{
color: current?.textColor ?? "#202939",
backgroundColor: current?.backgroundColor,
padding: current?.backgroundColor ? "6px 12px" : undefined,
}}
>
{current?.text ?? "---"}
</div>
);
}
return (
<div className="flex items-center gap-1.5 w-fit">
{/* DOT */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: current?.backgroundColor }}
/>
{/* TEXT */}
<p
className="text-sm font-medium"
style={{ color: current?.textColor ?? "#23262f" }}
>
{current?.text ?? "---"}
</p>
</div>
);
}
export default StateActive;

View File

@ -0,0 +1,123 @@
"use client";
import React from "react";
import Link from "next/link";
import clsx from "clsx";
export interface PropsButton {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children?: React.ReactNode;
href?: string;
icon?: React.ReactNode;
className?: string;
target?: string;
disabled?: boolean;
variant?:
| "default"
| "midnightBlue"
| "green"
| "red"
| "grey"
| "white"
| "black"
| "warning";
size?: "sm" | "md" | "lg";
rounded?: "sm" | "md" | "lg" | "full";
fullWidth?: boolean;
}
export default function CustomButton({
children,
onClick,
href,
icon,
className,
target,
disabled = false,
variant = "default",
size = "md",
rounded = "md",
fullWidth = true,
}: PropsButton) {
// 🎨 Variant
const variantClasses: Record<string, string> = {
default: "bg-gray-100 text-gray-700 border border-gray-200 cursor-pointer",
midnightBlue:
"bg-blue-900 text-white border border-blue-900 cursor-pointer",
green: "bg-green-500 text-white border border-green-500 cursor-pointer",
red: "bg-red-500 text-white border border-red-500 cursor-pointer",
grey: "bg-white text-gray-700 border border-gray-200 cursor-pointer",
white:
"bg-white text-gray-700 border border-gray-200 shadow-sm cursor-pointer",
black: "bg-gray-800 text-white border border-gray-800 cursor-pointer",
warning: "bg-orange-400 text-white border border-orange-400 cursor-pointer",
};
// 📏 Size
const sizeClasses = {
sm: "px-3 py-1 text-sm",
md: "px-5 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
// 🔵 Rounded
const roundedClasses = {
sm: "rounded-md",
md: "rounded-xl",
lg: "rounded-2xl",
full: "rounded-full",
};
const baseClass = clsx(
"inline-flex items-center justify-center gap-2",
"transition-all duration-200 select-none",
"active:scale-[0.98] hover:opacity-90",
variantClasses[variant],
sizeClasses[size],
roundedClasses[rounded],
fullWidth ? "w-full" : "w-fit",
disabled && "opacity-40 pointer-events-none cursor-not-allowed",
className,
);
const content = (
<>
{icon && <span className="flex items-center">{icon}</span>}
<span>{children}</span>
</>
);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
onClick?.(e);
};
// 🔗 Link mode
if (href) {
return (
<Link
href={href}
target={target}
className={baseClass}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
>
{content}
</Link>
);
}
// 🔘 Button mode
return (
<button
type="button"
onClick={handleClick}
className={baseClass}
disabled={disabled}
>
{content}
</button>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import React from "react";
import IconRound, { IconRoundType } from "../utils/IconRound";
import CustomPopup from "./custom-popup";
import CustomButton from "./custom-button";
/* ================== TYPES ================== */
export interface PropsDialog {
open: boolean;
title: React.ReactNode;
note?: React.ReactNode;
onClose: () => void;
onSubmit: () => void;
titleCancel?: string;
titleSubmit?: string;
type?: IconRoundType;
}
/* ================== COMPONENT ================== */
export default function CustomDialog({
open,
title,
note,
titleCancel = "Hủy bỏ",
titleSubmit = "Xác nhận",
onClose,
onSubmit,
type = "success",
}: PropsDialog) {
return (
<CustomPopup open={open} onClose={onClose}>
<div
role="dialog"
aria-modal="true"
className="
relative w-[440px] max-w-[90vw]
rounded-2xl
bg-white shadow-lg
px-6 py-7
flex flex-col items-center text-center
"
>
{/* ICON */}
<IconRound type={type} />
{/* TITLE */}
<h4 className="mt-4 text-xl font-semibold text-gray-800">{title}</h4>
{/* NOTE */}
{note && <p className="mt-2 text-sm text-gray-500">{note}</p>}
{/* ACTIONS */}
<div className="w-full flex items-center gap-3 mt-6">
<CustomButton onClick={onClose} variant="grey" rounded="full">
{titleCancel}
</CustomButton>
<CustomButton
onClick={onSubmit}
variant="midnightBlue"
rounded="full"
>
{titleSubmit}
</CustomButton>
</div>
</div>
</CustomPopup>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { ReactNode, useMemo, useRef } from "react";
import clsx from "clsx";
interface Props {
lenght?: number;
onSetValue?: (value: any) => void;
name?: string;
className?: string;
}
export default function InputSingle({
className,
lenght = 6,
onSetValue,
name = "otp_code",
}: Props) {
const inputsRef = useRef<HTMLInputElement[]>([]);
// update value
const updateOTPValue = () => {
const code = inputsRef.current.map((input) => input?.value || "");
onSetValue?.((prev: any) => ({
...prev,
[name]: code.join(""),
}));
};
// input change
const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
const target = e.currentTarget;
const index = Number(target.dataset.index);
let value = target.value;
if (value.length > 1) {
value = value.slice(0, 1);
target.value = value;
}
updateOTPValue();
if (value) {
inputsRef.current[index + 1]?.focus();
}
};
// backspace
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const index = Number(e.currentTarget.dataset.index);
if (e.key === "Backspace") {
if (!e.currentTarget.value) {
inputsRef.current[index - 1]?.focus();
}
requestAnimationFrame(() => {
updateOTPValue();
});
}
};
// set refs
const setRef = (el: HTMLInputElement | null, index: number) => {
if (el) {
inputsRef.current[index] = el;
}
};
const renderInputs = useMemo(() => {
const list: ReactNode[] = [];
for (let i = 0; i < lenght; i++) {
list.push(
<input
key={i}
ref={(el) => setRef(el, i)}
data-index={i}
inputMode="numeric"
maxLength={1}
onInput={handleChange}
onKeyDown={handleKeyDown}
className={clsx(
"h-[72px] w-[56px]",
"rounded-xl border-2 border-[#dae1ea]",
"text-center text-[42px] font-medium",
"outline-none transition",
"focus:border-[#066bb1]",
)}
/>,
);
}
return list;
}, [lenght]);
return (
<div className={clsx("flex items-center justify-center gap-3", className)}>
{renderInputs}
</div>
);
}

View File

@ -0,0 +1,54 @@
"use client";
import CustomPortal from "./custom-portal";
interface PropsLoading {
loading?: boolean;
}
const SCALE = 2.5;
export default function CustomLoading({ loading }: PropsLoading) {
if (!loading) return null;
return (
<CustomPortal>
<div className="fixed inset-0 flex items-center justify-center bg-black/60 z-[10002]">
<div
className="relative"
style={{
width: 40 * SCALE,
height: 40 * SCALE,
}}
>
{Array.from({ length: 12 }).map((_, i) => {
const rotate = i * 30;
return (
<div
key={i}
style={{
transformOrigin: `${20 * SCALE}px ${20 * SCALE}px`,
transform: `rotate(${rotate}deg)`,
animation: "spinnerFade 1.2s linear infinite",
animationDelay: `${-(1.1 - i * 0.1)}s`,
}}
className="absolute top-0 left-0 w-full h-full"
>
<div
style={{
top: 1.5 * SCALE,
left: 18.5 * SCALE,
width: 3 * SCALE,
height: 9 * SCALE,
}}
className="absolute bg-white rounded-sm"
/>
</div>
);
})}
</div>
</div>
</CustomPortal>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import React, {
useEffect,
useMemo,
useRef,
useState,
forwardRef,
} from "react";
import clsx from "clsx";
import { ChevronDown, ArrowLeft, ArrowRight } from "lucide-react";
export interface PropsPagination {
total: number;
page: number;
onSetPage: (page: number) => void;
pageSize: number;
onSetPageSize: (pageSize: number) => void;
dependencies?: any[];
}
// Wrapper component to forward ref for the dropdown trigger
const TriggerButton = forwardRef<
HTMLDivElement,
{
onClick: () => void;
className: string;
pageSize: number;
openLimit: boolean;
}
>(({ onClick, className, pageSize, openLimit }, ref) => (
<div ref={ref} onClick={onClick} className={className}>
<span className="text-sm font-medium">{pageSize}</span>
<ChevronDown
className={clsx(
"text-gray-500 transition-transform",
openLimit && "rotate-180 text-[#0011ab]",
)}
size={20}
/>
</div>
));
TriggerButton.displayName = "TriggerButton";
export default function Pagination({
total,
page,
pageSize,
onSetPage,
onSetPageSize,
dependencies = [],
}: PropsPagination) {
const pageSizes = [50, 100, 200];
const [openLimit, setOpenLimit] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const maxPage = Math.ceil(total / Number(pageSize));
const items = useMemo(() => {
const nodes: React.ReactNode[] = [];
for (let i = 1; i <= maxPage; i++) {
const isCurrent = page === i;
const isNear =
i === page ||
i === page - 1 ||
i === page + 1 ||
i === 1 ||
i === maxPage;
if (isNear) {
nodes.push(
<li
key={i}
onClick={() => onSetPage(i)}
className={clsx(
"h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold border rounded cursor-pointer transition",
isCurrent
? "bg-[#0011ab] text-white border-[#0011ab] pointer-events-none"
: "bg-white text-black border-[#efefef] hover:opacity-70",
)}
>
{i}
</li>,
);
}
if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) {
nodes.push(
<li
key={`dot-${i}`}
className="h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold"
>
...
</li>,
);
}
}
return nodes;
}, [maxPage, page, onSetPage]);
const handlePrev = () => {
if (page > 1) onSetPage(page - 1);
};
const handleNext = () => {
if (page < maxPage) onSetPage(page + 1);
};
useEffect(() => {
if (dependencies.length > 0) {
onSetPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
openLimit &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpenLimit(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openLimit]);
if (total <= 0) return null;
return (
<div className="mt-2 flex flex-wrap-reverse items-center justify-between gap-2">
{/* LEFT */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<p>Hiển thị</p>
<div ref={dropdownRef} className="relative">
<TriggerButton
onClick={() => setOpenLimit(!openLimit)}
className={clsx(
"w-[80px] flex items-center justify-between px-3 py-1 border rounded-full cursor-pointer bg-white",
"border-gray-300",
)}
pageSize={pageSize}
openLimit={openLimit}
/>
{openLimit && (
<div className="absolute right-0 z-10 w-[80px] border border-gray-300 bg-white rounded p-1 mt-1 shadow-lg">
{pageSizes.map((v) => (
<div
key={v}
onClick={() => {
onSetPageSize(v);
setOpenLimit(false);
}}
className={clsx(
"px-2 py-1 text-[13px] cursor-pointer rounded",
"hover:bg-[#0011ab] hover:text-white",
pageSize === v && "bg-[#0011ab] text-white",
)}
>
{v}
</div>
))}
</div>
)}
</div>
<p className="text-sm text-gray-600">
trong tổng <span className="font-semibold">{total}</span> kết quả
</p>
</div>
{/* PAGINATION */}
<div className="flex items-center justify-center">
{page > 1 && (
<button
onClick={handlePrev}
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition mr-2"
>
<ArrowLeft />
</button>
)}
<ul className="flex items-center gap-2">{items}</ul>
{page < maxPage && (
<button
onClick={handleNext}
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition ml-2"
>
<ArrowRight />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import React, { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
/* ================== TYPES ================== */
interface Props {
open: boolean;
isFull?: boolean;
onClose: () => void;
children?: ReactNode;
showOverlay?: boolean;
}
/* ================== COMPONENT ================== */
export default function CustomPopup({
open,
onClose,
showOverlay = true,
isFull,
children,
}: Props) {
// lock body scroll
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original || "auto";
};
}, [open]);
// SSR safe
if (typeof window === "undefined") return null;
// popup closed
if (!open) return null;
return createPortal(
<>
{/* OVERLAY */}
{showOverlay && (
<div
onClick={onClose}
className="
fixed
inset-0
z-[10000]
bg-[#141416]/50
animate-[fadeIn_0.2s_ease-in]
"
/>
)}
{/* CONTENT */}
<div
className={clsx(
`
fixed
z-[10001]
flex
items-center
justify-center
top-1/2
left-1/2
-translate-x-1/2
-translate-y-1/2
`,
isFull &&
`
max-[768px]:top-0
max-[768px]:left-0
max-[768px]:right-0
max-[768px]:bottom-0
max-[768px]:translate-x-0
max-[768px]:translate-y-0
`,
)}
>
{children}
</div>
</>,
document.body,
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { ReactNode, useLayoutEffect, useMemo } from "react";
import { createPortal } from "react-dom";
interface Props {
children: ReactNode;
className?: string;
containerId?: string;
}
export default function CustomPortal({
children,
className,
containerId,
}: Props) {
// ✅ tạo element 1 lần duy nhất
const element = useMemo(() => {
if (typeof window === "undefined") return null;
const el = document.createElement("div");
if (className) {
el.className = className;
}
return el;
}, [className]);
useLayoutEffect(() => {
if (!element) return;
const parent = containerId
? document.getElementById(containerId)
: document.body;
if (!parent) return;
parent.appendChild(element);
return () => {
parent.removeChild(element);
};
}, [element, containerId]);
// ✅ không dùng ref.current
// ✅ không dùng setState
if (!element) return null;
return createPortal(children, element);
}

View File

@ -0,0 +1,56 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { Search as SearchIcon } from "lucide-react";
interface PropsSearch {
placeholder?: string;
keyword?: string;
setKeyword: (value: string) => void;
}
export default function Search({
keyword = "",
setKeyword,
placeholder = "Nhập từ khóa tìm kiếm",
}: PropsSearch) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState(keyword);
// useEffect(() => {
// setSearchTerm(keyword);
// }, [keyword]);
useEffect(() => {
const timer = setTimeout(() => {
setKeyword(searchTerm.trim());
}, 600);
return () => clearTimeout(timer);
}, [searchTerm]);
return (
<div
onClick={() => inputRef.current?.focus()}
className={clsx(
"flex h-12 cursor-text items-center gap-2 rounded-full border bg-white px-4 transition-colors",
"border-[#e1e5ed] hover:border-[#3772ff] focus-within:border-[#3772ff]",
)}
>
<SearchIcon size={20} className="text-[#908F99]" />
<input
ref={inputRef}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={placeholder}
className="
flex-1 border-none bg-transparent p-2
text-[16px] font-medium outline-none
placeholder:text-[#777e90]
"
/>
</div>
);
}

View File

@ -0,0 +1,241 @@
"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import {
ChevronUp,
ArrowDownUp,
ArrowDownWideNarrow,
ArrowDownNarrowWide,
} from "lucide-react";
/* ================= TYPES ================= */
interface ColumnType<T> {
title: string | React.ReactNode;
render: (row: T, index: number, path?: number[]) => React.ReactNode;
className?: string;
checkBox?: boolean;
fixedLeft?: boolean;
fixedRight?: boolean;
maxWidth?: number | string;
sortTable?: string;
}
interface PropsTable<T> {
data: T[];
column: ColumnType<T>[];
fixedHeader?: boolean;
activeHeader?: boolean;
handleCheckedAll?: (e: React.ChangeEvent<HTMLInputElement>) => void;
isCheckedAll?: boolean;
handleCheckedRow?: (e: React.ChangeEvent<HTMLInputElement>, row: T) => void;
handleIsCheckedRow?: (row: T) => boolean;
rowKey: (row: T) => React.Key;
getChildren?: (row: T) => T[] | undefined;
useIndexPathAsKey?: boolean;
}
/* ================= COMPONENT ================= */
export default function Table<T>({
data,
column,
fixedHeader,
activeHeader,
handleCheckedAll,
isCheckedAll,
handleCheckedRow,
handleIsCheckedRow,
rowKey,
getChildren,
useIndexPathAsKey,
}: PropsTable<T>) {
const tableRef = useRef<HTMLDivElement>(null);
const thRefs = useRef<(HTMLTableCellElement | null)[]>([]);
const [expandedRows, setExpandedRows] = useState<React.Key[]>([]);
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: "asc" | "desc" | null;
}>({ key: "", direction: null });
/* ================= SORT ================= */
const handleSort = (key: string) => {
setSortConfig((prev) => {
if (prev.key === key) {
const next =
prev.direction === "asc"
? "desc"
: prev.direction === "desc"
? null
: "asc";
return { key, direction: next };
}
return { key, direction: "asc" };
});
};
const sortedData = useMemo(() => {
if (!sortConfig.key || !sortConfig.direction) return data;
const copy = [...data];
copy.sort((a: any, b: any) => {
const aVal = a?.[sortConfig.key];
const bVal = b?.[sortConfig.key];
if (typeof aVal === "number" && typeof bVal === "number") {
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
}
if (typeof aVal === "string" && typeof bVal === "string") {
return sortConfig.direction === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return 0;
});
return copy;
}, [data, sortConfig]);
/* ================= EXPAND ================= */
const toggleExpand = (key: React.Key) => {
setExpandedRows((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
/* ================= ROWS ================= */
const renderRows = (rows: T[], path: number[] = []): React.ReactNode =>
rows.map((row, i) => {
const currentPath = [...path, i];
const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row);
const children = getChildren?.(row);
const expanded = expandedRows.includes(keyPath);
return (
<Fragment key={String(keyPath)}>
<tr className="bg-white border-b">
{getChildren && (
<td className="w-[60px] text-center">
{!!children?.length && (
<button
onClick={() => toggleExpand(keyPath)}
className={clsx(
"transition text-gray-400 hover:text-blue-500",
expanded && "rotate-180 text-blue-500",
)}
>
<ChevronUp size={18} />
</button>
)}
</td>
)}
{column.map((col, idx) => (
<td
key={idx}
className={clsx(
"px-4 py-3 text-sm font-medium whitespace-nowrap",
col.fixedLeft && "sticky left-0 bg-white z-10",
col.fixedRight && "sticky right-0 bg-white z-10",
)}
>
<div
style={{
maxWidth:
typeof col.maxWidth === "number"
? `${col.maxWidth}px`
: col.maxWidth,
}}
className={clsx("flex items-center gap-2")}
>
{col.checkBox && (
<input
type="checkbox"
className="w-4 h-4 accent-blue-500 cursor-pointer"
onChange={(e) => handleCheckedRow?.(e, row)}
checked={handleIsCheckedRow?.(row) ?? false}
/>
)}
{col.render(row, i, currentPath)}
</div>
</td>
))}
</tr>
{expanded && children && renderRows(children, currentPath)}
</Fragment>
);
});
/* ================= UI ================= */
return (
<div
ref={tableRef}
className={clsx(
"overflow-auto pb-2",
fixedHeader && "[&>table>thead]:sticky [&>table>thead]:top-0",
activeHeader && "[&>table>thead]:bg-[#F4F7FA]",
)}
>
<table className="w-full border-collapse">
<thead className="bg-white border-b">
<tr>
{getChildren && <th className="w-[60px]" />}
{column.map((col, i) => {
const isActive = col.sortTable === sortConfig.key;
return (
<th
key={i}
ref={(el) => {
thRefs.current[i] = el;
}}
onClick={() => col.sortTable && handleSort(col.sortTable)}
className={clsx(
"px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap",
col.sortTable && "cursor-pointer",
isActive && "text-blue-600",
col.fixedLeft && "sticky left-0 bg-white z-10",
col.fixedRight && "sticky right-0 bg-white z-10",
)}
>
<div className="flex items-center gap-1">
{col.checkBox && (
<input
type="checkbox"
onChange={handleCheckedAll}
checked={isCheckedAll}
/>
)}
{col.title}
{col.sortTable &&
(isActive ? (
sortConfig.direction === "asc" ? (
<ArrowDownNarrowWide size={14} />
) : sortConfig.direction === "desc" ? (
<ArrowDownWideNarrow size={14} />
) : (
<ArrowDownUp size={14} />
)
) : (
<ArrowDownUp size={14} />
))}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>{renderRows(sortedData)}</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { CircleCheck, CircleAlert, BadgeCheck } from "lucide-react";
import clsx from "clsx";
export interface PropsIconToastifyCustom {
type: "success" | "info" | "warn" | "error";
}
const ICON_CONFIG = {
success: {
bg: "bg-[#06d7a0]",
icon: CircleCheck,
},
info: {
bg: "bg-[#4bc9f0]",
icon: CircleCheck,
},
warn: {
bg: "bg-[#ffd167]",
icon: CircleAlert,
},
error: {
bg: "bg-[#ee464c]",
icon: BadgeCheck,
},
} as const;
export default function IconToastifyCustom({ type }: PropsIconToastifyCustom) {
const { bg, icon: Icon } = ICON_CONFIG[type];
return (
<div
className={clsx(
"w-[50px] h-[50px] flex items-center justify-center rounded-full",
bg,
)}
>
<Icon size={20} className="text-white" />
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import React from "react";
import clsx from "clsx";
interface CustomSliderProps {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange?: (value: number) => void;
showValue?: boolean;
disabled?: boolean;
className?: string;
labelClassName?: string;
sliderClassName?: string;
thumbClassName?: string;
trackClassName?: string;
color?: string;
trackColor?: string;
renderValue?: (value: number) => React.ReactNode;
}
const CustomSlider = ({
label,
value,
min = 0,
max = 1,
step = 0.01,
onChange,
showValue = true,
disabled = false,
className,
labelClassName,
sliderClassName,
color = "#2563eb",
trackColor = "#e5e7eb",
renderValue,
}: CustomSliderProps) => {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={clsx("w-full space-y-3", className)}>
{/* Header */}
{(label || showValue) && (
<div
className={clsx("flex items-center justify-between", labelClassName)}
>
{label && (
<span className="text-sm font-medium text-gray-700">{label}</span>
)}
{showValue && (
<span className="text-sm font-semibold text-gray-900">
{renderValue ? renderValue(value) : value}
</span>
)}
</div>
)}
{/* Slider */}
<input
type="range"
min={min}
max={max}
step={step}
disabled={disabled}
value={value}
onChange={(e) => onChange?.(Number(e.target.value))}
className={clsx(
`
w-full
appearance-none
bg-transparent
cursor-pointer
disabled:opacity-50
disabled:cursor-not-allowed
[&::-webkit-slider-runnable-track]:h-2
[&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:w-6
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-4
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:-mt-2
[&::-moz-range-track]:h-2
[&::-moz-range-track]:rounded-full
[&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:w-6
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border-4
`,
sliderClassName,
)}
style={{
background: `linear-gradient(to right, ${color} ${percentage}%, ${trackColor} ${percentage}%)`,
}}
/>
</div>
);
};
export default CustomSlider;

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default } from "./Header";

View File

@ -0,0 +1,4 @@
export interface PropsHeader {
title: string;
breadcrumb?: React.ReactNode;
}

View File

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

View File

@ -0,0 +1 @@
export { default } from "./MenuProfile";

View File

@ -0,0 +1,3 @@
export interface PropsMenuProfile {
onClose: () => void;
}

View File

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

View File

@ -0,0 +1 @@
export { default } from "./Navbar";

View File

@ -0,0 +1 @@
export { default } from "./BaseLayout";

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export interface PropsChartWrapper {
children: React.ReactNode;
isEmpty?: boolean;
message?: string;
}

View File

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

View File

@ -0,0 +1 @@
export { default } from "./GridColumn";

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default } from "./LayoutAuth";

View File

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

View File

@ -0,0 +1 @@
export { default } from "./LayoutPages";

View File

@ -0,0 +1,8 @@
export interface PropsLayoutPages {
children: React.ReactNode;
listPages: {
title: string;
url: string;
color: string;
}[];
}

View File

@ -0,0 +1,8 @@
"use client";
import React from "react";
const MainPageHome = () => {
return <div>MainPageHome</div>;
};
export default MainPageHome;

View File

@ -0,0 +1 @@
export { default } from "./MainPageHome";

View File

@ -0,0 +1,66 @@
"use client";
import React, { useState } from "react";
import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "./interface";
import { ContextForgotPassword } from "./context";
import FormEmail from "./components/FormEmail";
import FormPassword from "./components/FormPassword/FormPassword";
export default function MainForgotPassword() {
const [type, setType] = useState<TYPE_FORGOT_PASWORD>(
TYPE_FORGOT_PASWORD.EMAIL,
);
const [form, setForm] = useState<IFormForgotPassword>({
email: "",
otp: "",
password: "",
rePassword: "",
});
return (
<div className="w-full">
{/* TITLE */}
<h3
className="
text-[34px]
font-bold
text-[#1A1B2D]
"
>
Quên mật khẩu
</h3>
{/* DESCRIPTION */}
<p
className="
mt-1
text-[14px]
font-medium
text-[#6F767E]
"
>
Nhập đa chỉ email liên kết với tài khoản của bạn đ lấy lại mật khẩu!
</p>
{/* FORM */}
<div className="mt-6">
<ContextForgotPassword.Provider
value={{
form,
setForm,
type,
setType,
}}
>
{type === TYPE_FORGOT_PASWORD.EMAIL && <FormEmail />}
{type === TYPE_FORGOT_PASWORD.PASSWORD && <FormPassword />}
</ContextForgotPassword.Provider>
</div>
</div>
);
}

View File

@ -0,0 +1,126 @@
"use client";
import React, { useContext } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { Mail } from "lucide-react";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomPopup from "@/components/customs/custom-popup";
import CustomLoading from "@/components/customs/custom-loading";
import CustomButton from "@/components/customs/custom-button";
import { ContextForgotPassword } from "../../context";
import FormOTP from "../FormOTP";
import { PATH } from "@/constant/config";
import { httpRequest } from "@/services";
import accountServices from "@/services/accountServices";
function FormEmail() {
const router = useRouter();
const searchParams = useSearchParams();
const open = searchParams.get("_open");
const { form, setForm } = useContext(ContextForgotPassword);
const sendOTPMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Mã OTP đã được gửi đến email của bạn",
http: accountServices.sendOTP({
email: form?.email,
}),
});
},
onSuccess(data) {
if (!data) return;
const params = new URLSearchParams(searchParams.toString());
params.set("_open", "otp");
router.replace(`?${params.toString()}`);
},
});
const handleSendOTP = () => {
sendOTPMutation.mutate();
};
const handleClosePopup = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_open");
router.replace(`?${params.toString()}`);
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSendOTP}>
<CustomLoading loading={sendOTPMutation.isPending} />
{/* INPUT EMAIL */}
<InputForm
label={
<span>
Email
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập email"
type="text"
name="email"
isEmail
onClean
isRequired
isBlur
icon={<Mail size={22} />}
/>
{/* BUTTON GROUP */}
<div className="mt-6">
{/* BUTTON SUBMIT */}
<CustomButton
// type="submit"
disabled={sendOTPMutation.isPending}
variant="midnightBlue"
rounded="full"
className="w-full"
>
Lấy lại mật khẩu
</CustomButton>
{/* LINE */}
<div className="my-5 h-[1px] w-full bg-[#E5E5E5]" />
{/* LOGIN BUTTON */}
<CustomButton
// type="button"
variant="grey"
rounded="full"
className="w-full"
onClick={() => router.push(PATH.LOGIN)}
>
Đăng nhập ngay
</CustomButton>
</div>
{/* POPUP OTP */}
<CustomPopup open={open === "otp"} onClose={handleClosePopup}>
<FormOTP />
</CustomPopup>
</FormCustom>
);
}
export default FormEmail;

View File

@ -0,0 +1 @@
export { default } from "./FormEmail";

View File

@ -0,0 +1,241 @@
"use client";
import React, { useContext, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { X } from "lucide-react";
import { ContextForgotPassword } from "../../context";
import { TYPE_FORGOT_PASWORD } from "../../interface";
import CustomButton from "@/components/customs/custom-button";
import CustomLoading from "@/components/customs/custom-loading";
import { httpRequest } from "@/services";
import accountServices from "@/services/accountServices";
import InputSingle from "@/components/customs/custom-input";
import fancyTimeFormat, { obfuscateEmail } from "@/common/funcs/optionConvert";
function FormOTP() {
const TIME_OTP = 60;
const router = useRouter();
const searchParams = useSearchParams();
const [countDown, setCountDown] = useState<number>(TIME_OTP);
const { form, setForm, setType } = useContext(ContextForgotPassword);
/* ========================= COUNTDOWN ========================= */
useEffect(() => {
if (countDown <= 0) return;
const timeout = setTimeout(() => {
setCountDown((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timeout);
}, [countDown]);
/* ========================= CLOSE POPUP ========================= */
const closeForm = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_open");
router.replace(`?${params.toString()}`);
};
/* ========================= SEND OTP ========================= */
const sendOTPMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Mã OTP đã được gửi đến email của bạn",
http: accountServices.sendOTP({
email: form.email!,
}),
});
},
onSuccess(data) {
if (data) {
setCountDown(TIME_OTP);
}
},
});
/* ========================= VERIFY OTP ========================= */
const submitOTPMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Xác thực thành công",
http: accountServices.enterOTP({
email: form.email!,
otp: form.otp!,
}),
});
},
onSuccess(data) {
if (data) {
setType(TYPE_FORGOT_PASWORD.PASSWORD);
closeForm();
}
},
});
/* ========================= HANDLERS ========================= */
const handleSendCode = () => {
sendOTPMutation.mutate();
};
const handleSubmit = () => {
submitOTPMutation.mutate();
};
return (
<div
className="
relative
w-[476px]
rounded-2xl
bg-white
p-10
shadow-[0px_8px_40px_rgba(63,83,115,0.16),0px_3px_24px_rgba(63,83,115,0.2),0px_3px_8px_rgba(63,83,115,0.08),0px_1px_0px_rgba(63,83,115,0.08)]
"
>
{/* LOADING */}
<CustomLoading
loading={sendOTPMutation.isPending || submitOTPMutation.isPending}
/>
{/* TITLE */}
<h3
className="
text-center
text-[28px]
font-bold
text-[#1A1B2D]
"
>
Xác thực OTP
</h3>
{/* DESCRIPTION */}
<p
className="
mt-1
text-center
text-[14px]
font-medium
text-[#6F767E]
"
>
Một xác thực đã đưc gửi cho bạn qua đa chỉ email:
<span className="font-semibold"> {obfuscateEmail(form.email!)}</span>
</p>
{/* FORM */}
<div className="mt-6">
{/* LABEL */}
<p
className="
text-center
text-[16px]
font-medium
text-[#23262F]
"
>
Nhập OTP
</p>
{/* INPUT OTP */}
<div className="mt-4">
<InputSingle onSetValue={setForm} name="otp" lenght={6} />
</div>
{/* COUNTDOWN */}
<p
className="
mt-5
text-center
text-[15px]
font-medium
text-[#23262F]
"
>
Bạn chưa nhận đưc .
{countDown > 0 ? (
<span
className="
ml-1
cursor-default
text-[15px]
font-medium
text-[#0011AB]
"
>
Gửi lại OTP ({fancyTimeFormat(countDown)})
</span>
) : (
<span
onClick={handleSendCode}
className="
ml-1
cursor-pointer
text-[15px]
font-medium
text-[#0011AB]
transition-all
hover:underline
"
>
Gửi lại OTP
</span>
)}
</p>
</div>
{/* BUTTON */}
<div className="mt-6">
<CustomButton
variant="midnightBlue"
rounded="full"
className="w-full"
disabled={form.otp.length! < 6}
onClick={handleSubmit}
>
Xác thực Email
</CustomButton>
</div>
{/* CLOSE */}
<button
type="button"
onClick={closeForm}
className="
absolute
right-5
top-5
cursor-pointer
select-none
transition-all
active:scale-90
"
>
<X size={24} />
</button>
</div>
);
}
export default FormOTP;

View File

@ -0,0 +1 @@
export { default } from "./FormOTP";

View File

@ -0,0 +1,169 @@
"use client";
import React, { useContext } from "react";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { useSelector } from "react-redux";
import md5 from "md5";
import { RootState, store } from "@/redux/store";
import {
setDataLoginStorage,
setStateLogin,
setToken,
} from "@/redux/reducer/auth";
import { setInfoUser } from "@/redux/reducer/user";
import { PATH } from "@/constant/config";
import { httpRequest } from "@/services";
import authServices from "@/services/authServices";
import { ContextForgotPassword, IContextForgotPassword } from "../../context";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomButton from "@/components/customs/custom-button";
import CustomLoading from "@/components/customs/custom-loading";
import FormCustom from "@/components/utils/FormCustom";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
export default function FormPassword() {
const router = useRouter();
const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
const { isRememberPassword } = useSelector((state: RootState) => state.site);
const { form, setForm } = useContext<IContextForgotPassword>(
ContextForgotPassword,
);
const loginMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Đăng nhập thành công!",
http: authServices.login({
username: dataLoginStorage?.usernameStorage || "",
password: md5(
`${form.password}_${process.env.NEXT_PUBLIC_KEY_PASSWORD}`,
),
ip: "",
address: "",
type: 0,
}),
});
},
onSuccess(data: any) {
if (!data) return;
store.dispatch(setStateLogin(true));
store.dispatch(setToken(data.accessToken));
store.dispatch(
setInfoUser({
accessToken: data?.accessToken || "",
refreshToken: data?.refreshToken || "",
accessExpiresAt: data?.accessExpiresAt || "",
refreshExpiresAt: data?.refreshExpiresAt || "",
avatar: data?.avatar || "",
fullname: data?.fullname || "",
}),
);
if (isRememberPassword) {
store.dispatch(
setDataLoginStorage({
usernameStorage: dataLoginStorage?.usernameStorage || "",
passwordStorage: form.password,
}),
);
} else {
store.dispatch(setDataLoginStorage(null));
}
router.replace(PATH.HOME);
},
});
const handleSubmit = () => {
loginMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<CustomLoading loading={loginMutation.isPending} />
{/* PASSWORD */}
<InputForm
label={
<span>
Mật khẩu mới
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập mật khẩu mới"
type="password"
name="password"
onClean
isRequired
isBlur
showDone
icon={<ShieldCheck size={22} />}
/>
{/* RE PASSWORD */}
<div className="mt-5">
<InputForm
label={
<span>
Xác nhận mật khẩu mới
<span className="text-red-500"> *</span>
</span>
}
placeholder="Xác nhận mật khẩu mới"
type="password"
name="rePassword"
valueConfirm={form.password}
onClean
isRequired
isBlur
showDone
icon={<ShieldCheck size={22} />}
/>
</div>
{/* BUTTON */}
<div className="mt-6">
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
variant="midnightBlue"
rounded="full"
disabled={!isDone}
className="py-[10px] font-bold"
>
Lấy lại mật khẩu
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</FormCustom>
);
}

View File

@ -0,0 +1,16 @@
import { createContext, Dispatch, SetStateAction } from "react";
import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "../interface";
export interface IContextForgotPassword {
form: IFormForgotPassword;
setForm: Dispatch<SetStateAction<IFormForgotPassword>>;
type: number;
setType: Dispatch<SetStateAction<number>>;
}
export const ContextForgotPassword = createContext<IContextForgotPassword>({
form: { email: "", otp: "", password: "", rePassword: "" },
setForm: () => null,
type: TYPE_FORGOT_PASWORD.EMAIL,
setType: () => null,
});

View File

@ -0,0 +1,11 @@
export interface IFormForgotPassword {
email: string;
otp: string;
password: string;
rePassword: string;
}
export enum TYPE_FORGOT_PASWORD {
EMAIL = 1,
PASSWORD,
}

View File

@ -0,0 +1,269 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { LockKeyhole, ShieldPlus, User } from "lucide-react";
import { useSelector } from "react-redux";
import { RootState, store } from "@/redux/store";
import {
setDataLoginStorage,
setStateLogin,
setToken,
} from "@/redux/reducer/auth";
import { setRememberPassword } from "@/redux/reducer/site";
import { setInfoUser } from "@/redux/reducer/user";
import authServices from "@/services/authServices";
import { httpRequest } from "@/services";
import { PATH } from "@/constant/config";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomLoading from "@/components/customs/custom-loading";
// import FormCustom, { InputForm } from "@/components/common/FormCustom";
export default function MainLogin() {
const router = useRouter();
const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
const { isRememberPassword } = useSelector((state: RootState) => state.site);
const [form, setForm] = useState({
username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "",
password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "",
});
const loginMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Đăng nhập thành công!",
http: authServices.login({
username: form.username,
password: form.password,
ip: "",
address: "",
type: 0,
}),
});
},
onSuccess(data: any) {
if (!data) return;
store.dispatch(setStateLogin(true));
store.dispatch(setToken(data.accessToken));
store.dispatch(
setInfoUser({
accessToken: data?.accessToken || "",
refreshToken: data?.refreshToken || "",
accessExpiresAt: data?.accessExpiresAt || "",
refreshExpiresAt: data?.refreshExpiresAt || "",
avatar: data?.avatar || "",
fullname: data?.fullname || "",
}),
);
if (isRememberPassword) {
store.dispatch(
setDataLoginStorage({
usernameStorage: form.username,
passwordStorage: form.password,
}),
);
} else {
store.dispatch(setDataLoginStorage(null));
}
router.replace(PATH.HOME);
},
});
const handleLogin = () => {
loginMutation.mutate();
};
return (
<div className="w-full">
<CustomLoading loading={loginMutation.isPending} />
<FormCustom form={form} setForm={setForm} onSubmit={handleLogin}>
{/* TITLE */}
<h3
className="
text-[34px]
font-semibold
text-[#1A1B2D]
"
>
Đăng nhập
</h3>
<p
className="
mt-1
text-[14px]
font-medium
text-[#6F767E]
"
>
Chào mừng bạn đến với hệ thống quản
</p>
{/* FORM */}
<div className="mt-6">
{/* USERNAME */}
<InputForm
label={
<span>
Tài khoản
<span className="text-red-500"> *</span>
</span>
}
placeholder="Tài khoản"
type="text"
name="username"
onClean
isRequired
isBlur
showDone
icon={<User size={22} />}
/>
{/* PASSWORD */}
<div className="mt-5">
<InputForm
label={
<span>
Mật khẩu
<span className="text-red-500"> *</span>
</span>
}
placeholder="Mật khẩu"
type="password"
name="password"
onClean
isRequired
isBlur
showDone
icon={<ShieldPlus size={22} />}
/>
</div>
{/* REMEMBER PASSWORD */}
<div
className="
mt-[14px]
flex
items-center
gap-2
cursor-pointer
"
>
<input
id="rememberPassword"
type="checkbox"
checked={isRememberPassword}
onChange={() =>
store.dispatch(setRememberPassword(!isRememberPassword))
}
className="
h-5
w-5
cursor-pointer
accent-[#0011AB]
"
/>
<label
htmlFor="rememberPassword"
className="
cursor-pointer
select-none
text-[14px]
font-medium
text-[#171717]
"
>
Nhớ mật khẩu
</label>
</div>
{/* BUTTONS */}
<div className="mt-6">
{/* LOGIN */}
<button
type="submit"
disabled={loginMutation.isPending}
className="
flex
h-[52px]
w-full
items-center
justify-center
rounded-full
bg-[#0011AB]
px-6
text-white
font-bold
transition-all
hover:opacity-90
disabled:cursor-not-allowed
disabled:opacity-50
"
>
Đăng nhập
</button>
{/* LINE */}
<div
className="
my-5
h-[1px]
w-full
bg-[#E5E5E5]
"
/>
{/* FORGOT PASSWORD */}
<button
type="button"
onClick={() => router.push(PATH.FORGOT_PASSWORD)}
className="
flex
h-[52px]
w-full
items-center
justify-center
gap-2
rounded-full
border
border-gray-300
bg-white
px-6
font-bold
text-[#171717]
transition-all
hover:bg-gray-50
"
>
<LockKeyhole size={22} />
Quên mật khẩu
</button>
</div>
</div>
</FormCustom>
</div>
);
}

View File

@ -0,0 +1 @@
export { default } from "./MainLogin";

View File

@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
function LoadingTopBar() {
useEffect(() => {
NProgress.configure({ showSpinner: false });
const handleClick = (e: any) => {
const target = e.target.closest("a");
if (target?.href && target.href.startsWith(window.location.origin)) {
NProgress.start();
}
};
const handleStop = () => {
NProgress.done();
};
window.addEventListener("click", handleClick);
window.addEventListener("load", handleStop);
return () => {
window.removeEventListener("click", handleClick);
window.removeEventListener("load", handleStop);
};
}, []);
return null;
}
export default LoadingTopBar;

View File

@ -0,0 +1 @@
export { default } from "./LoadingTopBar";

View File

@ -0,0 +1,30 @@
"use client";
import { PATH } from "@/constant/config";
import { RootState } from "@/redux/store";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSelector } from "react-redux";
interface IRequireAuthProps {
children: React.ReactNode;
}
export default function RequireAuth(props: IRequireAuthProps) {
const router = useRouter();
const { loading } = useSelector((state: RootState) => state.site);
const { isLogin } = useSelector((state: RootState) => state.auth);
useEffect(() => {
if (!isLogin && !loading) {
router.replace(PATH.LOGIN);
}
}, [isLogin, loading, router]);
if (isLogin && !loading) {
return <>{props.children}</>;
}
return <div className="loading-page" />;
}

View File

@ -0,0 +1,34 @@
//**********************
//* COMPONENT PROTECTED SCREEN THEN LOGIN
//**********************
"use client";
import React, { useEffect } from "react";
import { RootState } from "@/redux/store";
import { useRouter } from "next/navigation";
import { useSelector } from "react-redux";
import { PATH } from "@/constant/config";
interface props {
children: React.ReactNode;
}
function RequiredLogout({ children }: props) {
const { replace } = useRouter();
const { isLogin } = useSelector((state: RootState) => state.auth);
const { loading } = useSelector((state: RootState) => state.site);
useEffect(() => {
if (isLogin && !loading) replace(PATH.HOME);
}, [isLogin, loading, replace]);
if (!isLogin && !loading) {
return <>{children}</>;
}
return <div className="loading-page"></div>;
}
export default RequiredLogout;

View File

@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import { RootState, store } from "@/redux/store";
import { useSelector } from "react-redux";
import clsx from "clsx";
import Lottie from "lottie-react";
import { getItemStorage, setItemStorage } from "@/common/funcs/localStorage";
import { KEY_STORE } from "@/constant/config";
import { setLoading, setRememberPassword } from "@/redux/reducer/site";
import {
setDataLoginStorage,
setStateLogin,
setToken,
} from "@/redux/reducer/auth";
import { setInfoUser } from "@/redux/reducer/user";
import loadingAnim from "@/../public/static/anim/loading_screen.json";
function SplashScreen() {
const [isClosing, setIsClosing] = useState(false);
const { loading, isRememberPassword } = useSelector(
(state: RootState) => state.site,
);
const { infoUser } = useSelector((state: RootState) => state.user);
const { token, isLogin, dataLoginStorage } = useSelector(
(state: RootState) => state.auth,
);
// 🔥 Load state từ localStorage
useEffect(() => {
(async () => {
const encryptedState = await getItemStorage(KEY_STORE);
const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
const decode = (data: string): any => {
try {
const decoded = decodeURIComponent(atob(data));
const clean = decoded.startsWith(SECRET_KEY)
? decoded.slice(SECRET_KEY.length)
: null;
return clean ? JSON.parse(clean) : null;
} catch (err) {
console.error("Decode error:", err);
return null;
}
};
const state =
typeof encryptedState === "string" ? decode(encryptedState) : null;
if (state) {
store.dispatch(setToken(state.token));
store.dispatch(setStateLogin(state.isLogin));
store.dispatch(setInfoUser(state.infoUser));
store.dispatch(setRememberPassword(state.isRememberPassword));
store.dispatch(setDataLoginStorage(state.dataLoginStorage));
}
store.dispatch(setLoading(false));
})();
}, []);
// 🔥 Save lại state
useEffect(() => {
if (!loading) {
const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
const encode = (data: string): string => {
const textToEncode = SECRET_KEY + data;
return btoa(encodeURIComponent(textToEncode));
};
const hashedData = encode(
JSON.stringify({
isLogin,
token,
infoUser,
isRememberPassword,
dataLoginStorage,
}),
);
setItemStorage(KEY_STORE, hashedData);
// 👉 trigger animation fade out
setTimeout(() => setIsClosing(true), 0);
}
}, [loading, isLogin, token, infoUser, isRememberPassword, dataLoginStorage]);
return (
<div
className={clsx(
"fixed inset-0 flex items-center justify-center z-[1000000] bg-white transition-opacity duration-300",
{
"opacity-0 invisible": !loading && isClosing,
"opacity-100 visible": loading,
},
)}
>
<div className="w-[40vw] max-w-[320px] max-h-[320px]">
<Lottie animationData={loadingAnim} loop autoplay />
</div>
</div>
);
}
export default SplashScreen;

View File

@ -0,0 +1 @@
export { default } from "./SplashScreen";

View File

@ -0,0 +1,70 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { PropsFormCustom } from "./interface";
import { ContextFormCustom } from "./contexts";
function FormCustom<T extends Record<string, any>>({
form,
setForm,
onSubmit,
children,
}: PropsFormCustom<T>) {
// 👉 tạo object error ban đầu
const initialError = useMemo(() => {
return Object.fromEntries(
Object.keys(form).map((key) => [key, null]),
) as Record<keyof T, string | null>;
}, [form]);
const [countValidate, setCountValidate] = useState(0);
const [errorText, setErrorText] =
useState<Record<keyof T, string | null>>(initialError);
const [validate, setValidate] = useState<Record<keyof T, boolean> | null>(
null,
);
// 👉 check form hợp lệ
const isDone = useMemo(() => {
if (!validate) return false;
return Object.values(validate).every(Boolean);
}, [validate]);
// 👉 submit form
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isDone) {
onSubmit?.();
} else {
setCountValidate((prev) => prev + 1);
}
},
[isDone, onSubmit],
);
return (
<ContextFormCustom.Provider
value={{
form,
setForm,
errorText,
setErrorText,
setValidate,
countValidate,
setCountValidate,
isDone,
}}
>
<form onSubmit={handleSubmit} className="w-full">
{children}
</form>
</ContextFormCustom.Provider>
);
}
export default FormCustom;

View File

@ -0,0 +1,401 @@
"use client";
import React, { useContext, useMemo, useState } from "react";
import clsx from "clsx";
import { Check, CircleX, Eye, EyeOff } from "lucide-react";
import { validateEmail, validatePhoneNumber } from "@/common/funcs/validate";
import { convertCoin, price } from "@/common/funcs/convertCoin";
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
import { PropsInputForm } from "./interface";
function InputForm({
type,
name,
placeholder,
icon,
label,
value,
unit,
note,
textRequired,
textConfirm,
valueConfirm,
isBlur = true,
onClean,
showDone,
readOnly,
max,
min,
isRequired,
isNumber,
isUppercase,
isPhone,
isEmail,
isMoney,
onBlur,
onChangeValue,
}: PropsInputForm) {
const isPassword = type === "password";
const [showPass, setShowPass] = useState(false);
const [isFocus, setIsFocus] = useState(false);
const {
form,
countValidate,
errorText,
isDone,
setForm,
setValidate,
setErrorText,
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
// =========================
// VALUE
// =========================
const currentValue = useMemo(() => {
return value ?? form?.[name] ?? "";
}, [value, form, name]);
// =========================
// VALIDATE
// =========================
const handleValidate = (inputValue?: string | number) => {
const finalValue = String(inputValue ?? currentValue ?? "");
if (isRequired && finalValue.trim() === "") {
return {
valid: false,
message: textRequired || "Vui lòng nhập trường này",
};
}
if (finalValue) {
if (isNumber && isNaN(Number(finalValue))) {
return {
valid: false,
message: "Vui lòng chỉ nhập số",
};
}
if (isPhone && !validatePhoneNumber(finalValue)) {
return {
valid: false,
message: "Định dạng số điện thoại không đúng",
};
}
if (isEmail && !validateEmail(finalValue)) {
return {
valid: false,
message: "Định dạng email không chính xác",
};
}
if (valueConfirm && finalValue !== valueConfirm) {
return {
valid: false,
message: textConfirm || "Mật khẩu không trùng khớp",
};
}
if (max && finalValue.length > max) {
return {
valid: false,
message: `Nhập tối đa ${max} kí tự`,
};
}
if (min && finalValue.length < min) {
return {
valid: false,
message: `Nhập tối thiểu ${min} kí tự`,
};
}
}
return {
valid: true,
message: null,
};
};
// =========================
// ERROR
// =========================
const handleSetMessage = (inputValue?: string | number) => {
const result = handleValidate(inputValue);
setErrorText((prev) => ({
...prev,
[name]: result.message,
}));
setValidate((prev) => ({
...prev,
[name]: result.valid,
}));
};
// =========================
// HANDLERS
// =========================
const handleFocus = () => {
setIsFocus(true);
setErrorText((prev) => ({
...prev,
[name]: null,
}));
};
const handleBlur = () => {
setIsFocus(false);
onBlur?.();
if (isBlur) {
handleSetMessage();
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
if (isUppercase) {
inputValue = inputValue.toUpperCase();
}
// MONEY
if (isMoney) {
const numericValue = Number(price(inputValue));
inputValue = numericValue ? convertCoin(numericValue) : "0";
}
// CUSTOM CHANGE
if (onChangeValue) {
onChangeValue(inputValue);
return;
}
// UPDATE FORM
setForm((prev: any) => ({
...prev,
[name]: inputValue,
}));
// VALIDATE LIVE
if (countValidate > 0) {
handleSetMessage(inputValue);
}
};
const handleClean = () => {
setForm((prev: any) => ({
...prev,
[name]: "",
}));
setErrorText((prev) => ({
...prev,
[name]: null,
}));
setValidate((prev) => ({
...prev,
[name]: !isRequired,
}));
};
// =========================
// RENDER
// =========================
return (
<div className="w-full">
{/* LABEL */}
{label && (
<label
className="
mb-2
block
text-[16px]
font-medium
text-[#171717]
"
>
{label}
</label>
)}
{/* INPUT WRAPPER */}
<div
className={clsx(
`
flex
h-12
items-center
gap-2
rounded-full
border
bg-white
px-4
transition-all
`,
"border-[#CDD5DF]",
"hover:border-[#0011AB]",
isFocus && "border-[#0011AB]",
errorText?.[name] && "border-red-500",
showDone && isDone && !errorText?.[name] && "border-green-600",
readOnly &&
`
cursor-not-allowed
border-gray-200
bg-gray-100
`,
)}
>
{/* ICON */}
{icon && (
<div
className={clsx(
"transition-all",
isFocus && "text-[#0011AB]",
errorText?.[name] && "text-red-500",
showDone && isDone && !errorText?.[name] && "text-green-600",
!isFocus && !errorText?.[name] && "text-gray-400",
)}
>
{icon}
</div>
)}
{/* INPUT */}
<input
name={name}
type={isPassword && showPass ? "text" : type}
value={String(currentValue)}
placeholder={placeholder}
autoComplete="off"
disabled={readOnly}
readOnly={readOnly}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
className="
flex-1
bg-transparent
text-[16px]
font-medium
outline-none
placeholder:text-gray-400
"
/>
{/* UNIT */}
{unit ? (
<div
className="
border-l
border-gray-300
pl-3
text-[16px]
font-medium
"
>
{unit}
</div>
) : (
<div className="flex items-center gap-2">
{/* CLEAN */}
{onClean && !!currentValue && (
<button
type="button"
onClick={handleClean}
className="
cursor-pointer
transition-all
hover:opacity-70
"
>
<CircleX size={18} />
</button>
)}
{/* DONE */}
{!isPassword &&
showDone &&
isDone &&
!errorText?.[name] &&
!!currentValue && <Check size={18} className="text-green-600" />}
{/* PASSWORD */}
{isPassword && (
<button
type="button"
onClick={() => setShowPass(!showPass)}
className="
cursor-pointer
transition-all
hover:opacity-70
"
>
{showPass ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
)}
</div>
)}
</div>
{/* ERROR */}
{errorText?.[name] && (
<p
className="
mt-1
text-xs
font-medium
text-red-500
"
>
{errorText[name]}
</p>
)}
{/* NOTE */}
{note && (
<p
className="
mt-1
text-xs
font-medium
text-gray-500
"
>
{note}
</p>
)}
</div>
);
}
export default InputForm;

View File

@ -0,0 +1 @@
export { default } from "./InputForm";

View File

@ -0,0 +1,35 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsInputForm {
type: string;
name: string;
placeholder: string;
icon?: React.ReactNode;
label?: string | React.ReactNode;
value?: string | number;
unit?: string;
note?: string;
textRequired?: string;
valueConfirm?: string;
textConfirm?: string;
isBlur?: boolean;
onClean?: boolean;
showDone?: boolean;
readOnly?: boolean;
max?: number;
min?: number;
isRequired?: boolean;
isNumber?: boolean;
isPhone?: boolean;
isEmail?: boolean;
isMoney?: boolean;
isUppercase?: boolean;
onBlur?: () => void;
onChangeValue?: (val: string | number) => void;
}

View File

@ -0,0 +1,175 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { ChevronDown, Search, CircleX } from "lucide-react";
import Image from "next/image";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import icons from "@/constant/images/icons";
import { PropsSelectForm } from "./interface";
function SelectForm<OptionType>({
label,
placeholder = "Chọn...",
isSearch = true,
readOnly,
value,
options = [], // ✅ FIX NULL
onClean,
onSelect,
getOptionLabel,
getOptionValue,
}: PropsSelectForm<OptionType>) {
const refWrapper = useRef<HTMLDivElement>(null);
const refInput = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
/* ================= CLICK OUTSIDE ================= */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!refWrapper.current?.contains(e.target as Node)) {
setOpen(false);
setKeyword("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* ================= FOCUS SEARCH ================= */
useEffect(() => {
if (open && isSearch) {
setTimeout(() => {
refInput.current?.focus();
}, 0);
}
}, [open, isSearch]);
/* ================= DATA ================= */
const safeOptions = options ?? [];
const selectedOption = safeOptions.find(
(opt) => getOptionValue(opt) === value,
);
const filteredOptions = safeOptions.filter((opt) => {
const label = removeVietnameseTones(getOptionLabel(opt) || "");
const key = removeVietnameseTones(keyword || "");
return label.includes(key);
});
/* ================= HANDLER ================= */
const handleSelect = (opt: OptionType) => {
onSelect?.(opt);
setOpen(false);
setKeyword("");
};
return (
<div className="relative w-full" ref={refWrapper}>
{/* LABEL */}
{label && (
<label className="block mb-2 text-[16px] font-medium">{label}</label>
)}
{/* SELECT BOX */}
<div
onClick={() => !readOnly && setOpen((prev) => !prev)}
className={clsx(
"flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition",
"border-gray-300 bg-white hover:border-blue-600",
open && "border-blue-600",
readOnly &&
"bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200",
)}
>
{/* VALUE */}
<p
className={clsx(
"flex-1 text-[16px] font-medium",
!selectedOption && "text-gray-400",
)}
>
{selectedOption ? getOptionLabel(selectedOption) : placeholder}
</p>
{/* CLEAR */}
{onClean && !!value && !readOnly && (
<div
onClick={(e) => {
e.stopPropagation();
onClean();
}}
className="mr-2 hover:opacity-70"
>
<CircleX size={18} className="text-gray-400" />
</div>
)}
{/* ARROW */}
<ChevronDown
size={18}
className={clsx(
"transition",
open && "rotate-180 text-blue-600",
readOnly && "text-gray-400",
)}
/>
</div>
{/* DROPDOWN */}
{open && (
<div className="mt-2 w-full bg-white border border-blue-600 rounded-2xl p-3 shadow-lg z-50 absolute">
{/* SEARCH */}
{isSearch && (
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mb-3">
<Search size={18} className="text-blue-600" />
<input
ref={refInput}
value={keyword}
placeholder="Tìm kiếm..."
onChange={(e) => setKeyword(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
/>
</div>
)}
{/* LIST */}
{filteredOptions.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto space-y-1">
{filteredOptions.map((opt) => {
const active = getOptionValue(opt) === value;
return (
<div
key={String(getOptionValue(opt))}
onClick={() => handleSelect(opt)}
className={clsx(
"px-4 py-2 text-sm font-medium rounded-2xl cursor-pointer transition",
active
? "bg-blue-700 text-white"
: "hover:bg-blue-700 hover:text-white",
)}
>
{getOptionLabel(opt)}
</div>
);
})}
</div>
) : (
<div className="h-[200px] flex flex-col items-center justify-center">
<Image src={icons.emptyFile} alt="empty" />
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
</div>
)}
</div>
)}
</div>
);
}
export default SelectForm;

View File

@ -0,0 +1 @@
export { default } from "./SelectForm";

View File

@ -0,0 +1,15 @@
export interface PropsSelectForm<OptionType> {
placeholder: string;
label?: string | React.ReactNode;
isSearch?: boolean;
readOnly?: boolean;
value: string | number;
options: OptionType[];
onClean?: () => void;
onSelect: (option: OptionType) => void;
getOptionLabel: (option: OptionType) => string;
getOptionValue: (option: OptionType) => string | number;
}

View File

@ -0,0 +1,261 @@
"use client";
import React, { Fragment, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { CirclePlus, Trash, Search, X } from "lucide-react";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import Image from "next/image";
import icons from "@/constant/images/icons";
import Popup from "@/components/customs/custom-popup";
import CustomButton from "@/components/customs/custom-button";
import { PropsSelectMany } from "./interface";
function SelectMany<OptionType>({
text,
label,
placeholder,
isSearch = true,
showSelectedItems = false,
readOnly,
disabledItems = [],
selectedItems = [],
options = [],
title,
onClickSelect,
setSelectedItems,
getOptionLabel,
getOptionValue,
onRemove,
action,
renderOption,
selectedItemFields,
selectedItemFieldLabels,
}: PropsSelectMany<OptionType>) {
const refInputSearch = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const [isFocus, setIsFocus] = useState(false);
const [listDataTerm, setListDataTerm] =
useState<Array<string | number>>(selectedItems);
useEffect(() => {
if (isFocus) {
setTimeout(() => refInputSearch.current?.focus(), 0);
}
}, [isFocus]);
const filteredOptions = options.filter((opt) =>
removeVietnameseTones(String(getOptionLabel(opt) || "")).includes(
removeVietnameseTones(keyword),
),
);
const handleOptionClick = (option: OptionType) => {
const value = getOptionValue(option);
if (disabledItems.includes(value)) return;
setListDataTerm((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
setKeyword("");
};
const handleRemoveSelected = (value: string | number) => {
const updated = listDataTerm.filter((v) => v !== value);
setListDataTerm(updated);
onRemove ? onRemove(value) : setSelectedItems?.(updated);
};
const handleClose = () => {
setKeyword("");
setIsFocus(false);
// reset lại data cũ
setTimeout(() => {
setListDataTerm(selectedItems);
}, 0);
};
const handleSaveData = () => {
setIsFocus(false);
setSelectedItems?.(listDataTerm);
};
return (
<Fragment>
<div className="w-full">
{label && (
<label className="block mb-2 text-base font-medium">{label}</label>
)}
{/* Select box */}
<div
onClick={() => !readOnly && setIsFocus(true)}
className={clsx(
"flex items-center justify-between h-12 px-4 rounded-full border cursor-pointer transition",
"border-gray-300 bg-white hover:border-blue-700",
isFocus && "border-blue-700",
readOnly && "bg-gray-100 border-gray-200 cursor-not-allowed",
)}
>
<p
className={clsx(
"flex-1 font-semibold",
selectedItems.length === 0 && "text-gray-400 font-medium",
)}
>
{selectedItems.length > 0
? `Đã chọn ${selectedItems.length} ${text}`
: placeholder}
</p>
<CirclePlus className="text-blue-700" size={20} />
</div>
{/* Selected chips */}
{showSelectedItems === "default" && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedItems
.map((v) => options.find((o) => getOptionValue(o) === v))
.filter(Boolean)
.map((item) => {
const value = getOptionValue(item!);
const disabled = disabledItems.includes(value);
return (
<div
key={value}
className={clsx(
"flex items-center gap-2 px-3 py-1 rounded-full border text-sm",
disabled
? "bg-gray-100 border-gray-200 text-gray-400"
: "bg-blue-50 border-blue-200 text-blue-700",
)}
>
<span>{getOptionLabel(item!)}</span>
<X
size={16}
className={clsx(
"cursor-pointer",
disabled && "pointer-events-none opacity-50",
)}
onClick={() => handleRemoveSelected(value)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Popup */}
<Popup open={isFocus} onClose={handleClose}>
<div className="relative w-[540px] max-h-[640px] bg-white rounded-2xl p-6 shadow-xl flex flex-col">
{/* Title */}
<h4 className="text-2xl font-semibold">{title || "Danh sách"}</h4>
{/* Search */}
{isSearch && (
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mt-3">
<Search className="text-blue-700" size={18} />
<input
ref={refInputSearch}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="flex-1 outline-none bg-transparent text-sm"
/>
</div>
)}
{/* Select all */}
{filteredOptions.length > 0 && (
<div className="flex items-center justify-between mt-3 mb-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={listDataTerm.length === options.length}
onChange={(e) =>
setListDataTerm(
e.target.checked ? options.map(getOptionValue) : [],
)
}
className="w-5 h-5 accent-blue-500"
/>
<span className="text-gray-500 font-medium">Chọn tất cả</span>
</div>
<p className="text-sm">
Đã chọn:{" "}
<span className="font-semibold">{listDataTerm.length}</span>
</p>
</div>
)}
{/* Options */}
{filteredOptions.length > 0 ? (
<div className="max-h-[320px] overflow-y-auto border-t">
{filteredOptions.map((opt) => {
const value = getOptionValue(opt);
const checked = listDataTerm.includes(value);
return (
<div
key={String(value)}
className="flex items-center gap-3 px-3 py-2 border-b"
>
<input
type="checkbox"
checked={checked}
onChange={() => handleOptionClick(opt)}
disabled={disabledItems.includes(value)}
className="w-5 h-5 accent-blue-500"
/>
<label className="flex-1 cursor-pointer text-base font-medium">
{getOptionLabel(opt)}
{renderOption?.(opt)}
</label>
</div>
);
})}
</div>
) : (
<div className="h-[280px] flex flex-col items-center justify-center">
<Image src={icons.emptyFile} alt="empty" />
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-2 mt-4">
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={listDataTerm.length === 0}
variant="midnightBlue"
rounded="full"
onClick={handleSaveData}
>
Xác nhận
</CustomButton>
</div>
{/* Close */}
<div
onClick={handleClose}
className="absolute top-6 right-6 cursor-pointer active:scale-90"
>
<X size={24} />
</div>
</div>
</Popup>
</Fragment>
);
}
export default SelectMany;

Some files were not shown because too many files have changed in this diff Show More