one-day-update-vna
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
@@ -13,6 +13,13 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"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;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -9,15 +9,36 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.2.9",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
"react": "19.2.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/md5": "^2.3.6",
|
||||||
|
"@types/moment": "^2.13.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nprogress": "^0.2.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-redux": "^7.1.34",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 49 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 26 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import AppProvider from "@/contexts/AppProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,7 +15,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Quản lý hàng không",
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,11 +25,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
// <html
|
||||||
lang="en"
|
// lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
// className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
// >
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
// <body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
// </html>
|
||||||
|
<html lang="vi" suppressHydrationWarning>
|
||||||
|
<body>
|
||||||
|
<AppProvider>{children}</AppProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<BaseLayout title="Continents">
|
||||||
<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">
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Image
|
<MainPageContinents />
|
||||||
className="dark:invert"
|
</Suspense>
|
||||||
src="/next.svg"
|
</BaseLayout>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default page;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export async function delay(duration: number) {
|
||||||
|
return await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />,
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 cá 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]">
|
||||||
|
Mã 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]">Mã IATA</p>
|
||||||
|
<p className="text-[15px] font-medium text-[#202939]">
|
||||||
|
{airlines.iataCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-medium text-[#697586]">Mã 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>
|
||||||
|
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;
|
||||||
@@ -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>
|
||||||
|
Mã 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>Mã 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>Mã 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>
|
||||||
|
Mã 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]">
|
||||||
|
Mã 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>
|
||||||
|
Mã 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>
|
||||||
|
Mã 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>
|
||||||
|
Mã 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>
|
||||||
|
Mã 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>
|
||||||
|
Mã Đ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>
|
||||||
|
Mã 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]">
|
||||||
|
Mã 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]">Mã ISO 2</p>
|
||||||
|
<p className="text-[15px] font-medium text-[#202939]">
|
||||||
|
{countries.iso2Code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-medium text-[#697586]">Mã ISO 3</p>
|
||||||
|
<p className="text-[15px] font-medium text-[#202939]">
|
||||||
|
{countries.iso3Code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-medium text-[#697586]">
|
||||||
|
Mã đ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]">
|
||||||
|
Mã 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;
|
||||||