one-day-update-vna

This commit is contained in:
TuanVT
2026-07-21 16:46:47 +07:00
parent cc1214a59f
commit 47f859c298
175 changed files with 15135 additions and 87 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+33
View File
@@ -0,0 +1,33 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Create new component",
"type": "shell",
"command": "mkdir -p '${workspaceFolder}/src/components/${input:type}/${input:dirName}/interfaces' && touch '${workspaceFolder}/src/components/${input:type}/${input:dirName}/interfaces/index.ts' '${workspaceFolder}/src/components/${input:type}/${input:dirName}/${input:dirName}.tsx' '${workspaceFolder}/src/components/${input:type}/${input:dirName}/${input:dirName}.module.scss' '${workspaceFolder}/src/components/${input:type}/${input:dirName}/index.ts' && echo 'export interface Props${input:dirName} {}' >> '${workspaceFolder}/src/components/${input:type}/${input:dirName}/interfaces/index.ts' && echo 'export { default } from \"./${input:dirName}\";' >> '${workspaceFolder}/src/components/${input:type}/${input:dirName}/index.ts' && echo 'import React from \"react\"; \n\nimport {Props${input:dirName}} from \"./interfaces\"; \nimport styles from \"./${input:dirName}.module.scss\"; \n\nfunction ${input:dirName}({}:Props${input:dirName}) { \n\treturn <div>${input:dirName}</div>; \n} \n\nexport default ${input:dirName};' >> '${workspaceFolder}/src/components/${input:type}/${input:dirName}/${input:dirName}.tsx'",
"problemMatcher": [],
"presentation": {
"echo": false,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
}
}
],
"inputs": [
{
"type": "promptString",
"id": "type",
"description": "Where do you want to create?",
"default": "pages/"
},
{
"type": "promptString",
"id": "dirName",
"description": "Complete my folder name",
"default": "tsx folder to create"
}
]
}
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+7
View File
@@ -13,6 +13,13 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
{
rules: {
"no-var": "off",
"prefer-const": "off",
"@typescript-eslint/no-explicit-any": "off", // tắt lỗi any
},
},
]);
export default eslintConfig;
+4 -1
View File
@@ -1,8 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+22 -1
View File
@@ -9,15 +9,36 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"clsx": "^2.1.1",
"countries-and-timezones": "^3.9.0",
"countries-list": "^3.4.1",
"iconsax-react": "^0.0.8",
"lottie-react": "^2.4.1",
"md5": "^2.3.0",
"moment": "^2.30.1",
"next": "16.2.9",
"nprogress": "^0.2.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-icons": "^5.7.0",
"react-moment": "^2.0.2",
"react-redux": "^9.3.0",
"react-toastify": "^11.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/md5": "^2.3.6",
"@types/moment": "^2.13.0",
"@types/node": "^20",
"@types/nprogress": "^0.2.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-redux": "^7.1.34",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.9",
+43
View File
@@ -0,0 +1,43 @@
{
"vna_loading_animation": {
"theme": "Vietnam Airlines Premium",
"background": {
"type": "gradient",
"colors": ["#004b5e", "#007b85"],
"direction": "vertical"
},
"aircraft": {
"asset_id": "{{DATA:IMAGE:IMAGE_3}}",
"style": "Photo-realistic",
"brand": "Vietnam Airlines",
"livery": "Teal body, Gold Lotus tail, Gold stripe",
"animation": {
"type": "bobbing",
"duration": "3s",
"timing": "ease-in-out",
"loop": true
}
},
"loading_bar": {
"color": "#c89f3c",
"track_color": "rgba(255, 255, 255, 0.2)",
"animation": {
"type": "progress",
"duration": "4s",
"loop": true
}
},
"atmosphere": {
"clouds": {
"color": "white",
"opacity": 0.3,
"animation": "parallax-scrolling"
},
"speed_lines": {
"color": "white",
"opacity": 0.4,
"animation": "fast-streak"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

After

Width:  |  Height:  |  Size: 220 B

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

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

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

After

Width:  |  Height:  |  Size: 619 B

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

After

Width:  |  Height:  |  Size: 411 B

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

After

Width:  |  Height:  |  Size: 563 B

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

After

Width:  |  Height:  |  Size: 732 B

+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailAirlines from "@/components/page/airlines/DetailAirlines";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chi Tiết">
<Suspense fallback={<div>Loading...</div>}>
<DetailAirlines />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import PageCreateAirlines from "@/components/page/airlines/PageCreateAirlines/PageCreateAirlines";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Thêm Hãng Hàng Không">
<Suspense fallback={<div>Loading...</div>}>
<PageCreateAirlines />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageAirlines from "@/components/page/airlines/MainPageAirlines";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Hãng Hàng Không">
<Suspense fallback={<div>Loading...</div>}>
<MainPageAirlines />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import PageUpdateAirlines from "@/components/page/airlines/PageUpdateAirlines";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chỉnh Sửa Hãng">
<Suspense fallback={<div>Loading...</div>}>
<PageUpdateAirlines />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailContinent from "@/components/page/continents/DetailContinent";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chi Tiết">
<Suspense fallback={<div>Loading...</div>}>
<DetailContinent />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageContinents from "@/components/page/continents/MainPageContinents";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Châu Lục">
<Suspense fallback={<div>Loading...</div>}>
<MainPageContinents />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailCountries from "@/components/page/countries/DetailCountries";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chi Tiết">
<Suspense fallback={<div>Loading...</div>}>
<DetailCountries />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import CreateCountries from "@/components/page/countries/CreateCountries";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Thêm Quốc Gia">
<Suspense fallback={<div>Loading...</div>}>
<CreateCountries />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageCountries from "@/components/page/countries/MainPageCountries";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Quốc Gia">
<Suspense fallback={<div>Loading...</div>}>
<MainPageCountries />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import UpdateCountries from "@/components/page/countries/UpdateCountries";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chỉnh Sửa Quốc Gia">
<Suspense fallback={<div>Loading...</div>}>
<UpdateCountries />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailCountryRegions from "@/components/page/country-regions/DetailCountryRegions";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Chi Tiết">
<Suspense fallback={<div>Loading...</div>}>
<DetailCountryRegions />
</Suspense>
</BaseLayout>
);
}
export default page;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageCountryRegions from "@/components/page/country-regions/MainPageCountryRegions";
import React, { Suspense } from "react";
function page() {
return (
<BaseLayout title="Vùng Miền">
<Suspense fallback={<div>Loading...</div>}>
<MainPageCountryRegions />
</Suspense>
</BaseLayout>
);
}
export default page;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import React from "react";
function page() {
return <BaseLayout title="Dashboard">page</BaseLayout>;
}
export default page;
+13 -6
View File
@@ -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 AppProvider from "@/contexts/AppProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,7 +15,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
title: "Quản lý hàng không",
description: "Generated by create next app",
};
@@ -23,11 +25,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
// <html
// lang="en"
// className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
// >
// <body className="min-h-full flex flex-col">{children}</body>
// </html>
<html lang="vi" suppressHydrationWarning>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}
+12 -61
View File
@@ -1,65 +1,16 @@
import Image from "next/image";
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import MainPageContinents from "@/components/page/continents/MainPageContinents";
import React, { Suspense } from "react";
export default function Home() {
function page() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<BaseLayout title="Continents">
<Suspense fallback={<div>Loading...</div>}>
<MainPageContinents />
</Suspense>
</BaseLayout>
);
}
export default page;
+14
View File
@@ -0,0 +1,14 @@
// Hàm chuyển đổi số coin thành chuỗi định dạng tiền tệ
export const convertCoin = (coin: number | null) => {
return coin
? coin.toLocaleString("de-DE", { maximumFractionDigits: 2 })
: "0";
};
// Hàm chuyển đổi chuỗi định dạng tiền tệ thành số coin
export const price = (value: string | number): number => {
const numberIsvalue = Number(
`${value}`.replace(/[^0-9,]/g, "").replace(",", "."),
);
return isNaN(numberIsvalue) ? 0 : numberIsvalue;
};
+3
View File
@@ -0,0 +1,3 @@
export async function delay(duration: number) {
return await new Promise((resolve) => setTimeout(resolve, duration));
}
+18
View File
@@ -0,0 +1,18 @@
export function getItemStorage(key: string) {
const value = localStorage.getItem(key)!;
if (value !== "undefined") {
return JSON.parse(value);
} else {
return null;
}
}
export function setItemStorage(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
}
export function deleteItemStorage(...keys: Array<any>) {
keys.forEach((key) => {
localStorage.removeItem(key);
});
}
+27
View File
@@ -0,0 +1,27 @@
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 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}`),
};
}
+96
View File
@@ -0,0 +1,96 @@
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) {
// 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 };
}
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { toast, ToastOptions } from "react-toastify";
import IconToastifyCustom from "@/components/customs/custom-toast";
const defaultOption: ToastOptions = {
autoClose: 2000,
hideProgressBar: true,
position: "top-center",
closeButton: true,
};
export const toastSuccess = ({ msg }: { msg: string }) =>
toast.success(msg, {
...defaultOption,
className: "toastify-custom-success",
icon: () => <IconToastifyCustom type="success" />,
});
export const toastWarn = ({ msg }: { msg: string }) =>
toast.warning(msg, {
...defaultOption,
className: "toastify-custom-warn",
icon: () => <IconToastifyCustom type="warn" />,
});
export const toastInfo = ({ msg }: { msg: string }) =>
toast.info(msg, {
...defaultOption,
className: "toastify-custom-info",
icon: () => <IconToastifyCustom type="info" />,
});
export const toastError = ({ msg }: { msg: string }) =>
toast.error(msg, {
...defaultOption,
className: "toastify-custom-error",
icon: () => <IconToastifyCustom type="error" />,
});
+14
View File
@@ -0,0 +1,14 @@
export function validateEmail(value: string) {
return !!value
?.trim()
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
);
}
export function validatePhoneNumber(phoneNumber: string) {
const vietnamPhoneRegex = /^(03|05|07|08|09)[0-9]{8}$/;
return vietnamPhoneRegex.test(phoneNumber);
}
+14
View File
@@ -0,0 +1,14 @@
export const useStyleClass = (props: any, style: any) => {
let styleProps: Array<any> = [];
for (let i in props) {
styleProps.push(i);
}
const convert: any = styleProps.map((item) => {
if (style[item] !== undefined) {
return { [style[item]]: !!props[item] };
}
});
return convert;
};
+63
View File
@@ -0,0 +1,63 @@
"use client";
import React, { ReactNode } from "react";
import Image from "next/image";
import icons from "@/constant/images/icons";
interface PropsDataWrapper {
title?: string;
note?: string;
loading: boolean;
data: unknown[];
children: ReactNode;
button?: ReactNode;
}
function DataWrapper({
title = "Dữ liệu trống",
note = "Hiện tại dữ liệu đang trống!",
data,
loading,
button,
children,
}: PropsDataWrapper) {
// ================= LOADING =================
if (loading) {
return (
<div className="min-h-[55vh] flex flex-col items-center justify-center">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<h3 className="text-2xl text-[#303b48] mt-6">Đang tải...</h3>
<p className="text-sm text-[#515c69] mt-1">
Vui lòng chờ, đang tải dữ liệu!
</p>
</div>
);
}
// ================= EMPTY =================
if (!loading && (!data || data.length === 0)) {
return (
<div className="min-h-[55vh] flex flex-col items-center justify-center text-center">
<Image
src={icons.emptyTable}
alt="empty-data"
width={180}
height={180}
priority
/>
<h3 className="text-2xl text-[#303b48] mt-6">{title}</h3>
<p className="text-sm text-[#515c69] mt-1">{note}</p>
{button && <div className="mt-5">{button}</div>}
</div>
);
}
// ================= HAS DATA =================
return <>{children}</>;
}
export default DataWrapper;
+230
View File
@@ -0,0 +1,230 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
interface OptionItem {
uuid: string | number;
name: string;
code?: string;
}
interface PropsFilterCustom<T extends string | number | null> {
styleRounded?: boolean;
showOptionAll?: boolean;
name: string;
listOption: OptionItem[];
value: T;
onChange: (value: T) => void;
}
function FilterCustom<T extends string | number | null>({
styleRounded = false,
showOptionAll = true,
name,
listOption,
value,
onChange,
}: PropsFilterCustom<T>) {
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const normalizedKeyword = removeVietnameseTones(keyword);
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [open]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const filteredOptions = listOption.filter((item) =>
removeVietnameseTones(item.name).includes(normalizedKeyword),
);
const selectedLabel =
listOption.find((item) => item.uuid === value)?.name || "Tất cả";
return (
<div ref={wrapperRef} className="relative w-full md:w-auto">
{/* Trigger */}
<div
onClick={() => setOpen((prev) => !prev)}
className={clsx(
"flex items-center justify-between gap-2 border px-4 h-12",
"cursor-pointer select-none bg-white border-[#e1e5ed]",
"transition-all hover:border-[#0019f6]",
"w-full min-w-[240px]", // Đảm bảo độ rộng tối thiểu và tự co giãn linh hoạt
styleRounded ? "rounded-full" : "rounded-full",
open && "border-[#0019f6]",
)}
>
<div className="flex items-center overflow-hidden flex-1">
<span className="text-sm font-medium text-[#777e90] whitespace-nowrap">
{name}:
</span>
<span className="ml-2 truncate text-sm font-medium">
{selectedLabel}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={clsx(
"size-5 shrink-0",
"transition-transform duration-200",
open && "rotate-180",
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</div>
{/* Dropdown */}
{open && (
<div
className={clsx(
"absolute left-0 top-full z-50 mt-2",
"w-full overflow-hidden bg-white shadow-xl rounded-2xl", // Đổi min-w-[240px] thành w-full ở đây
"border border-[#e1e5ed]", // Thêm viền nhẹ để dropdown rõ ràng hơn
)}
>
<div className="p-3">
{/* Search */}
<input
ref={inputRef}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="
mb-2 w-full
rounded-lg border border-[#e1e5ed]
px-3 py-2 text-sm
outline-none
focus:border-[#0019f6]
"
/>
{/* Options */}
<div className="max-h-[320px] overflow-y-auto custom-scrollbar">
{showOptionAll && (
<div
onClick={() => {
onChange(null as T);
setOpen(false);
}}
className={clsx(
"flex cursor-pointer items-center justify-between",
"border-b border-[#e2e0ec]",
"px-3 py-2 text-sm",
"hover:text-[#0019f6]",
(value === null || value === "") && "text-[#0019f6]",
)}
>
<span className="truncate mr-2">Tất cả</span>
{(value === null || value === "") && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5 shrink-0 text-[#5755FF]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
)}
</div>
)}
{filteredOptions.map((item) => {
const isActive = item.uuid === value;
return (
<div
key={item.uuid}
onClick={() => {
onChange(item.uuid as T);
setOpen(false);
}}
className={clsx(
"mt-1 flex cursor-pointer items-center justify-between",
"border-b border-[#e2e0ec]",
"px-3 py-2 text-sm",
"hover:text-[#0019f6]",
"last:border-none",
isActive && "text-[#0019f6]",
)}
>
<span className="truncate mr-2">{item.name}</span>
{isActive && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5 shrink-0 text-[#5755FF]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
)}
</div>
);
})}
{filteredOptions.length === 0 && (
<div className="py-3 text-center text-sm text-gray-400">
Không tìm thấy dữ liệu
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
export default FilterCustom;
+248
View File
@@ -0,0 +1,248 @@
"use client";
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import clsx from "clsx";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import CustomButton from "./custom-button";
interface OptionItem<T> {
uuid: T;
name: string;
code?: string;
}
interface PropsFilterMany<T extends string | number> {
styleRounded?: boolean;
small?: boolean;
name: string;
listOption: OptionItem<T>[];
value: T[];
setValue: Dispatch<SetStateAction<T[]>>;
}
function FilterMany<T extends string | number>({
styleRounded = false,
small = false,
name,
listOption,
value,
setValue,
}: PropsFilterMany<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [tempSelected, setTempSelected] = useState<T[]>([]);
const wrapperRef = useRef<HTMLDivElement>(null);
// sync khi mở dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleOpen = () => {
setOpen((prev) => !prev);
};
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [open]);
const isAllSelected =
tempSelected.length === 0 || tempSelected.length === listOption.length;
const filteredList = listOption.filter((item) =>
removeVietnameseTones(item.name).includes(
removeVietnameseTones(keyword || ""),
),
);
return (
<div ref={wrapperRef} className="relative inline-block">
{/* TRIGGER */}
<div
onClick={handleOpen}
className={clsx(
"flex items-center justify-between px-4 cursor-pointer border transition",
"border-gray-200 bg-white hover:border-blue-600",
small ? "h-10 text-sm" : "h-12",
styleRounded ? "rounded-full bg-gray-100" : "rounded-md",
open && "border-blue-600",
)}
>
<div className="flex items-center">
<p className="text-gray-400 text-sm font-medium">{name}:</p>
<p className="ml-2 text-sm font-medium">
{value.length === 0 || value.length === listOption.length
? "Tất cả"
: `${value.length} ${name.toLowerCase()}`}
</p>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={clsx(
"size-4",
"transition duration-300",
open && "rotate-180",
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</div>
{/* DROPDOWN */}
{open && (
<div
className={clsx(
"absolute left-0 top-full z-50 mt-2",
"w-[280px] bg-white border border-gray-200 shadow-lg p-3 space-y-3",
styleRounded && "rounded-2xl",
)}
>
{/* SEARCH */}
<input
ref={inputRef}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="
w-full px-3 py-2 text-sm
border border-gray-200 rounded-lg outline-none
focus:border-blue-500
"
/>
{/* LIST */}
<div className="max-h-60 overflow-y-auto space-y-1">
{/* ALL */}
<div
onClick={() => setTempSelected([])}
className={clsx(
"flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-100",
isAllSelected && "bg-blue-50",
)}
>
<p>Tất cả</p>
{isAllSelected && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4 text-blue-600"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
)}
</div>
{/* OPTIONS */}
{filteredList.map((item) => {
const active = tempSelected.includes(item.uuid);
return (
<div
key={String(item.uuid)}
onClick={() => {
setTempSelected((prev) =>
prev.includes(item.uuid)
? prev.filter((x) => x !== item.uuid)
: [...prev, item.uuid],
);
}}
className={clsx(
"flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-100",
active && "bg-blue-50",
)}
>
<p>{item.name}</p>
{active && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4 text-blue-600"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
)}
</div>
);
})}
</div>
{/* ACTION */}
<div className="flex gap-2">
<CustomButton
variant="grey"
rounded="full"
onClick={() => setOpen(false)}
>
Hủy bỏ
</CustomButton>
<CustomButton
variant="midnightBlue"
rounded="full"
onClick={() => {
setValue(tempSelected);
setOpen(false);
}}
>
Xác nhận
</CustomButton>
</div>
</div>
)}
</div>
);
}
export default FilterMany;
+62
View File
@@ -0,0 +1,62 @@
"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 | undefined;
listState: StateItem[];
}
function StateActive({
isBox = true,
stateActive,
listState,
}: PropsStateActive) {
// memo trạng thái hiện tại
const current = useMemo(() => {
return listState.find((item) => item.state === stateActive);
}, [stateActive, listState]);
if (isBox) {
return (
<div
className="w-fit rounded-full text-sm font-normal"
style={{
color: current?.textColor ?? "#202939",
backgroundColor: current?.backgroundColor,
padding: current?.backgroundColor ? "6px 12px" : undefined,
}}
>
{current?.text ?? "---"}
</div>
);
}
return (
<div className="flex items-center gap-1.5 w-fit">
{/* DOT */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: current?.backgroundColor }}
/>
{/* TEXT */}
<p
className="text-sm font-medium"
style={{ color: current?.textColor ?? "#23262f" }}
>
{current?.text ?? "---"}
</p>
</div>
);
}
export default StateActive;
+149
View File
@@ -0,0 +1,149 @@
"use client";
import clsx from "clsx";
import Link from "next/link";
export interface PropsButton {
children?: React.ReactNode;
icon?: React.ReactNode;
href?: string;
target?: string;
onClick?: (
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => void;
className?: string;
disabled?: boolean;
type?: "button" | "submit" | "reset";
variant?:
| "default"
| "midnightBlue"
| "green"
| "red"
| "grey"
| "white"
| "black"
| "warning";
size?: "sm" | "md" | "lg";
rounded?: "sm" | "md" | "lg" | "xl" | "full";
fullWidth?: boolean;
maxContent?: boolean;
maxHeight?: boolean;
}
export default function CustomButton({
children,
icon,
href,
target,
onClick,
className,
disabled = false,
type = "button",
variant = "default",
size = "md",
rounded = "md",
fullWidth = true,
maxContent = false,
maxHeight = false,
}: PropsButton) {
const variantClasses = {
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",
};
const sizeClasses = {
sm: "px-6 py-2 text-[13px]",
md: "px-6 py-3 text-sm",
lg: "px-8 py-3 text-base",
};
const roundedClasses = {
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
xl: "rounded-xl",
full: "rounded-full",
};
const baseClass = clsx(
"inline-flex items-center justify-center gap-2",
"font-medium whitespace-nowrap",
"select-none",
"transition-all duration-200",
"hover:opacity-80",
"active:scale-95",
variantClasses[variant],
sizeClasses[size],
roundedClasses[rounded],
fullWidth && "w-full",
maxContent && "w-max",
maxHeight && "h-full",
disabled &&
"opacity-30 cursor-not-allowed pointer-events-none active:scale-100",
className,
);
const content = (
<>
{icon && (
<span className="flex items-center justify-center shrink-0">
{icon}
</span>
)}
<span className="flex items-center justify-center">{children}</span>
</>
);
const handleClick = (
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e);
};
if (href) {
return (
<Link
href={href}
target={target}
className={baseClass}
onClick={handleClick}
aria-disabled={disabled}
>
{content}
</Link>
);
}
return (
<button
type={type}
disabled={disabled}
onClick={handleClick}
className={baseClass}
>
{content}
</button>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import React, { ReactNode } from "react";
import IconRound, { IconRoundType } from "../utils/IconRound";
import CustomPopup from "./custom-popup";
import CustomButton from "./custom-button";
export interface PropsDialog {
open: boolean;
title: ReactNode;
note?: ReactNode;
onClose: () => void;
onSubmit: () => void;
titleCancel?: string;
titleSubmit?: string;
type?: IconRoundType;
}
const CustomDialog = ({
open,
title,
note,
titleCancel = "Hủy bỏ",
titleSubmit = "Xác nhận",
onClose,
onSubmit,
type = "success",
}: PropsDialog) => {
return (
<CustomPopup open={open} onClose={onClose}>
<div
role="dialog"
aria-modal="true"
className=" relative w-[440px] max-w-[90vw]
rounded-2xl
bg-white shadow-lg
px-6 py-7
flex flex-col items-center text-center"
>
{/* ICON */}
<IconRound type={type} />
{/* TITLE */}
<h4 className="mt-4 text-xl font-semibold text-gray-800">{title}</h4>
{/* NOTE */}
{note && <p className="mt-2 text-sm text-gray-500">{note}</p>}
{/* ACTIONS */}
<div className="w-full flex items-center gap-3 mt-6">
<CustomButton onClick={onClose} variant="grey" rounded="full">
{titleCancel}
</CustomButton>
<CustomButton
onClick={onSubmit}
variant="midnightBlue"
rounded="full"
>
{titleSubmit}
</CustomButton>
</div>
</div>
</CustomPopup>
);
};
export default CustomDialog;
+83
View File
@@ -0,0 +1,83 @@
"use client";
import React from "react";
import Link from "next/link";
import clsx from "clsx";
import * as Tooltip from "@radix-ui/react-tooltip";
export interface PropsCustomIcon {
icon: React.ReactNode;
tooltip: string;
onClick?: () => void;
color?: string;
href?: string;
type?: "edit" | "delete" | "view" | "create";
disnable?: boolean;
}
export default function CustomIcon({
icon,
tooltip,
onClick,
color,
href,
type,
disnable = false,
}: PropsCustomIcon) {
const className = clsx(
"flex h-8 w-8 items-center justify-center rounded-md transition-all duration-200 select-none",
!disnable && ["cursor-pointer", "hover:opacity-70", "active:scale-90"],
disnable && "pointer-events-none opacity-30",
type === "create" && "text-green-500",
type === "edit" && "text-blue-600",
type === "delete" && "text-red-500",
type === "view" && "text-orange-500",
);
const content = (
<span
className={className}
style={color ? { color } : undefined}
onClick={onClick}
>
<span className="flex h-6 w-6 items-center justify-center">{icon}</span>
</span>
);
return (
<Tooltip.Provider delayDuration={250}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{href ? <Link href={href}>{content}</Link> : content}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
sideOffset={8}
className="
z-50
rounded-md
bg-gray-900
px-3
py-1.5
text-xs
text-white
shadow-lg
animate-in
fade-in-0
zoom-in-95
"
>
{tooltip}
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import React from "react";
import CustomPortal from "./custom-portal";
interface PropsLoading {
loading?: boolean;
}
const SCALE = 2.5;
function CustomLoading({ loading }: PropsLoading) {
if (!loading) return null;
return (
<CustomPortal>
<div className="fixed inset-0 flex items-center justify-center bg-black/60 z-[10002]">
<div
className="relative"
style={{
width: 40 * SCALE,
height: 40 * SCALE,
}}
>
{Array.from({ length: 12 }).map((_, i) => {
const rotate = i * 30;
return (
<div
key={i}
style={{
transformOrigin: `${20 * SCALE}PX ${20 * SCALE}px`,
transform: `rotate(${rotate}deg)`,
animation: "spinnerFade 1.2s linear infinite",
animationDelay: `${-(1.1 - i * 0.1)}s`,
}}
className="absolute top-0 left-0 w-full h-full"
>
<div
style={{
top: 1.5 * SCALE,
left: 18.5 * SCALE,
width: 3 * SCALE,
height: 9 * SCALE,
}}
className="absolute bg-white rounded-sm"
/>
</div>
);
})}
</div>
</div>
</CustomPortal>
);
}
export default CustomLoading;
@@ -0,0 +1,206 @@
"use client";
import React, { useEffect, useMemo, useRef, useState, forwardRef } from "react";
import clsx from "clsx";
// import { ChevronDown, ArrowLeft, ArrowRight } from "lucide-react";
import { ArrowDown2, ArrowLeft2, ArrowRight2 } from "iconsax-react";
export interface PropsPagination {
total: number;
page: number;
onSetPage: (page: number) => void;
pageSize: number;
onSetPageSize: (pageSize: number) => void;
dependencies?: any[];
}
// Wrapper component to forward ref for the dropdown trigger
const TriggerButton = forwardRef<
HTMLDivElement,
{
onClick: () => void;
className: string;
pageSize: number;
openLimit: boolean;
}
>(({ onClick, className, pageSize, openLimit }, ref) => (
<div ref={ref} onClick={onClick} className={className}>
<span className="text-sm font-medium">{pageSize}</span>
<ArrowDown2
className={clsx(
"text-gray-500 transition-transform",
openLimit && "rotate-180 text-[#0011ab]",
)}
size={20}
color="#888888"
/>
{/* <ChevronDown
/> */}
</div>
));
TriggerButton.displayName = "TriggerButton";
export default function Pagination({
total,
page,
pageSize,
onSetPage,
onSetPageSize,
dependencies = [],
}: PropsPagination) {
const pageSizes = [50, 100, 200];
const [openLimit, setOpenLimit] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const maxPage = Math.ceil(total / Number(pageSize));
const items = useMemo(() => {
const nodes: React.ReactNode[] = [];
for (let i = 1; i <= maxPage; i++) {
const isCurrent = page === i;
const isNear =
i === page ||
i === page - 1 ||
i === page + 1 ||
i === 1 ||
i === maxPage;
if (isNear) {
nodes.push(
<li
key={i}
onClick={() => onSetPage(i)}
className={clsx(
"h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold border rounded cursor-pointer transition",
isCurrent
? "bg-[#0011ab] text-white border-[#0011ab] pointer-events-none"
: "bg-white text-black border-[#efefef] hover:opacity-70",
)}
>
{i}
</li>,
);
}
if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) {
nodes.push(
<li
key={`dot-${i}`}
className="h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold"
>
...
</li>,
);
}
}
return nodes;
}, [maxPage, page, onSetPage]);
const handlePrev = () => {
if (page > 1) onSetPage(page - 1);
};
const handleNext = () => {
if (page < maxPage) onSetPage(page + 1);
};
useEffect(() => {
if (dependencies.length > 0) {
onSetPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
openLimit &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpenLimit(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openLimit]);
if (total <= 0) return null;
return (
<div className="mt-2 flex flex-wrap-reverse items-center justify-between gap-2">
{/* LEFT */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<p>Hiển thị</p>
<div ref={dropdownRef} className="relative">
<TriggerButton
onClick={() => setOpenLimit(!openLimit)}
className={clsx(
"w-[80px] flex items-center justify-between px-3 py-1 border rounded-full cursor-pointer bg-white",
"border-gray-300",
)}
pageSize={pageSize}
openLimit={openLimit}
/>
{openLimit && (
<div className="absolute right-0 z-10 w-[80px] border border-gray-300 bg-white rounded p-1 mt-1 shadow-lg">
{pageSizes.map((v) => (
<div
key={v}
onClick={() => {
onSetPageSize(v);
setOpenLimit(false);
}}
className={clsx(
"px-2 py-1 text-[13px] cursor-pointer rounded",
"hover:bg-[#0011ab] hover:text-white",
pageSize === v && "bg-[#0011ab] text-white",
)}
>
{v}
</div>
))}
</div>
)}
</div>
<p className="text-sm text-gray-600">
trong tổng <span className="font-semibold">{total}</span> kết quả
</p>
</div>
{/* PAGINATION */}
<div className="flex items-center justify-center">
{page > 1 && (
<button
onClick={handlePrev}
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition mr-2"
>
<ArrowLeft2 size={20} color="#888888" />
{/* <ArrowLeft /> */}
</button>
)}
<ul className="flex items-center gap-2">{items}</ul>
{page < maxPage && (
<button
onClick={handleNext}
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition ml-2"
>
<ArrowRight2 size={20} color="#888888" />
{/* <ArrowRight /> */}
</button>
)}
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import React, { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
/* ================== TYPES ================== */
interface Props {
open: boolean;
isFull?: boolean;
onClose: () => void;
children?: ReactNode;
showOverlay?: boolean;
}
/* ================== COMPONENT ================== */
export default function CustomPopup({
open,
onClose,
showOverlay = true,
isFull,
children,
}: Props) {
// lock body scroll
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original || "auto";
};
}, [open]);
// SSR safe
if (typeof window === "undefined") return null;
// popup closed
if (!open) return null;
return createPortal(
<>
{/* OVERLAY */}
{showOverlay && (
<div
onClick={onClose}
className="
fixed
inset-0
z-[10000]
bg-[#141416]/50
animate-[fadeIn_0.2s_ease-in]
"
/>
)}
{/* CONTENT */}
<div
className={clsx(
`
fixed
z-[10001]
flex
items-center
justify-center
top-1/2
left-1/2
-translate-x-1/2
-translate-y-1/2
`,
isFull &&
`
max-[768px]:top-0
max-[768px]:left-0
max-[768px]:right-0
max-[768px]:bottom-0
max-[768px]:translate-x-0
max-[768px]:translate-y-0
`,
)}
>
{children}
</div>
</>,
document.body,
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import React, { ReactNode, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
interface Props {
children: ReactNode;
className?: string;
containerId?: string;
}
function CustomPortal({ children, className, containerId }: Props) {
const [mounted, setMounted] = useState(false);
const element = useMemo(() => {
if (typeof window === "undefined") return null;
const el = document.createElement("div");
if (className) {
el.className = className;
}
return el;
}, [className]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !element) return;
const parent = containerId
? document.getElementById(containerId)
: document.body;
if (!parent) return;
parent.appendChild(element);
return () => {
parent.removeChild(element);
};
}, [mounted, element, containerId]);
if (!mounted || !element) return null;
return createPortal(children, element);
}
export default CustomPortal;
+69
View File
@@ -0,0 +1,69 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
interface PropsSearch {
placeholder?: string;
keyword?: string;
setKeyword: (value: string) => void;
}
export default function Search({
keyword = "",
setKeyword,
placeholder = "Nhập từ khóa tìm kiếm",
}: PropsSearch) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState(keyword);
useEffect(() => {
const timer = setTimeout(() => {
setKeyword(searchTerm.trim());
}, 600);
return () => clearTimeout(timer);
}, [searchTerm]);
return (
<div
onClick={() => inputRef.current?.focus()}
className={clsx(
"flex h-12 cursor-text items-center gap-2 rounded-full border bg-white px-4 transition-colors",
"border-[#e1e5ed] hover:border-[#3772ff] focus-within:border-[#3772ff]",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6 text-[#908F99]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
ref={inputRef}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={placeholder}
className={clsx(
"flex-1",
"border-none",
"bg-transparent",
"p-2",
"text-[16px]",
"font-medium",
"outline-none",
"placeholder:text-[#777e90]",
)}
/>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import clsx from "clsx";
import React, {
Fragment,
Key,
ReactNode,
useMemo,
useRef,
useState,
} from "react";
interface ColumnType<T> {
title: string | ReactNode;
render: (row: T, index: number, path?: number[]) => ReactNode;
className?: string;
checkBox?: boolean;
fixedLeft?: boolean;
fixedRight?: boolean;
maxWidth?: number | string;
sortTable?: string;
}
interface PropsTable<T> {
data: T[];
column: ColumnType<T>[];
fixedHeader?: boolean;
activeHeader?: boolean;
handleCheckedAll?: (e: React.ChangeEvent<HTMLInputElement>) => void;
isCheckedAll?: boolean;
handleCheckedRow?: (e: React.ChangeEvent<HTMLInputElement>, row: T) => void;
handleIsCheckedRow?: (row: T) => boolean;
rowKey: (row: T) => React.Key;
getChildren?: (row: T) => T[] | undefined;
useIndexPathAsKey?: boolean;
}
function CustomTable<T>({
data,
column,
fixedHeader,
activeHeader,
handleCheckedAll,
isCheckedAll,
handleCheckedRow,
handleIsCheckedRow,
rowKey,
getChildren,
useIndexPathAsKey,
}: PropsTable<T>) {
const tableRef = useRef<HTMLDivElement>(null);
const thRefs = useRef<(HTMLTableCellElement | null)[]>([]);
const [expandedRows, setExpandedRows] = useState<Key[]>([]);
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: "asc" | "desc" | null;
}>({ key: "", direction: null });
/* ================= SORT ================= */
const handleSort = (key: string) => {
setSortConfig((prev) => {
if (prev.key === key) {
const next =
prev.direction === "asc"
? "desc"
: prev.direction === "desc"
? null
: "asc";
return { key, direction: next };
}
return { key, direction: "asc" };
});
};
const sortedData = useMemo(() => {
if (!sortConfig.key || !sortConfig.direction) return data;
const copy = [...data];
copy.sort((a: any, b: any) => {
const aVal = a?.[sortConfig.key];
const bVal = b?.[sortConfig.key];
if (typeof aVal === "number" && typeof bVal === "number") {
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
}
if (typeof aVal === "string" && typeof bVal === "string") {
return sortConfig.direction === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return 0;
});
return copy;
}, [data, sortConfig]);
/* ================= EXPAND ================= */
const toggleExpand = (key: React.Key) => {
setExpandedRows((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
/* ================= ROWS ================= */
const renderRows = (rows: T[], path: number[] = []): React.ReactNode =>
rows.map((row, i) => {
const currentPath = [...path, i];
const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row);
const children = getChildren?.(row);
const expanded = expandedRows.includes(keyPath);
return (
<Fragment key={String(keyPath)}>
<tr className="bg-white border-b border-gray-400">
{getChildren && (
<td className="w-[60px] text-center">
{!!children?.length && (
<button
onClick={() => toggleExpand(keyPath)}
className={clsx(
"transition text-gray-400 hover:text-blue-500",
expanded && "rotate-180 text-blue-500",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 15.75 7.5-7.5 7.5 7.5"
/>
</svg>
</button>
)}
</td>
)}
{column.map((col, idx) => (
<td
key={idx}
className={clsx(
"px-4 py-3 text-sm font-medium whitespace-nowrap",
col.fixedLeft && "sticky left-0 bg-white z-10",
col.fixedRight && "sticky right-0 bg-white z-10",
)}
>
<div
style={{
maxWidth:
typeof col.maxWidth === "number"
? `${col.maxWidth}px`
: col.maxWidth,
}}
className={clsx("flex items-center gap-2")}
>
{col.checkBox && (
<input
type="checkbox"
className="w-4 h-4 accent-blue-500 cursor-pointer"
onChange={(e) => handleCheckedRow?.(e, row)}
checked={handleIsCheckedRow?.(row) ?? false}
/>
)}
{col.render(row, i, currentPath)}
</div>
</td>
))}
</tr>
{expanded && children && renderRows(children, currentPath)}
</Fragment>
);
});
return (
<div
ref={tableRef}
className={clsx(
"overflow-auto pb-2",
fixedHeader && "[&>table>thead]:sticky [&>table>thead]:top-0",
activeHeader && "[&>table>thead]:bg-[#F4F7FA]",
)}
>
<table className="w-full border-collapse">
<thead className="bg-white border-b">
<tr>
{getChildren && <th className="w-[60px]" />}
{column.map((col, i) => {
const isActive = col.sortTable === sortConfig.key;
return (
<th
key={i}
ref={(el) => {
thRefs.current[i] = el;
}}
onClick={() => col.sortTable && handleSort(col.sortTable)}
className={clsx(
"px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap",
col.sortTable && "cursor-pointer",
isActive && "text-blue-600",
col.fixedLeft && "sticky left-0 bg-white z-10",
col.fixedRight && "sticky right-0 bg-white z-10",
)}
>
<div className="flex items-center gap-1">
{col.checkBox && (
<input
type="checkbox"
onChange={handleCheckedAll}
checked={isCheckedAll}
/>
)}
{col.title}
{col.sortTable &&
(isActive ? (
sortConfig.direction === "asc" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25"
/>
</svg>
) : sortConfig.direction === "desc" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
/>
</svg>
)
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
/>
</svg>
))}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>{renderRows(sortedData)}</tbody>
</table>
</div>
);
}
export default CustomTable;
+61
View File
@@ -0,0 +1,61 @@
"use client";
import clsx from "clsx";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
interface PropsTabNavLink {
query: string;
listHref: {
title: string;
pathname: string;
query: string | null;
}[];
}
function CustomTabs({ query, listHref }: PropsTabNavLink) {
const router = useRouter();
const searchParams = useSearchParams();
const currentValue = searchParams.get(query);
const handleActive = (value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value === null) {
params.delete(query);
} else {
params.set(query, value);
}
router.replace(`?${params.toString()}`, { scroll: false });
};
return (
<div className="overflow-x-auto whitespace-nowrap pb-1 scrollbar-thin">
{listHref.map((item, i) => {
const isActive =
currentValue === item.query ||
(!currentValue && item.query === null && i === 0);
return (
<button
key={i}
type="button"
onClick={() => handleActive(item.query)}
className={clsx(
"inline-flex items-center justify-center",
"min-w-[60px] px-6 py-2.5 rounded-full border",
"select-none cursor-pointer transition",
"text-[13px] sm:text-[14px] md:text-[16px]",
i !== 0 && "ml-2",
isActive
? "bg-[#0019F6] text-white border-[#0019F6]"
: "bg-white text-[#202939] border-[#EAEDF2] hover:border-[#0019F6]",
)}
>
{item.title}
</button>
);
})}
</div>
);
}
export default CustomTabs;
+46
View File
@@ -0,0 +1,46 @@
"use client";
import clsx from "clsx";
import { CloseCircle, InfoCircle, TickCircle } from "iconsax-react";
export type ToastType = "success" | "info" | "warn" | "error";
interface Props {
type?: ToastType;
}
const ICON_CONFIG = {
success: {
bg: "bg-[#06D7A0]",
icon: TickCircle,
},
info: {
bg: "bg-[#4BC9F0]",
icon: TickCircle,
},
warn: {
bg: "bg-[#FFD167]",
icon: InfoCircle,
},
error: {
bg: "bg-[#EE464C]",
icon: CloseCircle,
},
} as const;
export default function IconToastifyCustom({ type = "info" }: Props) {
const config = ICON_CONFIG[type];
const Icon = config.icon;
return (
<div
className={clsx(
"flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full",
config.bg,
)}
>
<Icon size={20} color="#fff" variant="Bold" />
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import clsx from "clsx";
import React, { ReactNode, useMemo, useRef } from "react";
interface Props {
length?: number;
onSetValue?: (value: any) => void;
name?: string;
className?: string;
}
function InputSingle({
className,
length = 6,
onSetValue,
name = "otp_code",
}: Props) {
const inputsRef = useRef<HTMLInputElement[]>([]);
// update value
const updateOTPValue = () => {
const code = inputsRef.current.map((input) => input?.value || "");
onSetValue?.((prev: any) => ({
...prev,
[name]: code.join(""),
}));
};
// input change
const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
const target = e.currentTarget;
const index = Number(target.dataset.index);
let value = target.value;
if (value.length > 1) {
value = value.slice(0, 1);
target.value = value;
}
updateOTPValue();
if (value) {
inputsRef.current[index + 1]?.focus();
}
};
// backspace
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const index = Number(e.currentTarget.dataset.index);
if (e.key === "Backspace") {
if (!e.currentTarget.value) {
inputsRef.current[index - 1]?.focus();
}
requestAnimationFrame(() => {
updateOTPValue();
});
}
};
// set refs
const setRef = (el: HTMLInputElement | null, index: number) => {
if (el) {
inputsRef.current[index] = el;
}
};
const renderInputs = useMemo(() => {
const list: ReactNode[] = [];
for (let i = 0; i < length; i++) {
list.push(
<input
key={i}
ref={(el) => setRef(el, i)}
data-index={i}
inputMode="numeric"
maxLength={1}
onInput={handleChange}
onKeyDown={handleKeyDown}
className={clsx(
"h-[72px] w-[56px]",
"rounded-xl border-2 border-[#dae1ea]",
"text-center text-[42px] font-medium",
"outline-none transition",
"focus:border-[#066bb1]",
)}
/>,
);
}
return list;
}, [length]);
return (
<div className={clsx("flex items-center justify-center gap-3", className)}>
{renderInputs}
</div>
);
}
export default InputSingle;
+122
View File
@@ -0,0 +1,122 @@
"use client";
import React from "react";
import clsx from "clsx";
interface CustomSliderProps {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange?: (value: number) => void;
showValue?: boolean;
disabled?: boolean;
className?: string;
labelClassName?: string;
sliderClassName?: string;
thumbClassName?: string;
trackClassName?: string;
color?: string;
trackColor?: string;
renderValue?: (value: number) => React.ReactNode;
}
const CustomSlider = ({
label,
value,
min = 0,
max = 1,
step = 0.01,
onChange,
showValue = true,
disabled = false,
className,
labelClassName,
sliderClassName,
color = "#2563eb",
trackColor = "#e5e7eb",
renderValue,
}: CustomSliderProps) => {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={clsx("w-full space-y-3", className)}>
{/* Header */}
{(label || showValue) && (
<div
className={clsx("flex items-center justify-between", labelClassName)}
>
{label && (
<span className="text-sm font-medium text-gray-700">{label}</span>
)}
{showValue && (
<span className="text-sm font-semibold text-gray-900">
{renderValue ? renderValue(value) : value}
</span>
)}
</div>
)}
{/* Slider */}
<input
type="range"
min={min}
max={max}
step={step}
disabled={disabled}
value={value}
onChange={(e) => onChange?.(Number(e.target.value))}
className={clsx(
`
w-full
appearance-none
bg-transparent
cursor-pointer
disabled:opacity-50
disabled:cursor-not-allowed
[&::-webkit-slider-runnable-track]:h-2
[&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:w-6
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-4
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:-mt-2
[&::-moz-range-track]:h-2
[&::-moz-range-track]:rounded-full
[&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:w-6
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border-4
`,
sliderClassName,
)}
style={{
background: `linear-gradient(to right, ${color} ${percentage}%, ${trackColor} ${percentage}%)`,
}}
/>
</div>
);
};
export default CustomSlider;
@@ -0,0 +1,84 @@
"use client";
import React from "react";
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
import clsx from "clsx";
import Navbar from "./compoents/Navbar";
import Header from "./compoents/Header";
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
const [showFull, setShowFull] = React.useState(true);
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
return (
// <RequireAuth>
<ContextBaseLayout
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
>
<div className="min-h-screen bg-[#f8f8f8]">
{/* OVERLAY cho Mobile */}
<div
className={clsx(
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
)}
onClick={() => setOpenMenuMobile(false)}
/>
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
<nav
className={clsx(
"fixed top-0 left-0 h-screen bg-white border-r border-[#f4f7fa] z-[30] transition-all duration-300 ease-in-out",
/* DESKTOP */
showFull
? "xl:w-[240px] xl:translate-x-0"
: "xl:w-[240px] xl:-translate-x-full",
/* MOBILE + TABLET */
openMenuMobile
? "translate-x-0 w-[240px]"
: "-translate-x-full w-[240px]",
)}
>
<Navbar />
</nav>
{/* HEADER */}
<header
className={clsx(
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
/* DESKTOP */
showFull
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
: "xl:left-0 xl:w-full",
/* MOBILE */
"left-0 w-full",
)}
>
<Header title={title} breadcrumb={breadcrumb} />
</header>
{/* MAIN CONTENT */}
<main
className={clsx(
"pt-[92px] pb-6 px-6 transition-all duration-300 ease-in-out text-[#000000]",
/* DESKTOP */
showFull ? "xl:ml-[240px]" : "xl:ml-0",
/* MOBILE */
"ml-0",
)}
>
{children}
</main>
</div>
</ContextBaseLayout>
);
};
export default BaseLayout;
@@ -0,0 +1,156 @@
"use client";
import React, { useContext, useEffect } from "react";
import { PropsHeader } from "./interface";
import { usePathname } from "next/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 clsx from "clsx";
import MenuProfile from "../MenuProfile";
function Header({ title, breadcrumb }: PropsHeader) {
const pathname = usePathname();
const { infoUser } = useSelector((state: RootState) => state.user);
const context = useContext(ContextBaseLayout);
const [openProfile, setOpenProfile] = React.useState<boolean>(false);
// Đóng menu mobile khi đổi route
useEffect(() => {
if (window.innerWidth < 1280) {
context.setOpenMenuMobile?.(false);
}
}, [pathname]);
const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
/* =========================================================
CALL API GET LIST PROFILE & KHỚP NỐI THEO CHUẨN DATA MỚI
========================================================= */
// const profileQuery = useQuery<any>({
// queryKey: [QUERY_KEY.table_list_profile],
// queryFn: async () => {
// const res = await httpRequest<any>({
// showMessageFailed: true,
// http: profileServices.getProfile(), // Đảm bảo endpoint trỏ về /api/v1/UserProfile/list
// });
// // Trả về đúng object chứa items theo cấu trúc của API
// return (
// res || {
// items: [],
// page: 1,
// pageSize: 10,
// total: 0,
// totalPages: 0,
// }
// );
// },
// });
// Tìm kiếm thông tin profile của user hiện tại đang đăng nhập bằng useMemo
// eslint-disable-next-line react-hooks/preserve-manual-memoization
// const currentUserProfile = useMemo(() => {
// const listItems = profileQuery.data?.items || [];
// if (!infoUser?.userId) return null;
// // So khớp accountId của API trả về với userId trong Redux (infoUser)
// return (
// listItems.find((item: any) => item.accountId === infoUser.userId) || null
// );
// }, [profileQuery.data, infoUser?.userId]);
return (
<div className="h-full w-full px-6 flex items-center justify-between bg-white relative">
<div className="flex items-center gap-4">
{/* Nút bấm Mobile: Mở menu mobile */}
<div
className="xl:hidden cursor-pointer"
onClick={() => context?.setOpenMenuMobile?.(!context?.openMenuMobile)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
</div>
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
<div
className="hidden xl:block cursor-pointer"
onClick={() => context?.setShowFull?.(!context?.showFull)}
>
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
</div>
{breadcrumb ? (
breadcrumb
) : (
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
)}
</div>
<div className="flex items-center gap-7">
<div className="cursor-pointer" onClick={toggleFullScreen}>
<Image src={icons.full_screen} alt="" width={24} height={24} />
</div>
{/* PROFILE CONTROL AREA */}
<div
className="relative flex items-center gap-2 cursor-pointer"
onClick={() => setOpenProfile(!openProfile)}
>
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden relative">
<Image
src={
// currentUserProfile?.avatarUrl
// ? `${process.env.NEXT_PUBLIC_IMAGE}${currentUserProfile.avatarUrl}`
// :
icons.avatar
}
alt="avatar"
width={40}
height={40}
className="object-cover h-full w-full"
/>
</div>
<p className="hidden md:block text-sm font-semibold text-[#171832]">
{
// currentUserProfile?.fullName ||
"User admin"
}
</p>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
// className="size-4"
className={clsx(
"transition-transform",
"size-4",
openProfile && "rotate-180",
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
{/* ChevronDown */}
{/* {openProfile && (
<div className="absolute right-0 top-[120%] z-50">
<MenuProfile onClose={() => setOpenProfile(false)} />
</div>
)} */}
</div>
</div>
</div>
);
}
export default Header;
@@ -0,0 +1 @@
export { default } from "./Header";
@@ -0,0 +1,4 @@
export interface PropsHeader {
title: string;
breadcrumb?: React.ReactNode;
}
@@ -0,0 +1,89 @@
"use client";
import React, { useCallback } from "react";
import { PropsMenuProfile } from "./interface";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
import { PATH } from "@/constant/config";
function MenuProfile({ onClose }: PropsMenuProfile) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
/* ================== CHECK ACTIVE ================== */
const checkActive = useCallback(
(path: string) => {
return pathname === path;
},
[pathname],
);
const handleLogout = () => {
// logoutMutation.mutate();
};
return (
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
{/* PROFILE */}
<Link
href={PATH.Profile}
onClick={onClose}
className={clsx(
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
checkActive(PATH.Profile) && "bg-gray-100",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{/* <ShieldUser size={20} /> */}
<div>
<p className="text-sm font-medium">Thông tin nhân</p>
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
</div>
</Link>
<div className="h-px bg-gray-200 my-1" />
{/* LOGOUT */}
<div
onClick={() => {
handleLogout();
}}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5 text-red-500"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"
/>
</svg>
{/* <LogOut size={20} className="text-red-500" /> */}
<div>
<p className="text-sm font-medium text-red-500">Đăng xuất</p>
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
</div>
</div>
</div>
);
}
export default MenuProfile;
@@ -0,0 +1 @@
export { default } from "./MenuProfile";
@@ -0,0 +1,3 @@
export interface PropsMenuProfile {
onClose: () => void;
}
@@ -0,0 +1,72 @@
"use client";
import React, { useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Menus, PATH } from "@/constant/config";
import clsx from "clsx";
import Image from "next/image";
import icons from "@/constant/images/icons";
// import { Hospital } from "lucide-react";
const Navbar = () => {
const pathname = usePathname();
const checkActive = useCallback(
(pathActive: string) => {
// Handle root path "/" as dashboard
if (pathname === "/" && pathActive === "/continents") {
return true;
}
const currentRoute = pathname.split("/")[1];
return pathActive === `/${currentRoute}`;
},
[pathname],
);
return (
<div className="h-full w-full flex flex-col items-center p-3 bg-white">
<Link href={PATH.Home} className="flex items-center justify-center py-2">
<Image alt="Logo" src={icons.logoFull} width={140} height={140} />
</Link>
<div className="flex-1 w-full overflow-auto pt-3">
{Menus.map((menu, i) => (
<div key={i} className="mb-4">
{/* TITLE */}
<h5 className="text-xs font-semibold text-gray-400 uppercase mb-2">
{menu.title}
</h5>
<div className="space-y-1">
{menu.group.map((tab, y) => {
const isActive = checkActive(tab.pathActive);
return (
<Link
key={y}
href={tab.path}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg transition",
isActive
? "bg-indigo-100 text-blue-700"
: "text-gray-800 hover:bg-indigo-100 hover:text-blue-700",
)}
>
<tab.icon
size={22}
color={isActive ? "#1d4ed8" : "#374151"}
/>
<p className="text-base font-semibold">{tab.title}</p>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default Navbar;
@@ -0,0 +1 @@
export { default } from "./Navbar";
@@ -0,0 +1 @@
export { default } from "./BaseLayout";
@@ -0,0 +1,12 @@
export interface PropsBaseLayout {
children: React.ReactNode;
title: string;
breadcrumb?: React.ReactNode;
}
export interface TContextBaseLayout {
showFull?: boolean | undefined;
setShowFull?: (show: boolean) => void;
openMenuMobile?: boolean;
setOpenMenuMobile?: (show: boolean) => void;
}
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { GridColumnProps } from "./interface";
import clsx from "clsx";
const GridColumn = ({
children,
col = 3,
sm,
tabletCol3,
mobile2,
scroll20,
scrollMobile85,
className,
}: GridColumnProps) => {
return (
<div
className={clsx(
"grid w-full",
/* ===== GAP ===== */
sm ? "gap-2" : "gap-4",
/* ===== DEFAULT COL ===== */
{
"grid-cols-1": col === 1,
"grid-cols-2": col === 2,
"grid-cols-3": col === 3,
"grid-cols-4": col === 4,
"grid-cols-5": col === 5,
"grid-cols-6": col === 6,
"grid-cols-8": col === 8,
"grid-cols-12": col === 12,
},
/* ===== RESPONSIVE ===== */
"xl:grid-cols-3", // giống default SCSS fallback
"lg:grid-cols-2",
"md:grid-cols-2",
"sm:grid-cols-1",
/* ===== CUSTOM RULES ===== */
tabletCol3 && "lg:grid-cols-3",
mobile2 && "sm:grid-cols-2",
/* ===== COL 4 SPECIAL ===== */
col === 4 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
/* ===== COL 5 ===== */
col === 5 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
/* ===== SCROLL ===== */
scroll20 &&
"grid-flow-col auto-cols-[20%] overflow-x-auto snap-x snap-mandatory gap-2",
scrollMobile85 &&
"md:grid-cols-none md:grid-flow-col md:auto-cols-[85%] md:overflow-x-auto md:snap-x md:snap-mandatory",
className,
)}
>
{children}
</div>
);
};
export default GridColumn;
@@ -0,0 +1 @@
export { default } from "./GridColumn";
@@ -0,0 +1,14 @@
export interface GridColumnProps {
children: React.ReactNode;
col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12;
sm?: boolean;
tabletCol3?: boolean;
mobile2?: boolean;
scroll20?: boolean;
scrollMobile85?: boolean;
className?: string;
}
@@ -0,0 +1,40 @@
"use client";
import RequiredLogout from "@/components/protected/RequiredLogout";
import React from "react";
interface PropsLayoutAuth {
children: React.ReactNode;
}
function LayoutAuth({ children }: PropsLayoutAuth) {
return (
<RequiredLogout>
<div className="h-screen grid grid-cols-2 max-[1200px]:grid-cols-1">
{/* BACKGROUND */}
<div
className="
bg-no-repeat
bg-cover
bg-center
max-[1200px]:hidden bg-[url('/static/images/background_auth.jpg')]
"
/>
{/* MAIN */}
<main
className="
flex items-center bg-white
px-[200px]
max-[1600px]:px-[100px]
max-[768px]:px-[40px]
"
>
{children}
</main>
</div>
</RequiredLogout>
);
}
export default LayoutAuth;
@@ -0,0 +1 @@
export { default } from "./LayoutAuth";
@@ -0,0 +1,57 @@
"use client";
import React, { useCallback } from "react";
import { PropsLayoutPages } from "./interface";
import { usePathname } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
const LayoutPages = ({ children, listPages }: PropsLayoutPages) => {
const pathname = usePathname();
const checkActive = useCallback(
(url: string) => {
return pathname === url;
},
[pathname],
);
return (
<>
<div className="overflow-x-auto whitespace-nowrap pb-1 mb-5 scrollbar-thin">
{listPages.map((item, i) => {
const isActive = checkActive(item.url);
return (
<Link
key={i}
href={item.url}
onClick={(e) => {
if (isActive) e.preventDefault();
}}
className={clsx(
"inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer",
"text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]",
isActive
? "bg-[#3772ff] text-white border-[#3772ff]"
: "bg-white text-[#202939] border-[#eaedf2]",
i !== 0 && "ml-2",
)}
>
{" "}
<div className="flex items-center gap-2 justify-center">
<div
className="w-3 h-3 rounded-full"
style={{
background: isActive ? "#fff" : item.color,
}}
/>
<p>{item.title}</p>
</div>
</Link>
);
})}
</div>
{/* MAIN */}
<div>{children}</div>
</>
);
};
export default LayoutPages;
@@ -0,0 +1 @@
export { default } from "./LayoutPages";
@@ -0,0 +1,8 @@
export interface PropsLayoutPages {
children: React.ReactNode;
listPages: {
title: string;
url: string;
color: string;
}[];
}
@@ -0,0 +1,140 @@
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Edit } from "iconsax-react";
import { httpRequest } from "@/services";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import CustomButton from "@/components/customs/custom-button";
import StateActive from "@/components/customs/StateActive";
import airlinesServices, { AirlinesDetail } from "@/services/airlinesServices";
import { PATH } from "@/constant/config";
function DetailAirlines() {
const router = useRouter();
const params = useParams();
const airlinesId = params.id as string;
const airlinesDetailQuery = useQuery<AirlinesDetail>({
queryKey: [QUERY_KEY.detail_airlines, airlinesId],
enabled: !!airlinesId,
queryFn: async () => {
return await httpRequest<AirlinesDetail>({
showMessageFailed: true,
http: airlinesServices.detailAirlines(airlinesId),
});
},
});
const airlines = airlinesDetailQuery.data;
if (!airlines) {
return (
<div className="rounded-xl bg-white p-6 text-center">
Không tìm thấy thông tin hãng hàng không.
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h2
className="flex cursor-pointer items-center gap-2 text-[28px] font-semibold text-[#111827]"
onClick={() => router.back()}
>
<ArrowLeft size={32} color="#000000" />
{airlines.name}
</h2>
{airlines?.status === STATUS_CONTINENTS.ACTIVE && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Edit size={22} color="#fff" />}
onClick={() =>
router.push(`${PATH.Airlines}/update?id=${airlines.id}`)
}
>
Chỉnh sửa
</CustomButton>
)}
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<p className="text-[14px] font-medium text-[#697586]">
hãng hàng không
</p>
<p className="text-[15px] font-medium text-[#202939]">
{airlines.code}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
Tên hãng hàng không
</p>
<p className="text-[15px] font-medium text-[#202939]">
{airlines.name}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]"> IATA</p>
<p className="text-[15px] font-medium text-[#202939]">
{airlines.iataCode}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]"> ICAO</p>
<p className="text-[15px] font-medium text-[#202939]">
{airlines.icaoCode}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">Website</p>
<p className="text-[15px] font-medium text-[#202939]">
{airlines.website}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
Trạng thái
</p>
<StateActive
stateActive={airlines.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
</div>
</div>
</div>
</div>
</div>
);
}
export default DetailAirlines;
@@ -0,0 +1 @@
export { default } from "./DetailAirlines";
@@ -0,0 +1,345 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import CustomIcon from "@/components/customs/custom-icon";
import CustomPagination from "@/components/customs/custom-pagination";
import Search from "@/components/customs/custom-search";
import CustomTable from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import FilterCustom from "@/components/customs/FilterCustom";
import { PATH } from "@/constant/config";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import * as Tooltip from "@radix-ui/react-tooltip"; // Sửa lại cách import chuẩn của Radix UI
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Airplane, Edit, Eye, Trash } from "iconsax-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { Fragment, useState } from "react";
import StateActive from "@/components/customs/StateActive";
import countriesServices, {
CountriesResponse,
} from "@/services/countriesServices";
import airlinesServices, {
AirlinesItem,
AirlinesResponse,
} from "@/services/airlinesServices";
function MainPageAirlines() {
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const pathname = usePathname();
const [uuidDelete, setUuidDelete] = useState<string>("");
const action = searchParams.get("action");
const _uuidCountryRegion = searchParams.get("_uuidCountryRegion");
const page = Number(searchParams.get("_page") ?? "1");
const pageSize = Number(searchParams.get("_pageSize") ?? "10");
const keyword = searchParams.get("_keyword") ?? "";
const status = searchParams.get("_status");
const countriesId = searchParams.get("_countriesId");
// Nâng cấp hàm cập nhật Query URL để hỗ trợ cập nhật nhiều tham số cùng lúc
const updateQuery = (
updates: Record<string, string | number | null | undefined>,
replace = false,
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
});
const url = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
if (replace) {
router.replace(url);
} else {
router.push(url);
}
};
const airlinesQuery = useQuery<AirlinesResponse>({
queryKey: [QUERY_KEY.table_list_airline, page, pageSize, keyword, status],
queryFn: async () => {
return await httpRequest<AirlinesResponse>({
showMessageFailed: true,
http: airlinesServices.getAirlines({
page,
limit: pageSize,
search: keyword,
status: status || undefined, // active | inactive | undefined
countryId: countriesId,
}),
});
},
});
const countriesQuery = useQuery({
queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE],
queryFn: async () => {
const res = await httpRequest<CountriesResponse>({
http: countriesServices.getCountries({
status: STATUS_CONTINENTS.ACTIVE,
}),
showMessageFailed: true,
});
return res;
},
});
const deleteAirlineMutation = useMutation({
mutationFn: async (id: string) => {
return await httpRequest({
http: airlinesServices.deleteAirline(id),
showMessageSuccess: true,
msgSuccess: "Xóa hãng hàng không thành công!",
showMessageFailed: true,
});
},
onSuccess: () => {
setUuidDelete("");
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_airline],
});
},
onError: (error: any) => {
console.log(error);
},
});
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Hãng Hàng Không</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Airplane size="20" color="#f8f8f8" />}
href={PATH.CreateAirlines}
>
Thêm mới
</CustomButton>
</div>
{/* Bộ lọc & Tìm kiếm */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Search */}
<div className="lg:col-span-6">
<Search
keyword={keyword}
// SỬA: Khi tìm kiếm mới thì reset trang về trang 1
setKeyword={(value) => updateQuery({ _keyword: value, _page: 1 })}
placeholder="Tìm kiếm theo mã/iata/icao/tên"
/>
</div>
<div className="lg:col-span-3">
<FilterCustom<string | null>
name="Trạng thái"
value={status ? status : null}
onChange={(value) =>
updateQuery({ _status: value ?? "", _page: 1 })
}
listOption={[
{
uuid: STATUS_CONTINENTS.ACTIVE,
name: "Hoạt động",
},
{
uuid: STATUS_CONTINENTS.INACTIVE,
name: "Đã xóa",
},
]}
/>
</div>
<div className="lg:col-span-3">
<FilterCustom<string | null>
name="Quốc gia"
value={countriesId}
onChange={(value) =>
updateQuery({
_countriesId: value ?? "",
_page: 1,
})
}
listOption={
countriesQuery.data?.items?.map((item) => ({
uuid: item.id,
name: item.name,
})) ?? []
}
/>
</div>
</div>
</div>
{/* Bảng dữ liệu */}
<div className="rounded-lg bg-white p-6 shadow">
<DataWrapper
data={airlinesQuery.data?.items ?? []}
loading={airlinesQuery.isLoading}
title="Không tìm thấy yêu cầu nào"
note="Vui lòng kiểm tra lại sau hoặc thay đổi bộ lọc tìm kiếm"
>
<CustomTable
rowKey={(item) => item.id}
data={airlinesQuery.data?.items ?? []}
column={[
{
fixedLeft: true,
title: "Mã Hãng Hàng Không",
render: (item: AirlinesItem) => (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */}
<Link
href={`${PATH.Airlines}/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.code}
</Link>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="center">
<p className="p-2 bg-black text-white border rounded-lg text-xs shadow-md">
Xem chi tiết
</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
),
},
{
title: "Tên Hãng Hàng Không",
render: (item: AirlinesItem) => (
<span>{item.name || "---"}</span>
),
},
{
title: "Mã IATA",
render: (item: AirlinesItem) => (
<span>{item.iataCode || "---"}</span>
),
},
{
title: "Mã ICAO",
render: (item: AirlinesItem) => (
<span>{item.icaoCode || "---"}</span>
),
},
// {
// title: "LOGO",
// render: (item: AirlinesItem) => (
// <span>{item.logoUrl || "---"}</span>
// ),
// },
{
title: "Website",
render: (item: AirlinesItem) => (
<span>{item.website || "---"}</span>
),
},
{
title: "TRẠNG THÁI",
render: (item: AirlinesItem) => (
<StateActive
stateActive={item?.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
),
},
{
fixedRight: true,
title: "HÀNH ĐỘNG",
render: (item: AirlinesItem) => (
<div className="flex items-center gap-2">
<CustomIcon
href={`${PATH.Airlines}/${item?.id}`}
type="view"
icon={<Eye size="32" color="#0099FF" />}
tooltip="Xem chi tiết"
/>
{item?.status === STATUS_CONTINENTS.ACTIVE && (
<>
<CustomIcon
type="edit"
icon={<Edit size="32" color="#0099FF" />}
tooltip="Chỉnh sửa"
onClick={() =>
router.push(`${PATH.Airlines}/update?id=${item.id}`)
}
/>
<CustomIcon
type="delete"
icon={<Trash size="32" color="#FF0000" />}
tooltip="Xóa"
onClick={() => setUuidDelete(item?.id)}
/>
</>
)}
</div>
),
},
]}
/>
</DataWrapper>
{/* Phân trang */}
<CustomPagination
total={airlinesQuery.data?.total ?? 0}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery({ _page: value })}
onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })}
dependencies={[pageSize, keyword]}
/>
</div>
{/* Dialog xác nhận xóa */}
<CustomDialog
type="error"
open={!!uuidDelete}
onClose={() => setUuidDelete("")}
// SỬA: Đổi text liên quan từ "nhà thầu" thành "châu lục" để khớp ngữ cảnh của page
title="Xóa châu lục"
note="Bạn có chắc chắn muốn xóa hãng hàng không này?"
onSubmit={() => {
if (!uuidDelete) return;
deleteAirlineMutation.mutate(uuidDelete);
}}
/>
</div>
);
}
export default MainPageAirlines;
@@ -0,0 +1 @@
export { default } from "./MainPageAirlines";
@@ -0,0 +1,569 @@
// "use client";
// import { toastWarn } from "@/common/funcs/toast";
// import CustomButton from "@/components/customs/custom-button";
// import FormCustom from "@/components/utils/FormCustom";
// import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
// import SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm";
// import UploadImage from "@/components/utils/UploadImage";
// import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
// import { httpRequest } from "@/services";
// import airlinesServices from "@/services/airlinesServices";
// import countriesServices, {
// CountriesItem,
// CountriesResponse,
// } from "@/services/countriesServices";
// import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
// import { ArrowLeft } from "iconsax-react";
// import { useRouter } from "next/navigation";
// import React, { useMemo, useState } from "react";
// export interface ICreateAirlines {
// countryId: string;
// code: string;
// iataCode: string;
// icaoCode: string;
// name: string;
// logoUrl: string;
// website: string;
// }
// const PageCreateAirlines = () => {
// const router = useRouter();
// const queryClient = useQueryClient();
// const [file, setFile] = useState<File | null>(null);
// const [form, setForm] = useState<ICreateAirlines>({
// countryId: "",
// code: "",
// iataCode: "",
// icaoCode: "",
// name: "",
// logoUrl: "",
// website: "",
// });
// // Helper hàm onChangeValue cho InputForm nhận trực tiếp (fieldName, value)
// const handleChangeField = (
// field: keyof ICreateAirlines,
// value: string | number,
// ) => {
// setForm((prev) => ({
// ...prev,
// [field]: String(value),
// }));
// };
// // Query lấy danh sách quốc gia
// const countriesQuery = useQuery<CountriesResponse>({
// queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE],
// queryFn: async () => {
// return await httpRequest<CountriesResponse>({
// showMessageFailed: true,
// http: countriesServices.getCountries({
// status: STATUS_CONTINENTS.ACTIVE,
// }),
// });
// },
// });
// const countriesOptions = useMemo(() => {
// return countriesQuery.data?.items || [];
// }, [countriesQuery.data]);
// // Mutation thêm hãng hàng không
// const funcCreateAirlines = useMutation({
// mutationFn: async (payload: FormData) => {
// return await httpRequest({
// showMessageFailed: true,
// showMessageSuccess: true,
// msgSuccess: "Thêm hãng hàng không thành công!",
// http: airlinesServices.createAirlines(payload),
// });
// },
// onSuccess() {
// queryClient.invalidateQueries({
// queryKey: [QUERY_KEY.table_list_continents],
// });
// router.back();
// },
// });
// /* =========================
// SUBMIT HANDLER
// ========================= */
// const handleSubmit = () => {
// // Validate phía Frontend
// if (!form?.countryId) {
// return toastWarn({ msg: "Vui lòng chọn quốc gia!" });
// }
// if (!form?.code?.trim()) {
// return toastWarn({ msg: "Vui lòng nhập mã hãng!" });
// }
// if (!form?.name?.trim()) {
// return toastWarn({ msg: "Vui lòng nhập tên hãng!" });
// }
// if (!file) {
// return toastWarn({ msg: "Vui lòng tải lên logo!" });
// }
// const formData = new FormData();
// formData.append("countryId", form.countryId);
// formData.append("code", form.code.trim());
// formData.append("iataCode", form.iataCode.trim());
// formData.append("icaoCode", form.icaoCode.trim());
// formData.append("name", form.name.trim());
// formData.append("website", form.website.trim());
// // Thêm File logo
// formData.append("logoUrl", file);
// // Gửi request
// funcCreateAirlines.mutate(formData);
// };
// const isLoading = funcCreateAirlines.isPending;
// return (
// <FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
// <div className="rounded-lg bg-white shadow-xl flex flex-col">
// {/* HEADER */}
// <div className="flex items-center justify-between border-b px-7 py-5 bg-white rounded-t-lg">
// <h2
// className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
// onClick={() => router.back()}
// >
// <ArrowLeft size={32} color="#000000" /> Thêm Hãng Hàng Không
// </h2>
// <div className="flex gap-2 items-center justify-between">
// <CustomButton
// variant="grey"
// rounded="md"
// onClick={() => router.back()}
// disabled={isLoading}
// >
// Hủy bỏ
// </CustomButton>
// <CustomButton
// variant="midnightBlue"
// rounded="md"
// onClick={handleSubmit}
// disabled={isLoading}
// >
// {isLoading ? "Đang xử lý..." : "Thêm hãng"}
// </CustomButton>
// </div>
// </div>
// {/* BODY */}
// <div className="flex-1 px-7 py-5 custom-scrollbar">
// {/* SELECT QUỐC GIA */}
// <div className="space-y-5 mb-4">
// <SelectForm
// label={
// <span>
// Quốc gia <span className="text-red-500">*</span>
// </span>
// }
// placeholder="Chọn quốc gia"
// value={form.countryId}
// options={countriesOptions}
// onSelect={(item: CountriesItem) =>
// setForm((prev) => ({
// ...prev,
// countryId: item.id,
// }))
// }
// onClean={() =>
// setForm((prev) => ({
// ...prev,
// countryId: "",
// }))
// }
// getOptionLabel={(item: CountriesItem) => item.name}
// getOptionValue={(item: CountriesItem) => item.id}
// />
// </div>
// {/* MÃ HÃNG */}
// <div className="mt-6 max-md:mt-5 max-sm:mt-4">
// <InputForm
// placeholder="Nhập mã hãng"
// name="code"
// type="text"
// isRequired
// max={15}
// value={form.code}
// onChangeValue={(val) => handleChangeField("code", val)}
// label={
// <span>
// Mã hãng <span className="text-red-500">*</span>
// </span>
// }
// />
// </div>
// {/* TÊN HÃNG */}
// <div className="mt-6 max-md:mt-5 max-sm:mt-4">
// <InputForm
// placeholder="Nhập tên hãng"
// name="name"
// type="text"
// isRequired
// max={100}
// value={form.name}
// onChangeValue={(val) => handleChangeField("name", val)}
// label={
// <span>
// Tên hãng <span className="text-red-500">*</span>
// </span>
// }
// />
// </div>
// {/* MÃ IATA VÀ ICAO */}
// <div className="flex gap-2 mt-6 max-md:mt-5 max-sm:mt-4">
// <InputForm
// placeholder="Nhập mã IATA"
// name="iataCode"
// type="text"
// max={2}
// value={form.iataCode}
// onChangeValue={(val) => handleChangeField("iataCode", val)}
// label={<span>Mã IATA</span>}
// />
// <InputForm
// placeholder="Nhập mã ICAO"
// name="icaoCode"
// type="text"
// max={3}
// value={form.icaoCode}
// onChangeValue={(val) => handleChangeField("icaoCode", val)}
// label={<span>Mã ICAO</span>}
// />
// </div>
// {/* WEBSITE */}
// <div className="mt-6 max-md:mt-5 max-sm:mt-4">
// <InputForm
// placeholder="Nhập website"
// name="website"
// type="text"
// value={form.website}
// onChangeValue={(val) => handleChangeField("website", val)}
// label={<span>WEBSITE</span>}
// />
// </div>
// {/* UPLOAD LOGO */}
// <div className="mt-6 max-md:mt-5 max-sm:mt-4">
// <UploadImage
// label={
// <span>
// Hình ảnh logo <span style={{ color: "red" }}>*</span>
// </span>
// }
// name="logoUrl"
// file={file}
// setFile={setFile}
// path={form.logoUrl}
// resetPath={() =>
// setForm((prev) => ({
// ...prev,
// logoUrl: "",
// }))
// }
// />
// </div>
// </div>
// </div>
// </FormCustom>
// );
// };
// export default PageCreateAirlines;
"use client";
import { toastWarn } from "@/common/funcs/toast";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
import SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm";
import UploadImage from "@/components/utils/UploadImage";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import airlinesServices from "@/services/airlinesServices";
import countriesServices, {
CountriesItem,
CountriesResponse,
} from "@/services/countriesServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "iconsax-react";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
export interface ICreateAirlines {
countryId: string;
code: string;
iataCode: string;
icaoCode: string;
name: string;
logoUrl: string;
website: string;
}
const PageCreateAirlines = () => {
const router = useRouter();
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [form, setForm] = useState<ICreateAirlines>({
countryId: "",
code: "",
iataCode: "",
icaoCode: "",
name: "",
logoUrl: "",
website: "",
});
// Helper hàm onChangeValue cho InputForm nhận trực tiếp (fieldName, value)
const handleChangeField = (
field: keyof ICreateAirlines,
value: string | number,
) => {
setForm((prev) => ({
...prev,
[field]: String(value),
}));
};
// Query lấy danh sách quốc gia
const countriesQuery = useQuery<CountriesResponse>({
queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE],
queryFn: async () => {
return await httpRequest<CountriesResponse>({
showMessageFailed: true,
http: countriesServices.getCountries({
status: STATUS_CONTINENTS.ACTIVE,
}),
});
},
});
const countriesOptions = useMemo(() => {
return countriesQuery.data?.items || [];
}, [countriesQuery.data]);
// Mutation thêm hãng hàng không (Gửi JSON Payload)
const funcCreateAirlines = useMutation({
mutationFn: async (payload: ICreateAirlines) => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Thêm hãng hàng không thành công!",
http: airlinesServices.createAirlines(payload as any),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_continents],
});
router.back();
},
});
/* =========================
SUBMIT HANDLER
========================= */
const handleSubmit = () => {
// Validate phía Frontend
if (!form?.countryId) {
return toastWarn({ msg: "Vui lòng chọn quốc gia!" });
}
if (!form?.code?.trim()) {
return toastWarn({ msg: "Vui lòng nhập mã hãng!" });
}
if (!form?.name?.trim()) {
return toastWarn({ msg: "Vui lòng nhập tên hãng!" });
}
// Tạo payload dạng Object JSON thuần túy
const payload: ICreateAirlines = {
countryId: form.countryId,
code: form.code.trim(),
iataCode: form.iataCode.trim(),
icaoCode: form.icaoCode.trim(),
name: form.name.trim(),
website: form.website.trim(),
// Luôn truyền chuỗi "string" (hoặc truyền form.logoUrl nếu người dùng có nhập)
logoUrl: form.logoUrl.trim() || "string",
};
// Gửi request
funcCreateAirlines.mutate(payload);
};
const isLoading = funcCreateAirlines.isPending;
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="rounded-lg bg-white shadow-xl flex flex-col">
{/* HEADER */}
<div className="flex items-center justify-between border-b px-7 py-5 bg-white rounded-t-lg">
<h2
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft size={32} color="#000000" /> Thêm Hãng Hàng Không
</h2>
<div className="flex gap-2 items-center justify-between">
<CustomButton
variant="grey"
rounded="md"
onClick={() => router.back()}
disabled={isLoading}
>
Hủy bỏ
</CustomButton>
<CustomButton
variant="midnightBlue"
rounded="md"
onClick={handleSubmit}
disabled={isLoading}
>
{isLoading ? "Đang xử lý..." : "Thêm hãng"}
</CustomButton>
</div>
</div>
{/* BODY */}
<div className="flex-1 px-7 py-5 custom-scrollbar">
{/* SELECT QUỐC GIA */}
<div className="space-y-5 mb-4">
<SelectForm
label={
<span>
Quốc gia <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn quốc gia"
value={form.countryId}
options={countriesOptions}
onSelect={(item: CountriesItem) =>
setForm((prev) => ({
...prev,
countryId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
countryId: "",
}))
}
getOptionLabel={(item: CountriesItem) => item.name}
getOptionValue={(item: CountriesItem) => item.id}
/>
</div>
{/* MÃ HÃNG */}
<div className="mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập mã hãng"
name="code"
type="text"
isRequired
max={15}
value={form.code}
onChangeValue={(val) => handleChangeField("code", val)}
label={
<span>
hãng <span className="text-red-500">*</span>
</span>
}
/>
</div>
{/* TÊN HÃNG */}
<div className="mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập tên hãng"
name="name"
type="text"
isRequired
max={100}
value={form.name}
onChangeValue={(val) => handleChangeField("name", val)}
label={
<span>
Tên hãng <span className="text-red-500">*</span>
</span>
}
/>
</div>
{/* MÃ IATA VÀ ICAO */}
<div className="flex gap-2 mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập mã IATA"
name="iataCode"
type="text"
max={2}
value={form.iataCode}
onChangeValue={(val) => handleChangeField("iataCode", val)}
label={<span> IATA</span>}
/>
<InputForm
placeholder="Nhập mã ICAO"
name="icaoCode"
type="text"
max={3}
value={form.icaoCode}
onChangeValue={(val) => handleChangeField("icaoCode", val)}
label={<span> ICAO</span>}
/>
</div>
{/* WEBSITE */}
<div className="mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập website"
name="website"
type="text"
value={form.website}
onChangeValue={(val) => handleChangeField("website", val)}
label={<span>WEBSITE</span>}
/>
</div>
{/* UPLOAD LOGO */}
{/* <div className="mt-6 max-md:mt-5 max-sm:mt-4">
<UploadImage
label={
<span>
Hình ảnh logo <span style={{ color: "red" }}>*</span>
</span>
}
name="logoUrl"
file={file}
setFile={setFile}
path={form.logoUrl}
resetPath={() =>
setForm((prev) => ({
...prev,
logoUrl: "",
}))
}
/>
</div> */}
</div>
</div>
</FormCustom>
);
};
export default PageCreateAirlines;
@@ -0,0 +1,338 @@
"use client";
import { toastSuccess, toastWarn } from "@/common/funcs/toast";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
import SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm";
import UploadImage from "@/components/utils/UploadImage";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import airlinesServices, { AirlinesDetail } from "@/services/airlinesServices";
import countriesServices, {
CountriesItem,
CountriesResponse,
} from "@/services/countriesServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "iconsax-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState, Suspense } from "react";
// Hàm làm sạch dữ liệu URL từ API
const extractImageUrl = (value: any): string => {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
return value.url || value.path || value.link || value.src || "";
}
return "";
};
export interface IUpdateAirlines {
countryId: string;
code: string;
iataCode: string;
icaoCode: string;
name: string;
logoUrl: string;
website: string;
}
export default function PageUpdateAirlines() {
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const airlinesId = searchParams.get("id") || "";
const [file, setFile] = useState<File | null>(null);
const [form, setForm] = useState<IUpdateAirlines>({
countryId: "",
code: "",
iataCode: "",
icaoCode: "",
name: "",
logoUrl: "string",
website: "",
});
const { data, isLoading } = useQuery<AirlinesDetail>({
queryKey: [QUERY_KEY.detail_airlines, airlinesId],
enabled: !!airlinesId,
queryFn: async () => {
return await httpRequest<AirlinesDetail>({
http: airlinesServices.detailAirlines(airlinesId),
});
},
});
useEffect(() => {
if (!data) return;
const safeLogoUrl = extractImageUrl(data.logoUrl);
setForm({
countryId: data?.countryId || "",
code: data?.code || "",
iataCode: data?.iataCode || "",
icaoCode: data?.icaoCode || "",
name: data?.name || "",
logoUrl: safeLogoUrl,
website: data?.website || "",
});
}, [data]);
const countriesQuery = useQuery<CountriesResponse>({
queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE],
queryFn: async () => {
return await httpRequest<CountriesResponse>({
showMessageFailed: true,
http: countriesServices.getCountries({
status: STATUS_CONTINENTS.ACTIVE,
}),
});
},
});
const countriesOptions = useMemo(() => {
return countriesQuery.data?.items || [];
}, [countriesQuery.data]);
/* =========================================================
MUTATION: SỬ DỤNG FORMDATA ĐỂ TRÁNH LỖI OVER 500 CHARACTERS
========================================================= */
const funcUpdateAirlines = useMutation({
mutationFn: async () => {
const formData = new FormData();
formData.append("countryId", form.countryId);
formData.append("code", form.code);
formData.append("name", form.name);
if (form.iataCode) formData.append("iataCode", form.iataCode);
if (form.icaoCode) formData.append("icaoCode", form.icaoCode);
if (form.website) formData.append("website", form.website);
// Nếu người dùng chọn FILE MỚI -> gửi File
// Nếu không chọn file mới -> gửi lại URL CŨ dạng string
if (file) {
formData.append("logoUrl", file);
} else if (form.logoUrl) {
formData.append("logoUrl", form.logoUrl);
}
return await httpRequest({
showMessageFailed: false,
http: airlinesServices.updateAirlines(airlinesId, formData as any),
});
},
onSuccess: () => {
toastSuccess({
msg: "Chỉnh sửa hãng thành công!",
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_airline],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.detail_airlines, airlinesId],
});
router.back();
},
onError: (error: any) => {
toastWarn({
msg:
error?.response?.data?.error?.message ??
error?.message ??
"Có lỗi xảy ra khi cập nhật.",
});
},
});
const handleSubmit = () => {
if (!form?.countryId) {
return toastWarn({ msg: "Vui lòng chọn quốc gia!" });
}
if (!form?.code?.trim()) {
return toastWarn({ msg: "Vui lòng nhập mã hãng!" });
}
if (!form?.name?.trim()) {
return toastWarn({ msg: "Vui lòng nhập tên hãng!" });
}
if (!file && !form?.logoUrl) {
return toastWarn({ msg: "Vui lòng tải lên logo!" });
}
funcUpdateAirlines.mutate();
};
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
router.back();
};
if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center text-gray-500 font-medium">
Đang tải thông tin hãng hàng không...
</div>
);
}
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="rounded-lg bg-white shadow-xl flex flex-col">
<div className="flex items-center justify-between border-b px-7 py-5 bg-white rounded-t-lg">
<div
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={handleBack}
>
<ArrowLeft size={32} color="#000000" />
<span>Chỉnh Sửa Hãng Hàng Không</span>
</div>
<div className="flex gap-2 items-center justify-between">
<CustomButton
variant="grey"
rounded="md"
onClick={handleBack}
type="button"
>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={!form?.countryId || funcUpdateAirlines.isPending}
variant="midnightBlue"
rounded="md"
onClick={handleSubmit}
type="button"
>
{funcUpdateAirlines.isPending ? "Đang lưu..." : "Lưu thay đổi"}
</CustomButton>
</div>
</div>
<div className="flex-1 px-7 py-5 custom-scrollbar">
{/* SELECT QUỐC GIA */}
<div className="space-y-5 mb-4">
<SelectForm
label={
<span>
Quốc gia <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn quốc gia"
value={form.countryId}
options={countriesOptions}
onSelect={(item: CountriesItem) =>
setForm((prev) => ({ ...prev, countryId: item.id }))
}
onClean={() => setForm((prev) => ({ ...prev, countryId: "" }))}
getOptionLabel={(item: CountriesItem) => item.name}
getOptionValue={(item: CountriesItem) => item.id}
/>
</div>
{/* MÃ HÃNG */}
<div className="mt-6">
<InputForm
placeholder="Nhập mã hãng"
name="code"
type="text"
isRequired
max={15}
value={form.code}
onChangeValue={(val) =>
setForm((prev) => ({ ...prev, code: String(val) }))
}
label={
<span>
hãng <span className="text-red-500">*</span>
</span>
}
/>
</div>
{/* TÊN HÃNG */}
<div className="mt-6">
<InputForm
placeholder="Nhập tên hãng"
name="name"
type="text"
isRequired
max={100}
value={form.name}
onChangeValue={(val) =>
setForm((prev) => ({ ...prev, name: String(val) }))
}
label={
<span>
Tên hãng <span className="text-red-500">*</span>
</span>
}
/>
</div>
{/* MÃ IATA VÀ ICAO */}
<div className="flex gap-2 mt-6">
<InputForm
placeholder="Nhập mã IATA"
name="iataCode"
type="text"
max={2}
value={form.iataCode}
onChangeValue={(val) =>
setForm((prev) => ({ ...prev, iataCode: String(val) }))
}
label={<span> IATA</span>}
/>
<InputForm
placeholder="Nhập mã ICAO"
name="icaoCode"
type="text"
max={3}
value={form.icaoCode}
onChangeValue={(val) =>
setForm((prev) => ({ ...prev, icaoCode: String(val) }))
}
label={<span> ICAO</span>}
/>
</div>
{/* WEBSITE */}
<div className="mt-6">
<InputForm
placeholder="Nhập website"
name="website"
type="text"
value={form.website}
onChangeValue={(val) =>
setForm((prev) => ({ ...prev, website: String(val) }))
}
label={<span>WEBSITE</span>}
/>
</div>
{/* UPLOAD LOGO */}
{/* <div className="mt-6">
<UploadImage
label={
<span>
Hình ảnh logo <span style={{ color: "red" }}>*</span>
</span>
}
name="logoUrl"
file={file}
setFile={setFile}
path={form.logoUrl}
resetPath={() => setForm((prev) => ({ ...prev, logoUrl: "" }))}
/>
</div> */}
</div>
</div>
</FormCustom>
);
}
@@ -0,0 +1 @@
export { default } from "./PageUpdateAirlines";
@@ -0,0 +1,158 @@
"use client";
import { toastWarn } from "@/common/funcs/toast";
import CustomButton from "@/components/customs/custom-button";
import CustomLoading from "@/components/customs/custom-loading";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import continentsServices from "@/services/continentsServices";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CloseCircle, FolderOpen } from "iconsax-react";
import React, { useState } from "react";
export interface PropsCreateContinents {
onClose: () => void;
}
export interface IFormCreateContinents {
code: string;
name: string;
}
function CreateContinents({ onClose }: PropsCreateContinents) {
const queryClient = useQueryClient();
const [form, setForm] = useState<IFormCreateContinents>({
code: "",
name: "",
});
const funcCreateContinent = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Thêm châu lục thành công!",
http: continentsServices.createContinents({
code: form?.code,
name: form?.name,
sortOrder: 0,
status: "ACTIVE",
}),
});
},
onError(error: any) {
// Lấy message lỗi từ backend trả về
const message = error?.response?.data?.error?.message || error?.message;
// Bật thông báo Toast dạng cảnh báo
toastWarn({ msg: message });
},
onSuccess() {
/* REFRESH LIST */
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_continents],
});
onClose();
},
});
const handleSubmit = () => {
const cleanCode = form.code?.trim();
const cleanName = form.name?.trim();
if (!cleanCode) {
return toastWarn({ msg: "Vui lòng nhập mã châu lục!" });
}
if (!cleanName) {
return toastWarn({ msg: "Vui lòng nhập tên châu lục!" });
}
// Cập nhật lại state chuẩn trước khi gửi
setForm({ code: cleanCode, name: cleanName });
return funcCreateContinent.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<CustomLoading loading={funcCreateContinent.isPending} />
<div className="relative flex h-screen w-[540px] flex-col bg-white max-md:w-full">
{/* Header */}
<h4 className="px-6 py-6 text-2xl font-semibold text-[#2F3D50] max-md:px-5 max-md:py-5 max-sm:px-4 max-sm:py-4">
Thêm Mới Châu Lục
</h4>
{/* Form */}
<div className="flex-1 overflow-y-auto rounded-2xl bg-[#F4F5F6] p-6 max-md:p-5 max-sm:p-4">
<InputForm
placeholder="Nhập mã châu lục"
name="code"
type="text"
value={form.code}
isRequired
max={15}
label={
<span>
châu lục <span className="text-red-500">*</span>
</span>
}
/>
<div className="mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập tên châu lục"
name="name"
type="text"
value={form.name}
isRequired
max={255}
label={
<span>
Tên châu lục <span className="text-red-500">*</span>
</span>
}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 max-md:p-5 max-sm:p-4">
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone}
variant="midnightBlue"
rounded="md"
type="submit"
icon={<FolderOpen size={18} color="#fff" />}
onClick={handleSubmit}
>
Lưu lại
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
{/* Close */}
<button
type="button"
onClick={onClose}
className="absolute right-5 top-5 select-none transition active:scale-90"
>
<CloseCircle size={28} color="#8492A6" />
</button>
</div>
</FormCustom>
);
}
export default CreateContinents;
@@ -0,0 +1 @@
export { default } from "./CreateContinents";
@@ -0,0 +1,187 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import StateActive from "@/components/customs/StateActive";
import PositionContainer from "@/components/utils/PositionContainer";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import continentsServices, {
ContinentsDetail,
} from "@/services/continentsServices";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Edit } from "iconsax-react";
import {
useParams,
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";
import React from "react";
import UpdateContinents from "../UpdateContinents";
function DetailContinent() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const continentId = params?.id as string;
const _uuidContinent = searchParams.get("_uuidContinent");
const updateQuery = (
updates: Record<string, string | number | null | undefined>,
replace = false,
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
});
const url = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
if (replace) {
router.replace(url);
} else {
router.push(url);
}
};
const ContinentDetailQuery = useQuery<ContinentsDetail>({
queryKey: [QUERY_KEY.detail_continent, continentId],
enabled: !!continentId,
queryFn: async () => {
const res = await httpRequest<ContinentsDetail>({
showMessageFailed: true,
http: continentsServices.detailContinents(continentId),
});
return (
res || {
id: "",
code: "",
name: "",
status: "string",
sortOrder: 0,
createdAt: "",
updatedAt: "",
}
);
},
});
const continent = ContinentDetailQuery.data;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft size={32} color="#000000" /> {continent?.name}
</h2>
{continent?.status === STATUS_CONTINENTS.ACTIVE && (
<div>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Edit size="24" color="#ffff" />}
onClick={() =>
updateQuery(
{
_uuidContinent: continent?.id,
},
true,
)
}
>
Chỉnh sửa
</CustomButton>
</div>
)}
</div>
{/* Thông tin chi tiết bệnh nhân */}
<div className="grid grid-cols-3 gap-5">
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
châu lục
</p>
<p className="text-[15px] font-medium text-[#202939]">
{continent?.code}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Tên châu lục
</p>
<p className="text-[15px] font-medium text-[#202939]">
{continent?.name}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
TRẠNG THÁI:
</p>
<StateActive
stateActive={continent?.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
</div>
</div>
</div>
</div>
</div>
<PositionContainer
open={!!_uuidContinent}
onClose={() =>
updateQuery(
{
_uuidContinent: null,
},
true,
)
}
>
{_uuidContinent && (
<UpdateContinents
onClose={() =>
updateQuery(
{
_uuidContinent: null,
},
true,
)
}
/>
)}
</PositionContainer>
</div>
);
}
export default DetailContinent;
@@ -0,0 +1 @@
export { default } from "./DetailContinent";
@@ -0,0 +1,345 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import CustomIcon from "@/components/customs/custom-icon";
import CustomPagination from "@/components/customs/custom-pagination";
import Search from "@/components/customs/custom-search";
import CustomTable from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import FilterCustom from "@/components/customs/FilterCustom";
import PositionContainer from "@/components/utils/PositionContainer";
import { PATH } from "@/constant/config";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import continentsServices, {
ContinentsItem,
ContinentsResponse,
} from "@/services/continentsServices";
import * as Tooltip from "@radix-ui/react-tooltip"; // Sửa lại cách import chuẩn của Radix UI
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit, Eye, Global, Trash } from "iconsax-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { Fragment, useState } from "react";
import CreateContinents from "../CreateContinents";
import StateActive from "@/components/customs/StateActive";
import UpdateContinents from "../UpdateContinents";
function MainPageContinents() {
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const pathname = usePathname();
const [uuidDelete, setUuidDelete] = useState<string>("");
const action = searchParams.get("action");
const _uuidContinent = searchParams.get("_uuidContinent");
const page = Number(searchParams.get("_page") ?? "1");
const pageSize = Number(searchParams.get("_pageSize") ?? "10");
const keyword = searchParams.get("_keyword") ?? "";
const status = searchParams.get("_status");
// Nâng cấp hàm cập nhật Query URL để hỗ trợ cập nhật nhiều tham số cùng lúc
const updateQuery = (
updates: Record<string, string | number | null | undefined>,
replace = false,
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
});
const url = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
if (replace) {
router.replace(url);
} else {
router.push(url);
}
};
const continentQuery = useQuery<ContinentsResponse>({
queryKey: [
QUERY_KEY.table_list_continents,
page,
pageSize,
keyword,
status,
],
queryFn: async () => {
return await httpRequest<ContinentsResponse>({
showMessageFailed: true,
http: continentsServices.getContinents({
page,
limit: pageSize,
search: keyword,
status: status || undefined, // active | inactive | undefined
}),
});
},
});
const deleteContinentMutation = useMutation({
mutationFn: async (id: string) => {
return await httpRequest({
http: continentsServices.deleteContinents(id),
showMessageSuccess: true,
msgSuccess: "Xóa châu lục thành công!",
showMessageFailed: true,
});
},
onSuccess: () => {
setUuidDelete("");
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_continents],
});
},
onError: (error: any) => {
console.log(error);
},
});
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Châu Lục</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Global size="20" color="#f8f8f8" />}
onClick={() => {
// SỬA: Điều hướng chuẩn App Router bằng cách cập nhật query string
updateQuery({ action: "create" });
}}
>
Thêm mới
</CustomButton>
</div>
{/* Bộ lọc & Tìm kiếm */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={keyword}
// SỬA: Khi tìm kiếm mới thì reset trang về trang 1
setKeyword={(value) => updateQuery({ _keyword: value, _page: 1 })}
placeholder="Tìm kiếm theo tên, ID châu lục"
/>
</div>
<FilterCustom<string | null>
name="Trạng thái"
value={status ? status : null}
onChange={(value) =>
updateQuery({ _status: value ?? "", _page: 1 })
}
listOption={[
{
uuid: STATUS_CONTINENTS.ACTIVE,
name: "Hoạt động",
},
{
uuid: STATUS_CONTINENTS.INACTIVE,
name: "Đã xóa",
},
]}
/>
</div>
</div>
{/* Bảng dữ liệu */}
<div className="rounded-lg bg-white p-6 shadow">
<DataWrapper
data={continentQuery.data?.items ?? []}
loading={continentQuery.isLoading}
title="Không tìm thấy yêu cầu nào"
note="Vui lòng kiểm tra lại sau hoặc thay đổi bộ lọc tìm kiếm"
>
<CustomTable
rowKey={(item) => item.id}
data={continentQuery.data?.items ?? []}
column={[
{
fixedLeft: true,
title: "MÃ CHÂU LỤC",
render: (item: ContinentsItem) => (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */}
<Link
href={`${PATH.Continent}/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.code}
</Link>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="center">
<p className="p-2 bg-black text-white border rounded-lg text-xs shadow-md">
Xem chi tiết
</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
),
},
{
title: "TÊN CHÂU LỤC",
render: (item: ContinentsItem) => (
<span>{item.name || "---"}</span>
),
},
{
title: "TRẠNG THÁI",
render: (item: ContinentsItem) => (
<StateActive
stateActive={item?.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
),
},
{
fixedRight: true,
title: "HÀNH ĐỘNG",
render: (item: ContinentsItem) => (
<div className="flex items-center gap-2">
<CustomIcon
href={`${PATH.Continent}/${item?.id}`}
type="view"
icon={<Eye size="32" color="#0099FF" />}
tooltip="Xem chi tiết"
/>
{item?.status === STATUS_CONTINENTS.ACTIVE && (
<>
<CustomIcon
type="edit"
icon={<Edit size="32" color="#0099FF" />}
tooltip="Chỉnh sửa"
onClick={() =>
updateQuery(
{
_uuidContinent: item.id,
},
true,
)
}
/>
<CustomIcon
type="delete"
icon={<Trash size="32" color="#FF0000" />}
tooltip="Xóa"
onClick={() => setUuidDelete(item?.id)}
/>
</>
)}
</div>
),
},
]}
/>
</DataWrapper>
{/* Phân trang */}
<CustomPagination
total={continentQuery.data?.total ?? 0}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery({ _page: value })}
onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })}
dependencies={[pageSize, keyword]}
/>
</div>
<PositionContainer
open={action === "create"}
onClose={() =>
updateQuery(
{
action: null,
},
true,
)
}
>
{action === "create" && (
<CreateContinents
onClose={() =>
updateQuery(
{
action: null,
},
true,
)
}
/>
)}
</PositionContainer>
<PositionContainer
open={!!_uuidContinent}
onClose={() =>
updateQuery(
{
_uuidContinent: null,
},
true,
)
}
>
{_uuidContinent && (
<UpdateContinents
onClose={() =>
updateQuery(
{
_uuidContinent: null,
},
true,
)
}
/>
)}
</PositionContainer>
{/* Dialog xác nhận xóa */}
<CustomDialog
type="error"
open={!!uuidDelete}
onClose={() => setUuidDelete("")}
// SỬA: Đổi text liên quan từ "nhà thầu" thành "châu lục" để khớp ngữ cảnh của page
title="Xóa châu lục"
note="Bạn có chắc chắn muốn xóa châu lục này?"
onSubmit={() => {
if (!uuidDelete) return;
deleteContinentMutation.mutate(uuidDelete);
}}
/>
</div>
);
}
export default MainPageContinents;
@@ -0,0 +1 @@
export { default } from "./MainPageContinents";
@@ -0,0 +1,180 @@
"use client";
import React, { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import continentsServices, {
ContinentsDetail,
} from "@/services/continentsServices";
import FormCustom from "@/components/utils/FormCustom";
import CustomLoading from "@/components/customs/custom-loading";
import { toastWarn, toastSuccess } from "@/common/funcs/toast";
import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
import CustomButton from "@/components/customs/custom-button";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { CloseCircle, FolderOpen } from "iconsax-react";
export interface PropsUpdateContinents {
onClose: () => void;
}
export interface IUpdateContinents {
name: string;
code: string;
}
export default function UpdateContinents({ onClose }: PropsUpdateContinents) {
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const uuidContinent = searchParams.get("_uuidContinent");
const [form, setForm] = useState<IUpdateContinents>({
name: "",
code: "",
});
const { data, isLoading, isError } = useQuery<ContinentsDetail>({
queryKey: [QUERY_KEY.detail_continent, uuidContinent],
enabled: !!uuidContinent,
queryFn: async () => {
return await httpRequest<ContinentsDetail>({
http: continentsServices.detailContinents(uuidContinent!),
});
},
});
useEffect(() => {
if (!data) return;
setForm({
name: data.name ?? "",
code: data.code ?? "",
});
}, [data]);
const updateContinentMutation = useMutation({
mutationFn: async () => {
if (!uuidContinent) {
throw new Error("Không tìm thấy mã châu lục.");
}
return await httpRequest({
showMessageFailed: false,
http: continentsServices.updateContinents(uuidContinent as string, {
name: form.name,
code: form.code,
}),
});
},
onSuccess: () => {
toastSuccess({
msg: "Cập nhật châu lục thành công!",
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_continents],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.detail_continent, uuidContinent],
});
onClose();
},
onError: (error: any) => {
toastWarn({
msg:
error?.response?.data?.error?.message ??
error?.message ??
"Có lỗi xảy ra.",
});
},
});
const handleSubmit = () => {
updateContinentMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<CustomLoading loading={updateContinentMutation.isPending} />
<div className="relative flex h-screen w-[540px] flex-col bg-white max-md:w-full">
{/* Header */}
<h4 className="px-6 py-6 text-2xl font-semibold text-[#2F3D50] max-md:px-5 max-md:py-5 max-sm:px-4 max-sm:py-4">
Chỉnh sửa châu lục
</h4>
{/* Form */}
<div className="flex-1 overflow-y-auto rounded-2xl bg-[#F4F5F6] p-6 max-md:p-5 max-sm:p-4">
<InputForm
placeholder="Nhập mã châu lục"
name="code"
type="text"
value={form.code}
isRequired
max={15}
label={
<span>
châu lục <span className="text-red-500">*</span>
</span>
}
/>
<div className="mt-6 max-md:mt-5 max-sm:mt-4">
<InputForm
placeholder="Nhập tên châu lục"
name="name"
type="text"
value={form.name}
isRequired
max={255}
label={
<span>
Tên châu lục <span className="text-red-500">*</span>
</span>
}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 max-md:p-5 max-sm:p-4">
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone}
variant="midnightBlue"
rounded="md"
type="submit"
icon={<FolderOpen size={18} color="#fff" />}
onClick={handleSubmit}
>
Lưu lại
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
{/* Close */}
<button
type="button"
onClick={onClose}
className="absolute right-5 top-5 select-none transition active:scale-90"
>
<CloseCircle size={28} color="#8492A6" />
</button>
</div>
</FormCustom>
);
}
@@ -0,0 +1 @@
export { default } from "./UpdateContinents";
@@ -0,0 +1,354 @@
import { toastWarn } from "@/common/funcs/toast";
import CustomButton from "@/components/customs/custom-button";
import GridColumn from "@/components/layouts/GridColumn";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/compoents/InputForm/InputForm";
import SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm";
import { COUNTRY_OPTIONS } from "@/constant/config/country_option";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import continentsServices, {
ContinentsItem,
ContinentsResponse,
} from "@/services/continentsServices";
import countriesServices, { CountriesItem } from "@/services/countriesServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "iconsax-react";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
export interface ICreateCountries {
continentId: string;
code: string;
name: string;
iso2Code: string;
iso3Code: string;
phoneCode: string;
currencyCode: string;
timezone: string;
}
const CreateCountries = () => {
const router = useRouter();
const queryClient = useQueryClient();
const [form, setForm] = useState<ICreateCountries>({
continentId: "",
code: "",
name: "",
iso2Code: "",
iso3Code: "",
phoneCode: "",
currencyCode: "",
timezone: "",
});
/* =========================
GET LIST SPECIALTY
========================= */
const continentQuery = useQuery<ContinentsResponse>({
queryKey: [QUERY_KEY.table_list_continents, STATUS_CONTINENTS.ACTIVE],
queryFn: async () => {
return await httpRequest<ContinentsResponse>({
showMessageFailed: true,
http: continentsServices.getContinents({
status: STATUS_CONTINENTS.ACTIVE, // active | inactive | undefined
}),
});
},
});
const continentOptions = useMemo(() => {
return continentQuery.data?.items || [];
}, [continentQuery.data]);
/* =========================
CREATE CONSULTATION
========================= */
const funcCreateCountries = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Thêm quốc gia thành công!",
http: countriesServices.createCountries({
continentId: form?.continentId,
code: form?.code,
name: form?.name,
iso2Code: form?.iso2Code,
iso3Code: form?.iso3Code,
phoneCode: form?.phoneCode,
currencyCode: form?.currencyCode,
timezone: form?.timezone,
}),
});
},
onError(error: any) {
// Lấy message lỗi từ backend trả về
const message = error?.response?.data?.error?.message || error?.message;
// Bật thông báo Toast dạng cảnh báo
toastWarn({ msg: message });
},
onSuccess() {
/* REFRESH LIST */
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_continents],
});
router.back();
},
});
const handleSubmit = () => {
if (!form?.continentId) {
return toastWarn({ msg: "Vui lòng chọn châu lục" });
}
funcCreateCountries.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="max-h-[90vh] rounded-lg bg-white shadow-xl flex flex-col">
<div className="flex items-center justify-between border-b px-7 py-5 bg-white rounded-t-lg">
<h2
className="text-[28px] flex items-center gap-2 font-semibold text-[#111827] cursor-pointer"
onClick={() => router.back()}
>
<ArrowLeft size={32} color="#000000" /> Thêm Quốc Gia
</h2>
<div className="flex gap-2 items-center justify-between">
<CustomButton
variant="grey"
rounded="md"
onClick={() => router.back()}
>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={!form?.continentId || funcCreateCountries.isPending}
variant="midnightBlue"
rounded="md"
onClick={handleSubmit}
>
{funcCreateCountries.isPending ? "Đang thêm..." : "Thêm quốc gia"}
</CustomButton>
</div>
</div>
{/* Thêm overflow-y-auto ở phần nội dung để tự động có thanh cuộn dọc khi mở Múi giờ nếu form quá dài */}
<div className="flex-1 px-7 py-5 overflow-y-auto custom-scrollbar">
<div className="space-y-5 mb-4">
<SelectForm
label={
<span>
Châu lục <span className="text-red-500">*</span>
</span>
}
// Thay đổi ở đây: Bỏ !!form?.continentId, chỉ gán true nếu form thực sự ở chế độ chỉ xem (view mode)
readOnly={false}
placeholder="Chọn châu lục"
value={form.continentId}
options={continentOptions}
onSelect={(item: ContinentsItem) =>
setForm((prev) => ({
...prev,
continentId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
continentId: "",
}))
}
getOptionLabel={(item: ContinentsItem) => `${item.name}`}
getOptionValue={(item: ContinentsItem) => item.id}
/>
</div>
<div className="space-y-5 mb-4">
<div className="grid grid grid-cols-1 gap-4 sm:grid-cols-2">
<InputForm
label={
<span>
Quốc Gia <span className="text-red-500">*</span>
</span>
}
name="code"
type="text"
value={form.code}
placeholder="Nhập mã quốc gia"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, code: String(v) }))
}
/>
<InputForm
label={
<span>
Tên Quốc Gia <span className="text-red-500">*</span>
</span>
}
name="name"
type="text"
value={form.name}
placeholder="Nhập tên quốc gia"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, name: String(v) }))
}
/>
<SelectForm
label={
<span>
ISO 2 <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn mã ISO2"
value={form.iso2Code}
options={COUNTRY_OPTIONS}
onSelect={(item) =>
setForm((prev) => ({
...prev,
iso2Code: item.iso2Code,
iso3Code: item.iso3Code,
phoneCode: item.phoneCode,
timezone: item.timezone,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
iso2Code: "",
iso3Code: "",
phoneCode: "",
timezone: "",
}))
}
getOptionLabel={(item) => `${item.iso2Code} - ${item.name}`}
getOptionValue={(item) => item.iso2Code} // Trùng khớp với form.iso2Code
/>
<SelectForm
label={
<span>
ISO 3 <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn mã ISO3"
value={form.iso3Code}
options={COUNTRY_OPTIONS}
onSelect={(item) =>
setForm((prev) => ({
...prev,
iso2Code: item.iso2Code,
iso3Code: item.iso3Code,
phoneCode: item.phoneCode,
timezone: item.timezone,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
iso2Code: "",
iso3Code: "",
phoneCode: "",
timezone: "",
}))
}
getOptionLabel={(item) => `${item.iso3Code} - ${item.name}`}
getOptionValue={(item) => item.iso3Code} // SỬA: Phải là item.iso3Code thay vì item.uuid
/>
<div className="relative">
<SelectForm
label={
<span>
Điện thoại <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn mã điện thoại"
value={form.phoneCode}
options={COUNTRY_OPTIONS}
onSelect={(item) =>
setForm((prev) => ({
...prev,
phoneCode: item.phoneCode,
timezone: item.timezone,
iso2Code: item.iso2Code,
iso3Code: item.iso3Code,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
iso2Code: "",
iso3Code: "",
phoneCode: "",
timezone: "",
}))
}
getOptionLabel={(item) => `${item.phoneCode} (${item.name})`} // Nên hiển thị thêm kèm tên nước cho dễ nhìn
getOptionValue={(item) => item.phoneCode} // SỬA: Phải là item.phoneCode thay vì item.uuid
/>
</div>
<InputForm
label={
<span>
Tiền Tệ <span className="text-red-500">*</span>
</span>
}
name="currencyCode"
type="text"
value={form.currencyCode}
placeholder="VND"
isRequired
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, currencyCode: String(v) }))
}
/>
</div>
{/* Phần Múi Giờ dưới cùng */}
<div className="relative">
<SelectForm
label={
<span>
Múi Giờ <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn múi giờ"
value={form.timezone}
options={COUNTRY_OPTIONS}
onSelect={(item) =>
setForm((prev) => ({
...prev,
timezone: item.timezone,
phoneCode: item.phoneCode,
iso2Code: item.iso2Code,
iso3Code: item.iso3Code,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
iso2Code: "",
iso3Code: "",
phoneCode: "",
timezone: "",
}))
}
getOptionLabel={(item) => `${item.timezone} (${item.name})`} // Hiển thị kèm tên nước cho trực quan
getOptionValue={(item) => item.timezone} // SỬA: Phải là item.timezone thay vì item.uuid
/>
</div>
</div>
</div>
</div>
</FormCustom>
);
};
export default CreateCountries;
@@ -0,0 +1 @@
export { default } from "./CreateCountries";
@@ -0,0 +1,157 @@
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Edit } from "iconsax-react";
import { httpRequest } from "@/services";
import countriesServices, {
CountriesDetail,
} from "@/services/countriesServices";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import CustomButton from "@/components/customs/custom-button";
import StateActive from "@/components/customs/StateActive";
function DetailCountries() {
const router = useRouter();
const params = useParams();
const countriesId = params.id as string;
const countriesDetailQuery = useQuery<CountriesDetail>({
queryKey: [QUERY_KEY.detail_countries, countriesId],
enabled: !!countriesId,
queryFn: async () => {
return await httpRequest<CountriesDetail>({
showMessageFailed: true,
http: countriesServices.detailCountries(countriesId),
});
},
});
const countries = countriesDetailQuery.data;
if (!countries) {
return (
<div className="rounded-xl bg-white p-6 text-center">
Không tìm thấy thông tin quốc gia.
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h2
className="flex cursor-pointer items-center gap-2 text-[28px] font-semibold text-[#111827]"
onClick={() => router.back()}
>
<ArrowLeft size={32} color="#000000" />
{countries.name}
</h2>
{countries?.status === STATUS_CONTINENTS.ACTIVE && (
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Edit size={22} color="#fff" />}
onClick={() => router.push(`/countries/update/${countries.id}`)}
>
Chỉnh sửa
</CustomButton>
)}
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<p className="text-[14px] font-medium text-[#697586]">
quốc gia
</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.code}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
Tên quốc gia
</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.name}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]"> ISO 2</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.iso2Code}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]"> ISO 3</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.iso3Code}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
điện thoại
</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.phoneCode}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
tiền tệ
</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.currencyCode}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">Múi giờ</p>
<p className="text-[15px] font-medium text-[#202939]">
{countries.timezone}
</p>
</div>
<div>
<p className="text-[14px] font-medium text-[#697586]">
Trạng thái
</p>
<StateActive
stateActive={countries.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
</div>
</div>
</div>
</div>
</div>
);
}
export default DetailCountries;
@@ -0,0 +1 @@
export { default } from "./DetailCountries";
@@ -0,0 +1,356 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomIcon from "@/components/customs/custom-icon";
import Search from "@/components/customs/custom-search";
import CustomTable from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import FilterCustom from "@/components/customs/FilterCustom";
import StateActive from "@/components/customs/StateActive";
import { PATH } from "@/constant/config";
import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import countriesServices, {
CountriesItem,
CountriesResponse,
} from "@/services/countriesServices";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit, Eye, LocationAdd, Trash } from "iconsax-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useState } from "react";
import CustomPagination from "@/components/customs/custom-pagination";
import continentsServices, {
ContinentsResponse,
} from "@/services/continentsServices";
import CustomDialog from "@/components/customs/custom-dialog";
const MainPageCountries = () => {
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const pathname = usePathname();
const [uuidDelete, setUuidDelete] = useState<string>("");
const page = Number(searchParams.get("_page") ?? "1");
const pageSize = Number(searchParams.get("_pageSize") ?? "10");
const keyword = searchParams.get("_keyword") ?? "";
const status = searchParams.get("_status");
const continentId = searchParams.get("_continentId");
// Nâng cấp hàm cập nhật Query URL để hỗ trợ cập nhật nhiều tham số cùng lúc
const updateQuery = (
updates: Record<string, string | number | null | undefined>,
replace = false,
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
});
const url = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
if (replace) {
router.replace(url);
} else {
router.push(url);
}
};
const countriesQuery = useQuery<CountriesResponse>({
queryKey: [QUERY_KEY.table_list_countries, page, pageSize, keyword, status],
queryFn: async () => {
return await httpRequest<CountriesResponse>({
showMessageFailed: true,
http: countriesServices.getCountries({
page,
limit: pageSize,
search: keyword,
status: status || undefined, // active | inactive | undefined
continentId: continentId,
}),
});
},
});
const continentQuery = useQuery({
queryKey: [QUERY_KEY.table_list_continents, STATUS_CONTINENTS.ACTIVE],
queryFn: async () => {
const res = await httpRequest<ContinentsResponse>({
http: continentsServices.getContinents({
status: STATUS_CONTINENTS.ACTIVE,
}),
showMessageFailed: true,
});
return res;
},
});
const deleteCountriesMutation = useMutation({
mutationFn: async (id: string) => {
return await httpRequest({
http: countriesServices.deleteCountries(id),
showMessageSuccess: true,
msgSuccess: "Xóa quốc gia thành công!",
showMessageFailed: true,
});
},
onSuccess: () => {
setUuidDelete("");
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_countries],
});
},
onError: (error: any) => {
console.log(error);
},
});
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-gray-900">Quốc Gia</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<LocationAdd size="20" color="#f8f8f8" />}
href={PATH.CreateCountries}
className="w-full sm:w-auto"
>
Thêm mới
</CustomButton>
</div>
{/* Bộ lọc & Tìm kiếm */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Search */}
<div className="lg:col-span-6">
<Search
keyword={keyword}
setKeyword={(value) =>
updateQuery({
_keyword: value,
_page: 1,
})
}
placeholder="Tìm kiếm theo mã/tên/ISO2/ISO3"
/>
</div>
{/* Status */}
<div className="lg:col-span-3">
<FilterCustom<string | null>
name="Trạng thái"
value={status}
onChange={(value) =>
updateQuery({
_status: value ?? "",
_page: 1,
})
}
listOption={[
{
uuid: STATUS_CONTINENTS.ACTIVE,
name: "Hoạt động",
},
{
uuid: STATUS_CONTINENTS.INACTIVE,
name: "Đã xóa",
},
]}
/>
</div>
{/* Continent */}
<div className="lg:col-span-3">
<FilterCustom<string | null>
name="Châu lục"
value={continentId}
onChange={(value) =>
updateQuery({
_continentId: value ?? "",
_page: 1,
})
}
listOption={
continentQuery.data?.items?.map((item) => ({
uuid: item.id,
name: item.name,
})) ?? []
}
/>
</div>
</div>
</div>
{/* Bảng dữ liệu */}
<div className="rounded-lg bg-white p-6 shadow">
<DataWrapper
data={countriesQuery.data?.items ?? []}
loading={countriesQuery.isLoading}
title="Không tìm thấy yêu cầu nào"
note="Vui lòng kiểm tra lại sau hoặc thay đổi bộ lọc tìm kiếm"
>
<CustomTable
rowKey={(item) => item.id}
data={countriesQuery.data?.items ?? []}
column={[
{
fixedLeft: true,
title: "MÃ QUỐC GIA",
render: (item: CountriesItem) => (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */}
<Link
href={`${PATH.Countries}/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.code}
</Link>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="center">
<p className="p-2 bg-black text-white border rounded-lg text-xs shadow-md">
Xem chi tiết
</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
),
},
{
title: "TÊN QUỐC GIA",
render: (item: CountriesItem) => (
<span>{item.name || "---"}</span>
),
},
{
title: "MÃ ISO 2",
render: (item: CountriesItem) => (
<span>{item.iso2Code || "---"}</span>
),
},
{
title: "MÃ ISO 3",
render: (item: CountriesItem) => (
<span>{item.iso3Code || "---"}</span>
),
},
{
title: "MÃ ĐIỆN THOẠI",
render: (item: CountriesItem) => (
<span>{item.phoneCode || "---"}</span>
),
},
{
title: "MÃ TIỀN TỆ",
render: (item: CountriesItem) => (
<span>{item.currencyCode || "---"}</span>
),
},
{
title: "MÚI GIỜ",
render: (item: CountriesItem) => (
<span>{item.timezone || "---"}</span>
),
},
{
title: "TRẠNG THÁI",
render: (item: CountriesItem) => (
<StateActive
stateActive={item?.status}
listState={[
{
state: STATUS_CONTINENTS.ACTIVE,
text: "Hoạt động",
textColor: "#fff",
backgroundColor: "#06D7A0",
},
{
state: STATUS_CONTINENTS.INACTIVE,
text: "Đã xóa",
textColor: "#fff",
backgroundColor: "#F37277",
},
]}
/>
),
},
{
fixedRight: true,
title: "HÀNH ĐỘNG",
render: (item: CountriesItem) => (
<div className="flex items-center gap-2">
<CustomIcon
href={`${PATH.Continent}/${item?.id}`}
type="view"
icon={<Eye size="32" color="#0099FF" />}
tooltip="Xem chi tiết"
/>
{item?.status === STATUS_CONTINENTS.ACTIVE && (
<>
<CustomIcon
type="edit"
icon={<Edit size="32" color="#0099FF" />}
tooltip="Chỉnh sửa"
href={`/countries/update?id=${item.id}`}
/>
<CustomIcon
type="delete"
icon={<Trash size="32" color="#FF0000" />}
tooltip="Xóa"
onClick={() => setUuidDelete(item?.id)}
/>
</>
)}
</div>
),
},
]}
/>
</DataWrapper>
{/* Phân trang */}
<CustomPagination
total={countriesQuery.data?.total ?? 0}
page={page}
pageSize={pageSize}
onSetPage={(value) => updateQuery({ _page: value })}
onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })}
dependencies={[pageSize, keyword]}
/>
</div>
{/* Dialog xác nhận xóa */}
<CustomDialog
type="error"
open={!!uuidDelete}
onClose={() => setUuidDelete("")}
// SỬA: Đổi text liên quan từ "nhà thầu" thành "châu lục" để khớp ngữ cảnh của page
title="Xóa quốc gia"
note="Bạn có chắc chắn muốn xóa quốc gia này?"
onSubmit={() => {
if (!uuidDelete) return;
deleteCountriesMutation.mutate(uuidDelete);
}}
/>
</div>
);
};
export default MainPageCountries;

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