diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..8b757b2 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules \ No newline at end of file diff --git a/app/globals.css b/app/globals.css deleted file mode 100644 index a2dc41e..0000000 --- a/app/globals.css +++ /dev/null @@ -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; -} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 3f36f7c..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); -} diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..a986ff8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,12 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + rules: { + "no-var": "off", + "@typescript-eslint/no-explicit-any": "off", // tắt lỗi any + }, + }, ]); export default eslintConfig; diff --git a/next.config.ts b/next.config.ts index e9ffa30..078e9e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: process.cwd(), + }, }; export default nextConfig; diff --git a/package.json b/package.json index 7ac131c..2ef519f 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,29 @@ "lint": "eslint" }, "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", + "nprogress": "^0.2.0", "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": { "@tailwindcss/postcss": "^4", + "@types/md5": "^2", "@types/node": "^20", + "@types/nprogress": "^0", "@types/react": "^19", "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.2.6", "tailwindcss": "^4", diff --git a/public/static/anim/loading_screen.json b/public/static/anim/loading_screen.json new file mode 100644 index 0000000..19615dd --- /dev/null +++ b/public/static/anim/loading_screen.json @@ -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": [] +} diff --git a/public/static/images/avatar-default.png b/public/static/images/avatar-default.png new file mode 100644 index 0000000..9334d43 Binary files /dev/null and b/public/static/images/avatar-default.png differ diff --git a/public/static/images/background_auth.jpg b/public/static/images/background_auth.jpg new file mode 100644 index 0000000..33e3c78 Binary files /dev/null and b/public/static/images/background_auth.jpg differ diff --git a/public/static/images/box-remove.svg b/public/static/images/box-remove.svg new file mode 100644 index 0000000..79d6ac5 --- /dev/null +++ b/public/static/images/box-remove.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/static/images/emptyFile.svg b/public/static/images/emptyFile.svg new file mode 100644 index 0000000..345931d --- /dev/null +++ b/public/static/images/emptyFile.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/images/empty_table.png b/public/static/images/empty_table.png new file mode 100644 index 0000000..cf8ddaf Binary files /dev/null and b/public/static/images/empty_table.png differ diff --git a/public/static/images/full-screen.svg b/public/static/images/full-screen.svg new file mode 100644 index 0000000..95cf99c --- /dev/null +++ b/public/static/images/full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/icon-hamburger copy.svg b/public/static/images/icon-hamburger copy.svg new file mode 100644 index 0000000..5e7744e --- /dev/null +++ b/public/static/images/icon-hamburger copy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/icon-hamburger.svg b/public/static/images/icon-hamburger.svg new file mode 100644 index 0000000..5e7744e --- /dev/null +++ b/public/static/images/icon-hamburger.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/pause.svg b/public/static/images/pause.svg new file mode 100644 index 0000000..c41d8ca --- /dev/null +++ b/public/static/images/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/play-circle.svg b/public/static/images/play-circle.svg new file mode 100644 index 0000000..b3906d4 --- /dev/null +++ b/public/static/images/play-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/tick-circle.svg b/public/static/images/tick-circle.svg new file mode 100644 index 0000000..869e8f1 --- /dev/null +++ b/public/static/images/tick-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/warning-2.svg b/public/static/images/warning-2.svg new file mode 100644 index 0000000..2ecb51f --- /dev/null +++ b/public/static/images/warning-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/auth/forgot-password/page.tsx b/src/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..a532b4b --- /dev/null +++ b/src/app/auth/forgot-password/page.tsx @@ -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 ( + + + + ); +}; + +export default page; diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 0000000..018a548 --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -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 ( + + + + ); +}; + +export default page; diff --git a/src/app/consultation/page.tsx b/src/app/consultation/page.tsx new file mode 100644 index 0000000..348f97d --- /dev/null +++ b/src/app/consultation/page.tsx @@ -0,0 +1,9 @@ +"use client"; +import BaseLayout from "@/components/layouts/BaseLayout"; +import React from "react"; + +const page = () => { + return Phiên khám; +}; + +export default page; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..1a7fc19 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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 ( + + + + ); +}; + +export default page; diff --git a/app/favicon.ico b/src/app/favicon.ico similarity index 100% rename from app/favicon.ico rename to src/app/favicon.ico diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..c6cb94a --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/src/app/layout.tsx similarity index 55% rename from app/layout.tsx rename to src/app/layout.tsx index 976eb90..d6a36ff 100644 --- a/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import "react-toastify/dist/ReactToastify.css"; +import Providers from "@/contexts/AppProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -23,11 +25,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + // + // {children} + // + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..1e187e4 --- /dev/null +++ b/src/app/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/patient/page.tsx b/src/app/patient/page.tsx new file mode 100644 index 0000000..4a75145 --- /dev/null +++ b/src/app/patient/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import BaseLayout from "@/components/layouts/BaseLayout"; +import React from "react"; + +const page = () => { + return ( +
+ Bệnh nhân +
+ ); +}; + +export default page; diff --git a/src/app/prescription/page.tsx b/src/app/prescription/page.tsx new file mode 100644 index 0000000..404852d --- /dev/null +++ b/src/app/prescription/page.tsx @@ -0,0 +1,9 @@ +"use client"; +import BaseLayout from "@/components/layouts/BaseLayout"; +import React from "react"; + +const page = () => { + return Đơn thuốc; +}; + +export default page; diff --git a/src/common/funcs/convertCoin.ts b/src/common/funcs/convertCoin.ts new file mode 100644 index 0000000..92008ad --- /dev/null +++ b/src/common/funcs/convertCoin.ts @@ -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; +}; diff --git a/src/common/funcs/delay.ts b/src/common/funcs/delay.ts new file mode 100644 index 0000000..91b78df --- /dev/null +++ b/src/common/funcs/delay.ts @@ -0,0 +1,3 @@ +export async function delay(duration: number) { + return await new Promise((resolve) => setTimeout(resolve, duration)); +} diff --git a/src/common/funcs/localStorage.ts b/src/common/funcs/localStorage.ts new file mode 100644 index 0000000..dfa9d07 --- /dev/null +++ b/src/common/funcs/localStorage.ts @@ -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) { + keys.forEach((key) => { + localStorage.removeItem(key); + }); +} diff --git a/src/common/funcs/optionConvert.ts b/src/common/funcs/optionConvert.ts new file mode 100644 index 0000000..0ad0a05 --- /dev/null +++ b/src/common/funcs/optionConvert.ts @@ -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; +} diff --git a/src/common/funcs/selectData.ts b/src/common/funcs/selectData.ts new file mode 100644 index 0000000..459cd88 --- /dev/null +++ b/src/common/funcs/selectData.ts @@ -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 }; + } +} diff --git a/src/common/funcs/toast.ts b/src/common/funcs/toast.ts new file mode 100644 index 0000000..0e21877 --- /dev/null +++ b/src/common/funcs/toast.ts @@ -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" }), + }); diff --git a/src/common/funcs/validate.ts b/src/common/funcs/validate.ts new file mode 100644 index 0000000..6fceb9d --- /dev/null +++ b/src/common/funcs/validate.ts @@ -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); +} diff --git a/src/common/hooks/useStyleClass.ts b/src/common/hooks/useStyleClass.ts new file mode 100644 index 0000000..dc36bcd --- /dev/null +++ b/src/common/hooks/useStyleClass.ts @@ -0,0 +1,14 @@ +export const useStyleClass = (props: any, style: any) => { + let styleProps: Array = []; + + 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; +}; diff --git a/src/components/customs/DataWrapper.tsx b/src/components/customs/DataWrapper.tsx new file mode 100644 index 0000000..ec7974d --- /dev/null +++ b/src/components/customs/DataWrapper.tsx @@ -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 ( +
+
+ +

Đang tải...

+

+ Vui lòng chờ, đang tải dữ liệu! +

+
+ ); + } + + // ================= EMPTY ================= + if (!loading && (!data || data.length === 0)) { + return ( +
+ empty-data + +

{title}

+ +

{note}

+ + {button &&
{button}
} +
+ ); + } + + // ================= HAS DATA ================= + return <>{children}; +} + +export default DataWrapper; diff --git a/src/components/customs/StateActive.tsx b/src/components/customs/StateActive.tsx new file mode 100644 index 0000000..dcf6dc5 --- /dev/null +++ b/src/components/customs/StateActive.tsx @@ -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 ( +
+ {current?.text ?? "---"} +
+ ); + } + + return ( +
+ {/* DOT */} +
+ + {/* TEXT */} +

+ {current?.text ?? "---"} +

+
+ ); +} + +export default StateActive; diff --git a/src/components/customs/custom-button.tsx b/src/components/customs/custom-button.tsx new file mode 100644 index 0000000..d0443d0 --- /dev/null +++ b/src/components/customs/custom-button.tsx @@ -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) => 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 = { + 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 && {icon}} + {children} + + ); + + const handleClick = (e: React.MouseEvent) => { + if (disabled) return; + onClick?.(e); + }; + + // 🔗 Link mode + if (href) { + return ( + + {content} + + ); + } + + // 🔘 Button mode + return ( + + ); +} diff --git a/src/components/customs/custom-dialog.tsx b/src/components/customs/custom-dialog.tsx new file mode 100644 index 0000000..30a4224 --- /dev/null +++ b/src/components/customs/custom-dialog.tsx @@ -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 ( + +
+ {/* ICON */} + + + {/* TITLE */} +

{title}

+ + {/* NOTE */} + {note &&

{note}

} + + {/* ACTIONS */} +
+ + {titleCancel} + + + + {titleSubmit} + +
+
+
+ ); +} diff --git a/src/components/customs/custom-input.tsx b/src/components/customs/custom-input.tsx new file mode 100644 index 0000000..d5f7ec2 --- /dev/null +++ b/src/components/customs/custom-input.tsx @@ -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([]); + + // 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) => { + 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) => { + 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( + 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 ( +
+ {renderInputs} +
+ ); +} diff --git a/src/components/customs/custom-loading.tsx b/src/components/customs/custom-loading.tsx new file mode 100644 index 0000000..664f8c0 --- /dev/null +++ b/src/components/customs/custom-loading.tsx @@ -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 ( + +
+
+ {Array.from({ length: 12 }).map((_, i) => { + const rotate = i * 30; + + return ( +
+
+
+ ); + })} +
+
+ + ); +} diff --git a/src/components/customs/custom-pagination.tsx b/src/components/customs/custom-pagination.tsx new file mode 100644 index 0000000..4517b4b --- /dev/null +++ b/src/components/customs/custom-pagination.tsx @@ -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) => ( +
+ {pageSize} + +
+)); + +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(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( +
  • 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} +
  • , + ); + } + + if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) { + nodes.push( +
  • + ... +
  • , + ); + } + } + + 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 ( +
    + {/* LEFT */} +
    +

    Hiển thị

    + +
    + 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 && ( +
    + {pageSizes.map((v) => ( +
    { + 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} +
    + ))} +
    + )} +
    + +

    + trong tổng {total} kết quả +

    +
    + + {/* PAGINATION */} +
    + {page > 1 && ( + + )} + +
      {items}
    + + {page < maxPage && ( + + )} +
    +
    + ); +} diff --git a/src/components/customs/custom-popup.tsx b/src/components/customs/custom-popup.tsx new file mode 100644 index 0000000..b6c8b09 --- /dev/null +++ b/src/components/customs/custom-popup.tsx @@ -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 && ( +
    + )} + + {/* CONTENT */} +
    + {children} +
    + , + document.body, + ); +} diff --git a/src/components/customs/custom-portal.tsx b/src/components/customs/custom-portal.tsx new file mode 100644 index 0000000..53b0e3e --- /dev/null +++ b/src/components/customs/custom-portal.tsx @@ -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); +} diff --git a/src/components/customs/custom-search.tsx b/src/components/customs/custom-search.tsx new file mode 100644 index 0000000..0b87c57 --- /dev/null +++ b/src/components/customs/custom-search.tsx @@ -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(null); + const [searchTerm, setSearchTerm] = useState(keyword); + + // useEffect(() => { + // setSearchTerm(keyword); + // }, [keyword]); + + useEffect(() => { + const timer = setTimeout(() => { + setKeyword(searchTerm.trim()); + }, 600); + + return () => clearTimeout(timer); + }, [searchTerm]); + + return ( +
    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]", + )} + > + + + setSearchTerm(e.target.value)} + placeholder={placeholder} + className=" + flex-1 border-none bg-transparent p-2 + text-[16px] font-medium outline-none + placeholder:text-[#777e90] + " + /> +
    + ); +} diff --git a/src/components/customs/custom-table.tsx b/src/components/customs/custom-table.tsx new file mode 100644 index 0000000..96ff1bc --- /dev/null +++ b/src/components/customs/custom-table.tsx @@ -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 { + 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 { + data: T[]; + column: ColumnType[]; + fixedHeader?: boolean; + activeHeader?: boolean; + handleCheckedAll?: (e: React.ChangeEvent) => void; + isCheckedAll?: boolean; + handleCheckedRow?: (e: React.ChangeEvent, row: T) => void; + handleIsCheckedRow?: (row: T) => boolean; + rowKey: (row: T) => React.Key; + getChildren?: (row: T) => T[] | undefined; + useIndexPathAsKey?: boolean; +} + +/* ================= COMPONENT ================= */ +export default function Table({ + data, + column, + fixedHeader, + activeHeader, + handleCheckedAll, + isCheckedAll, + handleCheckedRow, + handleIsCheckedRow, + rowKey, + getChildren, + useIndexPathAsKey, +}: PropsTable) { + const tableRef = useRef(null); + const thRefs = useRef<(HTMLTableCellElement | null)[]>([]); + + const [expandedRows, setExpandedRows] = useState([]); + 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 ( + + + {getChildren && ( + + {!!children?.length && ( + + )} + + )} + + {column.map((col, idx) => ( + +
    + {col.checkBox && ( + handleCheckedRow?.(e, row)} + checked={handleIsCheckedRow?.(row) ?? false} + /> + )} + {col.render(row, i, currentPath)} +
    + + ))} + + + {expanded && children && renderRows(children, currentPath)} +
    + ); + }); + + /* ================= UI ================= */ + return ( +
    table>thead]:sticky [&>table>thead]:top-0", + activeHeader && "[&>table>thead]:bg-[#F4F7FA]", + )} + > + + + + {getChildren && + ); + })} + + + + {renderRows(sortedData)} +
    } + + {column.map((col, i) => { + const isActive = col.sortTable === sortConfig.key; + + return ( + { + 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", + )} + > +
    + {col.checkBox && ( + + )} + + {col.title} + + {col.sortTable && + (isActive ? ( + sortConfig.direction === "asc" ? ( + + ) : sortConfig.direction === "desc" ? ( + + ) : ( + + ) + ) : ( + + ))} +
    +
    +
    + ); +} diff --git a/src/components/customs/custom-toast.tsx b/src/components/customs/custom-toast.tsx new file mode 100644 index 0000000..80a244c --- /dev/null +++ b/src/components/customs/custom-toast.tsx @@ -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 ( +
    + +
    + ); +} diff --git a/src/components/customs/slider-props.tsx b/src/components/customs/slider-props.tsx new file mode 100644 index 0000000..4acc218 --- /dev/null +++ b/src/components/customs/slider-props.tsx @@ -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 ( +
    + {/* Header */} + {(label || showValue) && ( +
    + {label && ( + {label} + )} + + {showValue && ( + + {renderValue ? renderValue(value) : value} + + )} +
    + )} + + {/* Slider */} + 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}%)`, + }} + /> +
    + ); +}; + +export default CustomSlider; diff --git a/src/components/layouts/BaseLayout/BaseLayout.tsx b/src/components/layouts/BaseLayout/BaseLayout.tsx new file mode 100644 index 0000000..7c4b9fe --- /dev/null +++ b/src/components/layouts/BaseLayout/BaseLayout.tsx @@ -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({}); + +const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => { + const [showFull, setShowFull] = React.useState(false); + const [openMenuMobile, setOpenMenuMobile] = React.useState(false); + return ( + // + +
    + {/* OVERLAY cho Mobile */} +
    setOpenMenuMobile(false)} + /> + + {/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */} + + + {/* HEADER */} +
    +
    +
    + + {/* MAIN CONTENT */} +
    + {children} +
    +
    + + // + ); +}; + +export default BaseLayout; diff --git a/src/components/layouts/BaseLayout/componets/Header/Header.tsx b/src/components/layouts/BaseLayout/componets/Header/Header.tsx new file mode 100644 index 0000000..3d3f3d4 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/Header/Header.tsx @@ -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 ( +
    +
    + {/* Nút bấm Mobile: Mở menu mobile */} +
    context?.setOpenMenuMobile?.(true)} + > + menu +
    + {/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */} +
    context?.setShowFull?.(!context?.showFull)} + > + menu +
    + {breadcrumb ? ( + breadcrumb + ) : ( +

    {title}

    + )} +
    + +
    +
    + +
    + +
    setOpenProfile(!openProfile)} + > +
    + avatar +
    +

    + {infoUser?.fullname || "User admin"} +

    + + + {openProfile && ( +
    + setOpenProfile(false)} /> +
    + )} +
    +
    +
    + ); +}; + +export default Header; diff --git a/src/components/layouts/BaseLayout/componets/Header/index.ts b/src/components/layouts/BaseLayout/componets/Header/index.ts new file mode 100644 index 0000000..2764567 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/Header/index.ts @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/components/layouts/BaseLayout/componets/Header/interface/index.ts b/src/components/layouts/BaseLayout/componets/Header/interface/index.ts new file mode 100644 index 0000000..bd52b48 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/Header/interface/index.ts @@ -0,0 +1,4 @@ +export interface PropsHeader { + title: string; + breadcrumb?: React.ReactNode; +} diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx b/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx new file mode 100644 index 0000000..157fb72 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx @@ -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 ( +
    + {/* PROFILE */} + + +
    +

    Thông tin cá nhân

    +

    Chi tiết tài khoản

    +
    + +
    + + {/* CHANGE PASSWORD */} + + +
    +

    Đổi mật khẩu

    +

    Thay đổi mật khẩu

    +
    + +
    + + {/* LOGOUT */} +
    { + setOpenLogout(true); + onClose(); + }} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition" + > + +
    +

    Đăng xuất

    +

    Đăng xuất khỏi hệ thống

    +
    +
    + + {/* DIALOG */} + 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" + /> +
    + ); +}; + +export default MenuProfile; diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts b/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts new file mode 100644 index 0000000..12ae6c8 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts @@ -0,0 +1 @@ +export { default } from "./MenuProfile"; diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts b/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts new file mode 100644 index 0000000..88f466b --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts @@ -0,0 +1,3 @@ +export interface PropsMenuProfile { + onClose: () => void; +} diff --git a/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx b/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx new file mode 100644 index 0000000..6733296 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx @@ -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 ( +
    + + Logo +

    + Quản lý +

    + +
    + {Menus.map((menu, i) => ( +
    + {/* TITLE */} +
    + {menu.title} +
    + +
    + {menu.group.map((tab, y) => { + const isActive = checkActive(tab.pathActive); + + return ( + + +

    {tab.title}

    + + ); + })} +
    +
    + ))} +
    +
    + ); +}; + +export default Navbar; diff --git a/src/components/layouts/BaseLayout/componets/Navbar/index.ts b/src/components/layouts/BaseLayout/componets/Navbar/index.ts new file mode 100644 index 0000000..8d95d66 --- /dev/null +++ b/src/components/layouts/BaseLayout/componets/Navbar/index.ts @@ -0,0 +1 @@ +export { default } from "./Navbar"; diff --git a/src/components/layouts/BaseLayout/index.ts b/src/components/layouts/BaseLayout/index.ts new file mode 100644 index 0000000..cd391ad --- /dev/null +++ b/src/components/layouts/BaseLayout/index.ts @@ -0,0 +1 @@ +export { default } from "./BaseLayout"; diff --git a/src/components/layouts/BaseLayout/interface/index.ts b/src/components/layouts/BaseLayout/interface/index.ts new file mode 100644 index 0000000..637b24c --- /dev/null +++ b/src/components/layouts/BaseLayout/interface/index.ts @@ -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; +} diff --git a/src/components/layouts/ChartWrapper/ChartWrapper.tsx b/src/components/layouts/ChartWrapper/ChartWrapper.tsx new file mode 100644 index 0000000..2545a63 --- /dev/null +++ b/src/components/layouts/ChartWrapper/ChartWrapper.tsx @@ -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 ( +
    + {isEmpty ? ( +
    + Ảnh dữ liệu trống + {/* nếu muốn hiện message thì bật dòng dưới */} + {/*

    {message}

    */} +
    + ) : ( + children + )} +
    + ); +}; + +export default ChartWrapper; diff --git a/src/components/layouts/ChartWrapper/index.ts b/src/components/layouts/ChartWrapper/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/layouts/ChartWrapper/interface/index.ts b/src/components/layouts/ChartWrapper/interface/index.ts new file mode 100644 index 0000000..acf4283 --- /dev/null +++ b/src/components/layouts/ChartWrapper/interface/index.ts @@ -0,0 +1,5 @@ +export interface PropsChartWrapper { + children: React.ReactNode; + isEmpty?: boolean; + message?: string; +} diff --git a/src/components/layouts/GridColumn/GridColumn.tsx b/src/components/layouts/GridColumn/GridColumn.tsx new file mode 100644 index 0000000..68d696b --- /dev/null +++ b/src/components/layouts/GridColumn/GridColumn.tsx @@ -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 ( +
    + {children} +
    + ); +}; + +export default GridColumn; diff --git a/src/components/layouts/GridColumn/index.ts b/src/components/layouts/GridColumn/index.ts new file mode 100644 index 0000000..6b7d9a9 --- /dev/null +++ b/src/components/layouts/GridColumn/index.ts @@ -0,0 +1 @@ +export { default } from "./GridColumn"; diff --git a/src/components/layouts/GridColumn/interface/index.ts b/src/components/layouts/GridColumn/interface/index.ts new file mode 100644 index 0000000..9d1c8f9 --- /dev/null +++ b/src/components/layouts/GridColumn/interface/index.ts @@ -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; +} diff --git a/src/components/layouts/LayoutAuth‎/LayoutAuth‎.tsx b/src/components/layouts/LayoutAuth‎/LayoutAuth‎.tsx new file mode 100644 index 0000000..81f6949 --- /dev/null +++ b/src/components/layouts/LayoutAuth‎/LayoutAuth‎.tsx @@ -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 ( + +
    + {/* BACKGROUND */} +
    + + {/* MAIN */} +
    + {children} +
    +
    + + ); +}; + +export default LayoutAuth; diff --git a/src/components/layouts/LayoutAuth‎/index.ts b/src/components/layouts/LayoutAuth‎/index.ts new file mode 100644 index 0000000..9756f0a --- /dev/null +++ b/src/components/layouts/LayoutAuth‎/index.ts @@ -0,0 +1 @@ +export { default } from "./LayoutAuth‎"; diff --git a/src/components/layouts/LayoutPages/LayoutPages.tsx b/src/components/layouts/LayoutPages/LayoutPages.tsx new file mode 100644 index 0000000..b263476 --- /dev/null +++ b/src/components/layouts/LayoutPages/LayoutPages.tsx @@ -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 ( + <> +
    + {listPages.map((item, i) => { + const isActive = checkActive(item.url); + return ( + { + 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", + )} + > + {" "} +
    +
    +

    {item.title}

    +
    + + ); + })} +
    + {/* MAIN */} +
    {children}
    + + ); +}; + +export default LayoutPages; diff --git a/src/components/layouts/LayoutPages/index.ts b/src/components/layouts/LayoutPages/index.ts new file mode 100644 index 0000000..14b40c4 --- /dev/null +++ b/src/components/layouts/LayoutPages/index.ts @@ -0,0 +1 @@ +export { default } from "./LayoutPages"; diff --git a/src/components/layouts/LayoutPages/interface/index.ts b/src/components/layouts/LayoutPages/interface/index.ts new file mode 100644 index 0000000..c47ef62 --- /dev/null +++ b/src/components/layouts/LayoutPages/interface/index.ts @@ -0,0 +1,8 @@ +export interface PropsLayoutPages { + children: React.ReactNode; + listPages: { + title: string; + url: string; + color: string; + }[]; +} diff --git a/src/components/page/Home/MainPageHome/MainPageHome.tsx b/src/components/page/Home/MainPageHome/MainPageHome.tsx new file mode 100644 index 0000000..59c3551 --- /dev/null +++ b/src/components/page/Home/MainPageHome/MainPageHome.tsx @@ -0,0 +1,8 @@ +"use client"; +import React from "react"; + +const MainPageHome = () => { + return
    MainPageHome
    ; +}; + +export default MainPageHome; diff --git a/src/components/page/Home/MainPageHome/index.ts b/src/components/page/Home/MainPageHome/index.ts new file mode 100644 index 0000000..310c0db --- /dev/null +++ b/src/components/page/Home/MainPageHome/index.ts @@ -0,0 +1 @@ +export { default } from "./MainPageHome"; diff --git a/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx b/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx new file mode 100644 index 0000000..8632ba1 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx @@ -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.EMAIL, + ); + + const [form, setForm] = useState({ + email: "", + otp: "", + password: "", + rePassword: "", + }); + + return ( +
    + {/* TITLE */} +

    + Quên mật khẩu +

    + + {/* DESCRIPTION */} +

    + 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! +

    + + {/* FORM */} +
    + + {type === TYPE_FORGOT_PASWORD.EMAIL && } + + {type === TYPE_FORGOT_PASWORD.PASSWORD && } + +
    +
    + ); +} diff --git a/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx b/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx new file mode 100644 index 0000000..fc2557a --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx @@ -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 ( + + + + {/* INPUT EMAIL */} + + Email + * + + } + placeholder="Nhập email" + type="text" + name="email" + isEmail + onClean + isRequired + isBlur + icon={} + /> + + {/* BUTTON GROUP */} +
    + {/* BUTTON SUBMIT */} + + Lấy lại mật khẩu + + + {/* LINE */} +
    + + {/* LOGIN BUTTON */} + router.push(PATH.LOGIN)} + > + Đăng nhập ngay + +
    + + {/* POPUP OTP */} + + + + + ); +} + +export default FormEmail; diff --git a/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts b/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts new file mode 100644 index 0000000..28c8163 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts @@ -0,0 +1 @@ +export { default } from "./FormEmail"; diff --git a/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx b/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx new file mode 100644 index 0000000..09b0fb8 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx @@ -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(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 ( +
    + {/* LOADING */} + + + {/* TITLE */} +

    + Xác thực mã OTP +

    + + {/* DESCRIPTION */} +

    + Một mã xác thực đã được gửi cho bạn qua địa chỉ email: + {obfuscateEmail(form.email!)} +

    + + {/* FORM */} +
    + {/* LABEL */} +

    + Nhập mã OTP +

    + + {/* INPUT OTP */} +
    + +
    + + {/* COUNTDOWN */} +

    + Bạn chưa nhận được mã. + {countDown > 0 ? ( + + Gửi lại OTP ({fancyTimeFormat(countDown)}) + + ) : ( + + Gửi lại OTP + + )} +

    +
    + + {/* BUTTON */} +
    + + Xác thực Email + +
    + + {/* CLOSE */} + +
    + ); +} + +export default FormOTP; diff --git a/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts b/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts new file mode 100644 index 0000000..2b61278 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts @@ -0,0 +1 @@ +export { default } from "./FormOTP"; diff --git a/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx b/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx new file mode 100644 index 0000000..fa111f2 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx @@ -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( + 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 ( + + + + {/* PASSWORD */} + + Mật khẩu mới + * + + } + placeholder="Nhập mật khẩu mới" + type="password" + name="password" + onClean + isRequired + isBlur + showDone + icon={} + /> + + {/* RE PASSWORD */} +
    + + Xác nhận mật khẩu mới + * + + } + placeholder="Xác nhận mật khẩu mới" + type="password" + name="rePassword" + valueConfirm={form.password} + onClean + isRequired + isBlur + showDone + icon={} + /> +
    + + {/* BUTTON */} +
    + + {({ isDone }) => ( + + Lấy lại mật khẩu + + )} + +
    +
    + ); +} diff --git a/src/components/page/auth/MainForgotPassword/components/FormPassword/index.ts b/src/components/page/auth/MainForgotPassword/components/FormPassword/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/page/auth/MainForgotPassword/context/index.ts b/src/components/page/auth/MainForgotPassword/context/index.ts new file mode 100644 index 0000000..8878104 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/context/index.ts @@ -0,0 +1,16 @@ +import { createContext, Dispatch, SetStateAction } from "react"; +import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "../interface"; + +export interface IContextForgotPassword { + form: IFormForgotPassword; + setForm: Dispatch>; + type: number; + setType: Dispatch>; +} + +export const ContextForgotPassword = createContext({ + form: { email: "", otp: "", password: "", rePassword: "" }, + setForm: () => null, + type: TYPE_FORGOT_PASWORD.EMAIL, + setType: () => null, +}); diff --git a/src/components/page/auth/MainForgotPassword/index.ts b/src/components/page/auth/MainForgotPassword/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/page/auth/MainForgotPassword/interface/index.ts b/src/components/page/auth/MainForgotPassword/interface/index.ts new file mode 100644 index 0000000..f531e95 --- /dev/null +++ b/src/components/page/auth/MainForgotPassword/interface/index.ts @@ -0,0 +1,11 @@ +export interface IFormForgotPassword { + email: string; + otp: string; + password: string; + rePassword: string; +} + +export enum TYPE_FORGOT_PASWORD { + EMAIL = 1, + PASSWORD, +} diff --git a/src/components/page/auth/MainLogin/MainLogin.tsx b/src/components/page/auth/MainLogin/MainLogin.tsx new file mode 100644 index 0000000..8e77298 --- /dev/null +++ b/src/components/page/auth/MainLogin/MainLogin.tsx @@ -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 ( +
    + + + + {/* TITLE */} +

    + Đăng nhập +

    + +

    + Chào mừng bạn đến với hệ thống quản lý +

    + + {/* FORM */} +
    + {/* USERNAME */} + + Tài khoản + * + + } + placeholder="Tài khoản" + type="text" + name="username" + onClean + isRequired + isBlur + showDone + icon={} + /> + + {/* PASSWORD */} +
    + + Mật khẩu + * + + } + placeholder="Mật khẩu" + type="password" + name="password" + onClean + isRequired + isBlur + showDone + icon={} + /> +
    + + {/* REMEMBER PASSWORD */} +
    + + store.dispatch(setRememberPassword(!isRememberPassword)) + } + className=" + h-5 + w-5 + cursor-pointer + accent-[#0011AB] + " + /> + + +
    + + {/* BUTTONS */} +
    + {/* LOGIN */} + + + {/* LINE */} +
    + + {/* FORGOT PASSWORD */} + +
    +
    + +
    + ); +} diff --git a/src/components/page/auth/MainLogin/index.ts b/src/components/page/auth/MainLogin/index.ts new file mode 100644 index 0000000..efa3c86 --- /dev/null +++ b/src/components/page/auth/MainLogin/index.ts @@ -0,0 +1 @@ +export { default } from "./MainLogin"; diff --git a/src/components/protected/LoadingTopBar/LoadingTopBar.tsx b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx new file mode 100644 index 0000000..ea88219 --- /dev/null +++ b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx @@ -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; diff --git a/src/components/protected/LoadingTopBar/index.ts b/src/components/protected/LoadingTopBar/index.ts new file mode 100644 index 0000000..6219428 --- /dev/null +++ b/src/components/protected/LoadingTopBar/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadingTopBar"; diff --git a/src/components/protected/RequiredAuth/index.tsx b/src/components/protected/RequiredAuth/index.tsx new file mode 100644 index 0000000..011c0ed --- /dev/null +++ b/src/components/protected/RequiredAuth/index.tsx @@ -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
    ; +} diff --git a/src/components/protected/RequiredLogout/index.tsx b/src/components/protected/RequiredLogout/index.tsx new file mode 100644 index 0000000..cc8b369 --- /dev/null +++ b/src/components/protected/RequiredLogout/index.tsx @@ -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
    ; +} + +export default RequiredLogout; diff --git a/src/components/protected/SplashScreen/SplashScreen.tsx b/src/components/protected/SplashScreen/SplashScreen.tsx new file mode 100644 index 0000000..a78c6a1 --- /dev/null +++ b/src/components/protected/SplashScreen/SplashScreen.tsx @@ -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 ( +
    +
    + +
    +
    + ); +} + +export default SplashScreen; diff --git a/src/components/protected/SplashScreen/index.ts b/src/components/protected/SplashScreen/index.ts new file mode 100644 index 0000000..076d132 --- /dev/null +++ b/src/components/protected/SplashScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./SplashScreen"; diff --git a/src/components/utils/FormCustom/FormCustom.tsx b/src/components/utils/FormCustom/FormCustom.tsx new file mode 100644 index 0000000..36c61cf --- /dev/null +++ b/src/components/utils/FormCustom/FormCustom.tsx @@ -0,0 +1,70 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; + +import { PropsFormCustom } from "./interface"; +import { ContextFormCustom } from "./contexts"; + +function FormCustom>({ + form, + setForm, + onSubmit, + children, +}: PropsFormCustom) { + // 👉 tạo object error ban đầu + const initialError = useMemo(() => { + return Object.fromEntries( + Object.keys(form).map((key) => [key, null]), + ) as Record; + }, [form]); + + const [countValidate, setCountValidate] = useState(0); + const [errorText, setErrorText] = + useState>(initialError); + + const [validate, setValidate] = useState | 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) => { + e.preventDefault(); + + if (isDone) { + onSubmit?.(); + } else { + setCountValidate((prev) => prev + 1); + } + }, + [isDone, onSubmit], + ); + + return ( + +
    + {children} +
    +
    + ); +} + +export default FormCustom; diff --git a/src/components/utils/FormCustom/components/InputForm/InputForm.tsx b/src/components/utils/FormCustom/components/InputForm/InputForm.tsx new file mode 100644 index 0000000..0d26197 --- /dev/null +++ b/src/components/utils/FormCustom/components/InputForm/InputForm.tsx @@ -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>(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) => { + 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 ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* INPUT WRAPPER */} +
    + {/* ICON */} + {icon && ( +
    + {icon} +
    + )} + + {/* INPUT */} + + + {/* UNIT */} + {unit ? ( +
    + {unit} +
    + ) : ( +
    + {/* CLEAN */} + {onClean && !!currentValue && ( + + )} + + {/* DONE */} + {!isPassword && + showDone && + isDone && + !errorText?.[name] && + !!currentValue && } + + {/* PASSWORD */} + {isPassword && ( + + )} +
    + )} +
    + + {/* ERROR */} + {errorText?.[name] && ( +

    + {errorText[name]} +

    + )} + + {/* NOTE */} + {note && ( +

    + {note} +

    + )} +
    + ); +} + +export default InputForm; diff --git a/src/components/utils/FormCustom/components/InputForm/index.ts b/src/components/utils/FormCustom/components/InputForm/index.ts new file mode 100644 index 0000000..2d6bc1c --- /dev/null +++ b/src/components/utils/FormCustom/components/InputForm/index.ts @@ -0,0 +1 @@ +export { default } from "./InputForm"; diff --git a/src/components/utils/FormCustom/components/InputForm/interface/index.ts b/src/components/utils/FormCustom/components/InputForm/interface/index.ts new file mode 100644 index 0000000..7942454 --- /dev/null +++ b/src/components/utils/FormCustom/components/InputForm/interface/index.ts @@ -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; +} diff --git a/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx b/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx new file mode 100644 index 0000000..791eb36 --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx @@ -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({ + label, + placeholder = "Chọn...", + isSearch = true, + readOnly, + value, + options = [], // ✅ FIX NULL + onClean, + onSelect, + getOptionLabel, + getOptionValue, +}: PropsSelectForm) { + const refWrapper = useRef(null); + const refInput = useRef(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 ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* SELECT BOX */} +
    !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 */} +

    + {selectedOption ? getOptionLabel(selectedOption) : placeholder} +

    + + {/* CLEAR */} + {onClean && !!value && !readOnly && ( +
    { + e.stopPropagation(); + onClean(); + }} + className="mr-2 hover:opacity-70" + > + +
    + )} + + {/* ARROW */} + +
    + + {/* DROPDOWN */} + {open && ( +
    + {/* SEARCH */} + {isSearch && ( +
    + + setKeyword(e.target.value)} + className="flex-1 bg-transparent outline-none text-sm" + /> +
    + )} + + {/* LIST */} + {filteredOptions.length > 0 ? ( +
    + {filteredOptions.map((opt) => { + const active = getOptionValue(opt) === value; + + return ( +
    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)} +
    + ); + })} +
    + ) : ( +
    + empty +

    Danh sách lựa chọn rỗng!

    +
    + )} +
    + )} +
    + ); +} + +export default SelectForm; diff --git a/src/components/utils/FormCustom/components/SelectForm/index.ts b/src/components/utils/FormCustom/components/SelectForm/index.ts new file mode 100644 index 0000000..0470c2e --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectForm/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectForm"; diff --git a/src/components/utils/FormCustom/components/SelectForm/interface/index.ts b/src/components/utils/FormCustom/components/SelectForm/interface/index.ts new file mode 100644 index 0000000..d674375 --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectForm/interface/index.ts @@ -0,0 +1,15 @@ +export interface PropsSelectForm { + 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; +} diff --git a/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx b/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx new file mode 100644 index 0000000..1d4cec2 --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx @@ -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({ + text, + label, + placeholder, + isSearch = true, + showSelectedItems = false, + readOnly, + disabledItems = [], + selectedItems = [], + options = [], + title, + onClickSelect, + setSelectedItems, + getOptionLabel, + getOptionValue, + onRemove, + action, + renderOption, + selectedItemFields, + selectedItemFieldLabels, +}: PropsSelectMany) { + const refInputSearch = useRef(null); + + const [keyword, setKeyword] = useState(""); + const [isFocus, setIsFocus] = useState(false); + const [listDataTerm, setListDataTerm] = + useState>(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 ( + +
    + {label && ( + + )} + + {/* Select box */} +
    !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", + )} + > +

    + {selectedItems.length > 0 + ? `Đã chọn ${selectedItems.length} ${text}` + : placeholder} +

    + + +
    + + {/* Selected chips */} + {showSelectedItems === "default" && ( +
    + {selectedItems + .map((v) => options.find((o) => getOptionValue(o) === v)) + .filter(Boolean) + .map((item) => { + const value = getOptionValue(item!); + const disabled = disabledItems.includes(value); + + return ( +
    + {getOptionLabel(item!)} + handleRemoveSelected(value)} + /> +
    + ); + })} +
    + )} +
    + + {/* Popup */} + +
    + {/* Title */} +

    {title || "Danh sách"}

    + + {/* Search */} + {isSearch && ( +
    + + setKeyword(e.target.value)} + placeholder="Tìm kiếm..." + className="flex-1 outline-none bg-transparent text-sm" + /> +
    + )} + + {/* Select all */} + {filteredOptions.length > 0 && ( +
    +
    + + setListDataTerm( + e.target.checked ? options.map(getOptionValue) : [], + ) + } + className="w-5 h-5 accent-blue-500" + /> + Chọn tất cả +
    + +

    + Đã chọn:{" "} + {listDataTerm.length} +

    +
    + )} + + {/* Options */} + {filteredOptions.length > 0 ? ( +
    + {filteredOptions.map((opt) => { + const value = getOptionValue(opt); + const checked = listDataTerm.includes(value); + + return ( +
    + handleOptionClick(opt)} + disabled={disabledItems.includes(value)} + className="w-5 h-5 accent-blue-500" + /> + +
    + ); + })} +
    + ) : ( +
    + empty +

    Danh sách lựa chọn rỗng!

    +
    + )} + + {/* Buttons */} +
    + + Hủy bỏ + + + + Xác nhận + +
    + + {/* Close */} +
    + +
    +
    +
    +
    + ); +} + +export default SelectMany; diff --git a/src/components/utils/FormCustom/components/SelectMany/index.ts b/src/components/utils/FormCustom/components/SelectMany/index.ts new file mode 100644 index 0000000..ed5c741 --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectMany/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectMany"; diff --git a/src/components/utils/FormCustom/components/SelectMany/interface/index.ts b/src/components/utils/FormCustom/components/SelectMany/interface/index.ts new file mode 100644 index 0000000..35749c3 --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectMany/interface/index.ts @@ -0,0 +1,27 @@ +import { Dispatch, SetStateAction } from "react"; + +export interface PropsSelectMany { + text: string; + placeholder: string; + label?: string | React.ReactNode; + + isSearch?: boolean; + readOnly?: boolean; + showSelectedItems?: false | "default" | "input"; + + disabledItems?: Array; + selectedItems: Array; + options: OptionType[]; + title?: string; + + onClickSelect?: () => void; + setSelectedItems?: Dispatch>>; + getOptionLabel: (option: OptionType) => string | React.ReactNode; + getOptionValue: (option: OptionType) => string | number; + onRemove?: (item: string | number) => void; + + action?: React.ReactNode; + renderOption?: (option: OptionType) => React.ReactNode; + selectedItemFields?: (keyof OptionType)[]; + selectedItemFieldLabels?: Record; +} diff --git a/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx b/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx new file mode 100644 index 0000000..60033ef --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx @@ -0,0 +1,206 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { X, Search } from "lucide-react"; + +import Image from "next/image"; + +import { removeVietnameseTones } from "@/common/funcs/optionConvert"; +import icons from "@/constant/images/icons"; +import Popup from "@/components/customs/custom-popup"; +import CustomButton from "@/components/customs/custom-button"; +import { PropsSelectSingle } from "./interface"; + +function SelectSingle({ + text = "", + label, + placeholder = "Chọn 1 giá trị", + isSearch = true, + readOnly, + disabledItems = [], + selectedItem, + options, + title, + action, + getOptionLabel, + getOptionValue, + setSelectedItem, + onClickSelect, + renderOption, +}: PropsSelectSingle) { + const refInputSearch = useRef(null); + + const [keyword, setKeyword] = useState(""); + const [isFocus, setIsFocus] = useState(false); + const [selectedTemp, setSelectedTemp] = useState( + null, + ); + + useEffect(() => { + if (isFocus && refInputSearch.current) { + setTimeout(() => refInputSearch.current?.focus(), 0); + } + }, [isFocus]); + + const handlerFocused = () => { + if (onClickSelect) { + onClickSelect(); + return; + } + + if (readOnly) return; + + // sync dữ liệu tại đây + setSelectedTemp(selectedItem ?? null); + + setIsFocus(true); + }; + + const handleSelect = (value: string | number) => { + if (!disabledItems.includes(value)) { + setSelectedTemp(value); + } + }; + + const handleSave = () => { + setSelectedItem?.(selectedTemp); + setIsFocus(false); + }; + + const handleClose = () => { + setIsFocus(false); + setKeyword(""); + setSelectedTemp(selectedItem ?? null); + }; + + const selectedLabel = (() => { + const selectedOption = options.find( + (opt) => getOptionValue(opt) === selectedItem, + ); + return selectedOption + ? getOptionLabel(selectedOption) + : `Đã chọn 1 ${text}`; + })(); + + const filteredOptions = options.filter((opt) => + removeVietnameseTones(getOptionLabel(opt)).includes( + removeVietnameseTones(keyword), + ), + ); + + return ( +
    + {label && ( + + )} + + {/* Select box */} +
    +

    + {selectedItem ? selectedLabel : placeholder} +

    +
    + + {/* Popup */} + +
    + {/* Header */} +
    +

    + {title || "Chọn 1 giá trị"} +

    + + {isSearch && ( +
    +
    + + setKeyword(e.target.value)} + placeholder="Tìm kiếm..." + className="flex-1 ml-2 outline-none bg-transparent" + /> +
    + + {action &&
    {action}
    } +
    + )} +
    + + {/* List */} + {filteredOptions.length > 0 ? ( +
    + {filteredOptions.map((opt) => { + const value = getOptionValue(opt); + return ( +
    + handleSelect(value)} + disabled={disabledItems.includes(value)} + className="w-5 h-5 accent-[#0011AB]" + /> + +
    + ); + })} +
    + ) : ( +
    + empty +

    Danh sách lựa chọn rỗng!

    +
    + )} + + {/* Footer */} +
    + + Hủy bỏ + + + + Xác nhận + +
    + + {/* Close */} +
    + +
    +
    +
    +
    + ); +} + +export default SelectSingle; diff --git a/src/components/utils/FormCustom/components/SelectSingle/index.ts b/src/components/utils/FormCustom/components/SelectSingle/index.ts new file mode 100644 index 0000000..5e9f46e --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectSingle/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectSingle"; diff --git a/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts b/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts new file mode 100644 index 0000000..99c80ab --- /dev/null +++ b/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts @@ -0,0 +1,17 @@ +export interface PropsSelectSingle { + text?: string; + label?: React.ReactNode; + placeholder?: string; + isSearch?: boolean; + readOnly?: boolean; + disabledItems?: (string | number)[]; + selectedItem?: string | number | null; + options: OptionType[]; + title?: string; + action?: React.ReactNode; + getOptionLabel: (opt: OptionType) => string; + getOptionValue: (opt: OptionType) => string | number; + setSelectedItem?: (value: string | number | null) => void; + renderOption?: (option: OptionType) => React.ReactNode; + onClickSelect?: () => void; +} diff --git a/src/components/utils/FormCustom/components/TextArea/TextArea.tsx b/src/components/utils/FormCustom/components/TextArea/TextArea.tsx new file mode 100644 index 0000000..85f19d2 --- /dev/null +++ b/src/components/utils/FormCustom/components/TextArea/TextArea.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { useContext, useEffect, useState } from "react"; +import clsx from "clsx"; + +import { PropsTextArea } from "./interface"; +import { ContextFormCustom, IContextFormCustom } from "../../contexts"; + +function TextArea({ + name, + value, + placeholder, + label, + isBlur = true, + showDone, + readOnly, + max, + min, + isRequired, + textRequired, +}: PropsTextArea) { + const [isFocus, setIsFocus] = useState(false); + + const { + form, + countValidate, + errorText, + isDone, + setForm, + setValidate, + setErrorText, + } = useContext>(ContextFormCustom); + + // ========================= + // VALIDATE + // ========================= + + const handleValidate = (): boolean => { + const currentValue = `${form[name] || ""}`.trim(); + + if (isRequired && currentValue === "") return false; + + if (min && currentValue.length < min) return false; + + if (max && currentValue.length > max) return false; + + return true; + }; + + // ========================= + // ERROR MESSAGE + // ========================= + + const handleSetMessage = () => { + const currentValue = `${form[name] || ""}`.trim(); + + setErrorText((prev) => ({ + ...prev, + [name]: null, + })); + + if (isRequired && currentValue === "") { + return setErrorText((prev) => ({ + ...prev, + [name]: textRequired || "Vui lòng nhập trường này", + })); + } + + if (max && currentValue.length > max) { + return setErrorText((prev) => ({ + ...prev, + [name]: `Nhập tối đa ${max} kí tự`, + })); + } + + if (min && currentValue.length < min) { + return setErrorText((prev) => ({ + ...prev, + [name]: `Nhập tối thiểu ${min} kí tự`, + })); + } + }; + + // ========================= + // EFFECTS + // ========================= + + useEffect(() => { + if (countValidate > 0) { + handleSetMessage(); + } + }, [countValidate]); + + useEffect(() => { + setValidate((prev) => ({ + ...prev, + [name]: handleValidate(), + })); + }, [form[name]]); + + // ========================= + // EVENTS + // ========================= + + const handlerFocused = () => { + setIsFocus(true); + + setErrorText((prev) => ({ + ...prev, + [name]: null, + })); + }; + + const handlerBlur = () => { + setIsFocus(false); + + if (isBlur) { + handleSetMessage(); + + setValidate((prev) => ({ + ...prev, + [name]: handleValidate(), + })); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const { value } = e.target; + + setForm((prev: any) => ({ + ...prev, + [name]: value, + })); + }; + + return ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* TEXTAREA */} +