diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..779ad19 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..03b52fe --- /dev/null +++ b/.vscode/tasks.json @@ -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
${input:dirName}
; \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" + } + ] +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..8b757b2 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..4e80a14 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,13 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + rules: { + "no-var": "off", + "prefer-const": "off", + "@typescript-eslint/no-explicit-any": "off", // tắt lỗi any + }, + }, ]); export default eslintConfig; diff --git a/next.config.ts b/next.config.ts index 66e1566..3b846fe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + + turbopack: { + root: process.cwd(), + }, }; export default nextConfig; diff --git a/package.json b/package.json index 293fd09..cea0126 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,36 @@ "lint": "eslint" }, "dependencies": { + "@radix-ui/react-popover": "^1.1.17", + "@radix-ui/react-tooltip": "^1.2.12", + "@reduxjs/toolkit": "^2.12.0", + "@tanstack/react-query": "^5.101.2", + "axios": "^1.18.1", + "clsx": "^2.1.1", + "countries-and-timezones": "^3.9.0", + "countries-list": "^3.4.1", + "iconsax-react": "^0.0.8", + "lottie-react": "^2.4.1", + "md5": "^2.3.0", + "moment": "^2.30.1", "next": "16.2.9", + "nprogress": "^0.2.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-icons": "^5.7.0", + "react-moment": "^2.0.2", + "react-redux": "^9.3.0", + "react-toastify": "^11.1.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/md5": "^2.3.6", + "@types/moment": "^2.13.0", "@types/node": "^20", + "@types/nprogress": "^0.2.3", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-redux": "^7.1.34", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.2.9", diff --git a/public/static/anim/loading_screen.json b/public/static/anim/loading_screen.json new file mode 100644 index 0000000..a15255f --- /dev/null +++ b/public/static/anim/loading_screen.json @@ -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" + } + } + } +} diff --git a/public/static/images/avatar-default.png b/public/static/images/avatar-default.png new file mode 100644 index 0000000..9334d43 Binary files /dev/null and b/public/static/images/avatar-default.png differ diff --git a/public/static/images/box-remove.svg b/public/static/images/box-remove.svg new file mode 100644 index 0000000..79d6ac5 --- /dev/null +++ b/public/static/images/box-remove.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/static/images/emptyFile.svg b/public/static/images/emptyFile.svg new file mode 100644 index 0000000..345931d --- /dev/null +++ b/public/static/images/emptyFile.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/images/empty_table.png b/public/static/images/empty_table.png new file mode 100644 index 0000000..cf8ddaf Binary files /dev/null and b/public/static/images/empty_table.png differ diff --git a/public/static/images/full-screen.svg b/public/static/images/full-screen.svg new file mode 100644 index 0000000..95cf99c --- /dev/null +++ b/public/static/images/full-screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/icon-hamburger.svg b/public/static/images/icon-hamburger.svg new file mode 100644 index 0000000..5e7744e --- /dev/null +++ b/public/static/images/icon-hamburger.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/logoFull.png b/public/static/images/logoFull.png new file mode 100644 index 0000000..5b5c739 Binary files /dev/null and b/public/static/images/logoFull.png differ diff --git a/public/static/images/logoSmall.webp b/public/static/images/logoSmall.webp new file mode 100644 index 0000000..ad53df1 Binary files /dev/null and b/public/static/images/logoSmall.webp differ diff --git a/public/static/images/may-bay.png b/public/static/images/may-bay.png new file mode 100644 index 0000000..9f245f4 Binary files /dev/null and b/public/static/images/may-bay.png differ diff --git a/public/static/images/pause.svg b/public/static/images/pause.svg new file mode 100644 index 0000000..c41d8ca --- /dev/null +++ b/public/static/images/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/play-circle.svg b/public/static/images/play-circle.svg new file mode 100644 index 0000000..b3906d4 --- /dev/null +++ b/public/static/images/play-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/tick-circle.svg b/public/static/images/tick-circle.svg new file mode 100644 index 0000000..869e8f1 --- /dev/null +++ b/public/static/images/tick-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/warning-2.svg b/public/static/images/warning-2.svg new file mode 100644 index 0000000..2ecb51f --- /dev/null +++ b/public/static/images/warning-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/airlines/[id]/page.tsx b/src/app/airlines/[id]/page.tsx new file mode 100644 index 0000000..39a26ae --- /dev/null +++ b/src/app/airlines/[id]/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/airlines/create/page.tsx b/src/app/airlines/create/page.tsx new file mode 100644 index 0000000..6029ba5 --- /dev/null +++ b/src/app/airlines/create/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/airlines/page.tsx b/src/app/airlines/page.tsx new file mode 100644 index 0000000..bcb01b1 --- /dev/null +++ b/src/app/airlines/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/airlines/update/page.tsx b/src/app/airlines/update/page.tsx new file mode 100644 index 0000000..3c39736 --- /dev/null +++ b/src/app/airlines/update/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/continents/[id]/page.tsx b/src/app/continents/[id]/page.tsx new file mode 100644 index 0000000..4b135be --- /dev/null +++ b/src/app/continents/[id]/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/continents/page.tsx b/src/app/continents/page.tsx new file mode 100644 index 0000000..afdf9fe --- /dev/null +++ b/src/app/continents/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/countries/[id]/page.tsx b/src/app/countries/[id]/page.tsx new file mode 100644 index 0000000..7de2d9c --- /dev/null +++ b/src/app/countries/[id]/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/countries/create/page.tsx b/src/app/countries/create/page.tsx new file mode 100644 index 0000000..cfa4615 --- /dev/null +++ b/src/app/countries/create/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/countries/page.tsx b/src/app/countries/page.tsx new file mode 100644 index 0000000..6e29257 --- /dev/null +++ b/src/app/countries/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/countries/update/page.tsx b/src/app/countries/update/page.tsx new file mode 100644 index 0000000..be8716c --- /dev/null +++ b/src/app/countries/update/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/country-regions/[id]/page.tsx b/src/app/country-regions/[id]/page.tsx new file mode 100644 index 0000000..a96eeaa --- /dev/null +++ b/src/app/country-regions/[id]/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/country-regions/page.tsx b/src/app/country-regions/page.tsx new file mode 100644 index 0000000..3d3c117 --- /dev/null +++ b/src/app/country-regions/page.tsx @@ -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 ( + + Loading...}> + + + + ); +} + +export default page; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..0657834 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import BaseLayout from "@/components/layouts/BaseLayout"; +import React from "react"; + +function page() { + return page; +} + +export default page; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..13dbe24 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import "react-toastify/dist/ReactToastify.css"; +import AppProvider from "@/contexts/AppProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,7 +15,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", + title: "Quản lý hàng không", description: "Generated by create next app", }; @@ -23,11 +25,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + // + // {children} + // + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..547c34e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

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

-

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

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+ + Loading...}> + + + ); } + +export default page; diff --git a/src/common/funcs/convertCoin.ts b/src/common/funcs/convertCoin.ts new file mode 100644 index 0000000..427927b --- /dev/null +++ b/src/common/funcs/convertCoin.ts @@ -0,0 +1,14 @@ +// Hàm chuyển đổi số coin thành chuỗi định dạng tiền tệ +export const convertCoin = (coin: number | null) => { + return coin + ? coin.toLocaleString("de-DE", { maximumFractionDigits: 2 }) + : "0"; +}; + +// Hàm chuyển đổi chuỗi đị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; +}; diff --git a/src/common/funcs/delay.ts b/src/common/funcs/delay.ts new file mode 100644 index 0000000..91b78df --- /dev/null +++ b/src/common/funcs/delay.ts @@ -0,0 +1,3 @@ +export async function delay(duration: number) { + return await new Promise((resolve) => setTimeout(resolve, duration)); +} diff --git a/src/common/funcs/localStorage.ts b/src/common/funcs/localStorage.ts new file mode 100644 index 0000000..dfa9d07 --- /dev/null +++ b/src/common/funcs/localStorage.ts @@ -0,0 +1,18 @@ +export function getItemStorage(key: string) { + const value = localStorage.getItem(key)!; + if (value !== "undefined") { + return JSON.parse(value); + } else { + return null; + } +} + +export function setItemStorage(key: string, value: any) { + localStorage.setItem(key, JSON.stringify(value)); +} + +export function deleteItemStorage(...keys: Array) { + keys.forEach((key) => { + localStorage.removeItem(key); + }); +} diff --git a/src/common/funcs/optionConvert.ts b/src/common/funcs/optionConvert.ts new file mode 100644 index 0000000..37aa4e3 --- /dev/null +++ b/src/common/funcs/optionConvert.ts @@ -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}`), + }; +} diff --git a/src/common/funcs/selectData.ts b/src/common/funcs/selectData.ts new file mode 100644 index 0000000..88cb2d6 --- /dev/null +++ b/src/common/funcs/selectData.ts @@ -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 }; + } +} diff --git a/src/common/funcs/toast.tsx b/src/common/funcs/toast.tsx new file mode 100644 index 0000000..54d96dc --- /dev/null +++ b/src/common/funcs/toast.tsx @@ -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: () => , + }); + +export const toastWarn = ({ msg }: { msg: string }) => + toast.warning(msg, { + ...defaultOption, + className: "toastify-custom-warn", + icon: () => , + }); + +export const toastInfo = ({ msg }: { msg: string }) => + toast.info(msg, { + ...defaultOption, + className: "toastify-custom-info", + icon: () => , + }); + +export const toastError = ({ msg }: { msg: string }) => + toast.error(msg, { + ...defaultOption, + className: "toastify-custom-error", + icon: () => , + }); diff --git a/src/common/funcs/validate.ts b/src/common/funcs/validate.ts new file mode 100644 index 0000000..6fceb9d --- /dev/null +++ b/src/common/funcs/validate.ts @@ -0,0 +1,14 @@ +export function validateEmail(value: string) { + return !!value + ?.trim() + .toLowerCase() + .match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ); +} + +export function validatePhoneNumber(phoneNumber: string) { + const vietnamPhoneRegex = /^(03|05|07|08|09)[0-9]{8}$/; + + return vietnamPhoneRegex.test(phoneNumber); +} diff --git a/src/common/hooks/useStyleClass.ts b/src/common/hooks/useStyleClass.ts new file mode 100644 index 0000000..dc36bcd --- /dev/null +++ b/src/common/hooks/useStyleClass.ts @@ -0,0 +1,14 @@ +export const useStyleClass = (props: any, style: any) => { + let styleProps: Array = []; + + for (let i in props) { + styleProps.push(i); + } + + const convert: any = styleProps.map((item) => { + if (style[item] !== undefined) { + return { [style[item]]: !!props[item] }; + } + }); + return convert; +}; diff --git a/src/components/customs/DataWrapper.tsx b/src/components/customs/DataWrapper.tsx new file mode 100644 index 0000000..a1105ed --- /dev/null +++ b/src/components/customs/DataWrapper.tsx @@ -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 ( +
+
+ +

Đang tải...

+

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

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

{title}

+ +

{note}

+ + {button &&
{button}
} +
+ ); + } + + // ================= HAS DATA ================= + return <>{children}; +} + +export default DataWrapper; diff --git a/src/components/customs/FilterCustom.tsx b/src/components/customs/FilterCustom.tsx new file mode 100644 index 0000000..e40e672 --- /dev/null +++ b/src/components/customs/FilterCustom.tsx @@ -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 { + styleRounded?: boolean; + showOptionAll?: boolean; + name: string; + listOption: OptionItem[]; + value: T; + onChange: (value: T) => void; +} + +function FilterCustom({ + styleRounded = false, + showOptionAll = true, + name, + listOption, + value, + onChange, +}: PropsFilterCustom) { + const wrapperRef = useRef(null); + const inputRef = useRef(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 ( +
+ {/* Trigger */} +
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]", + )} + > +
+ + {name}: + + + {selectedLabel} + +
+ + + + +
+ + {/* Dropdown */} + {open && ( +
+
+ {/* Search */} + 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 */} +
+ {showOptionAll && ( +
{ + 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]", + )} + > + Tất cả + + {(value === null || value === "") && ( + + + + )} +
+ )} + + {filteredOptions.map((item) => { + const isActive = item.uuid === value; + + return ( +
{ + 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]", + )} + > + {item.name} + + {isActive && ( + + + + )} +
+ ); + })} + + {filteredOptions.length === 0 && ( +
+ Không tìm thấy dữ liệu +
+ )} +
+
+
+ )} +
+ ); +} + +export default FilterCustom; diff --git a/src/components/customs/FilterMany.tsx b/src/components/customs/FilterMany.tsx new file mode 100644 index 0000000..18642e8 --- /dev/null +++ b/src/components/customs/FilterMany.tsx @@ -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 { + uuid: T; + name: string; + code?: string; +} + +interface PropsFilterMany { + styleRounded?: boolean; + small?: boolean; + name: string; + listOption: OptionItem[]; + value: T[]; + setValue: Dispatch>; +} + +function FilterMany({ + styleRounded = false, + small = false, + name, + listOption, + value, + setValue, +}: PropsFilterMany) { + const inputRef = useRef(null); + + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + const [tempSelected, setTempSelected] = useState([]); + + const wrapperRef = useRef(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 ( +
+ {/* TRIGGER */} +
+
+

{name}:

+ +

+ {value.length === 0 || value.length === listOption.length + ? "Tất cả" + : `${value.length} ${name.toLowerCase()}`} +

+
+ + + + +
+ + {/* DROPDOWN */} + {open && ( +
+ {/* SEARCH */} + 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 */} +
+ {/* ALL */} +
setTempSelected([])} + className={clsx( + "flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-100", + isAllSelected && "bg-blue-50", + )} + > +

Tất cả

+ + {isAllSelected && ( + + + + )} +
+ + {/* OPTIONS */} + {filteredList.map((item) => { + const active = tempSelected.includes(item.uuid); + + return ( +
{ + 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", + )} + > +

{item.name}

+ + {active && ( + + + + )} +
+ ); + })} +
+ + {/* ACTION */} +
+ setOpen(false)} + > + Hủy bỏ + + + { + setValue(tempSelected); + setOpen(false); + }} + > + Xác nhận + +
+
+ )} +
+ ); +} + +export default FilterMany; diff --git a/src/components/customs/StateActive.tsx b/src/components/customs/StateActive.tsx new file mode 100644 index 0000000..a83004a --- /dev/null +++ b/src/components/customs/StateActive.tsx @@ -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 ( +
+ {current?.text ?? "---"} +
+ ); + } + + return ( +
+ {/* DOT */} +
+ + {/* TEXT */} +

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

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

{title}

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

{note}

} + + {/* ACTIONS */} +
+ + {titleCancel} + + + + {titleSubmit} + +
+
+
+ ); +}; + +export default CustomDialog; diff --git a/src/components/customs/custom-icon.tsx b/src/components/customs/custom-icon.tsx new file mode 100644 index 0000000..14c8a09 --- /dev/null +++ b/src/components/customs/custom-icon.tsx @@ -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 = ( + + {icon} + + ); + + return ( + + + + {href ? {content} : content} + + + + + {tooltip} + + + + + + + ); +} diff --git a/src/components/customs/custom-loading.tsx b/src/components/customs/custom-loading.tsx new file mode 100644 index 0000000..95dfe0f --- /dev/null +++ b/src/components/customs/custom-loading.tsx @@ -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 ( + +
+
+ {Array.from({ length: 12 }).map((_, i) => { + const rotate = i * 30; + return ( +
+
+
+ ); + })} +
+
+ + ); +} + +export default CustomLoading; diff --git a/src/components/customs/custom-pagination.tsx b/src/components/customs/custom-pagination.tsx new file mode 100644 index 0000000..476517b --- /dev/null +++ b/src/components/customs/custom-pagination.tsx @@ -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) => ( +
+ {pageSize} + + {/* */} +
+)); + +TriggerButton.displayName = "TriggerButton"; + +export default function Pagination({ + total, + page, + pageSize, + onSetPage, + onSetPageSize, + dependencies = [], +}: PropsPagination) { + const pageSizes = [50, 100, 200]; + + const [openLimit, setOpenLimit] = useState(false); + const dropdownRef = useRef(null); + + const maxPage = Math.ceil(total / Number(pageSize)); + + const items = useMemo(() => { + const nodes: React.ReactNode[] = []; + + for (let i = 1; i <= maxPage; i++) { + const isCurrent = page === i; + const isNear = + i === page || + i === page - 1 || + i === page + 1 || + i === 1 || + i === maxPage; + + if (isNear) { + nodes.push( +
  • onSetPage(i)} + className={clsx( + "h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold border rounded cursor-pointer transition", + isCurrent + ? "bg-[#0011ab] text-white border-[#0011ab] pointer-events-none" + : "bg-white text-black border-[#efefef] hover:opacity-70", + )} + > + {i} +
  • , + ); + } + + if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) { + nodes.push( +
  • + ... +
  • , + ); + } + } + + return nodes; + }, [maxPage, page, onSetPage]); + + const handlePrev = () => { + if (page > 1) onSetPage(page - 1); + }; + + const handleNext = () => { + if (page < maxPage) onSetPage(page + 1); + }; + + useEffect(() => { + if (dependencies.length > 0) { + onSetPage(1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + openLimit && + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setOpenLimit(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [openLimit]); + + if (total <= 0) return null; + + return ( +
    + {/* LEFT */} +
    +

    Hiển thị

    + +
    + setOpenLimit(!openLimit)} + className={clsx( + "w-[80px] flex items-center justify-between px-3 py-1 border rounded-full cursor-pointer bg-white", + "border-gray-300", + )} + pageSize={pageSize} + openLimit={openLimit} + /> + + {openLimit && ( +
    + {pageSizes.map((v) => ( +
    { + onSetPageSize(v); + setOpenLimit(false); + }} + className={clsx( + "px-2 py-1 text-[13px] cursor-pointer rounded", + "hover:bg-[#0011ab] hover:text-white", + pageSize === v && "bg-[#0011ab] text-white", + )} + > + {v} +
    + ))} +
    + )} +
    + +

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

    +
    + + {/* PAGINATION */} +
    + {page > 1 && ( + + )} + +
      {items}
    + + {page < maxPage && ( + + )} +
    +
    + ); +} diff --git a/src/components/customs/custom-popup.tsx b/src/components/customs/custom-popup.tsx new file mode 100644 index 0000000..b6c8b09 --- /dev/null +++ b/src/components/customs/custom-popup.tsx @@ -0,0 +1,89 @@ +"use client"; + +import React, { ReactNode, useEffect } from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; + +/* ================== TYPES ================== */ +interface Props { + open: boolean; + isFull?: boolean; + onClose: () => void; + children?: ReactNode; + showOverlay?: boolean; +} + +/* ================== COMPONENT ================== */ +export default function CustomPopup({ + open, + onClose, + showOverlay = true, + isFull, + children, +}: Props) { + // lock body scroll + useEffect(() => { + if (!open) return; + + const original = document.body.style.overflow; + + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = original || "auto"; + }; + }, [open]); + + // SSR safe + if (typeof window === "undefined") return null; + + // popup closed + if (!open) return null; + + return createPortal( + <> + {/* OVERLAY */} + {showOverlay && ( +
    + )} + + {/* CONTENT */} +
    + {children} +
    + , + document.body, + ); +} diff --git a/src/components/customs/custom-portal.tsx b/src/components/customs/custom-portal.tsx new file mode 100644 index 0000000..4aa39c7 --- /dev/null +++ b/src/components/customs/custom-portal.tsx @@ -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; diff --git a/src/components/customs/custom-search.tsx b/src/components/customs/custom-search.tsx new file mode 100644 index 0000000..273cbfc --- /dev/null +++ b/src/components/customs/custom-search.tsx @@ -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(null); + const [searchTerm, setSearchTerm] = useState(keyword); + + useEffect(() => { + const timer = setTimeout(() => { + setKeyword(searchTerm.trim()); + }, 600); + + return () => clearTimeout(timer); + }, [searchTerm]); + + return ( +
    inputRef.current?.focus()} + className={clsx( + "flex h-12 cursor-text items-center gap-2 rounded-full border bg-white px-4 transition-colors", + "border-[#e1e5ed] hover:border-[#3772ff] focus-within:border-[#3772ff]", + )} + > + + + + + setSearchTerm(e.target.value)} + placeholder={placeholder} + className={clsx( + "flex-1", + "border-none", + "bg-transparent", + "p-2", + "text-[16px]", + "font-medium", + "outline-none", + "placeholder:text-[#777e90]", + )} + /> +
    + ); +} diff --git a/src/components/customs/custom-table.tsx b/src/components/customs/custom-table.tsx new file mode 100644 index 0000000..4f7911c --- /dev/null +++ b/src/components/customs/custom-table.tsx @@ -0,0 +1,305 @@ +"use client"; +import clsx from "clsx"; +import React, { + Fragment, + Key, + ReactNode, + useMemo, + useRef, + useState, +} from "react"; + +interface ColumnType { + 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 { + data: T[]; + column: ColumnType[]; + fixedHeader?: boolean; + activeHeader?: boolean; + handleCheckedAll?: (e: React.ChangeEvent) => void; + isCheckedAll?: boolean; + handleCheckedRow?: (e: React.ChangeEvent, row: T) => void; + handleIsCheckedRow?: (row: T) => boolean; + rowKey: (row: T) => React.Key; + getChildren?: (row: T) => T[] | undefined; + useIndexPathAsKey?: boolean; +} + +function CustomTable({ + data, + column, + fixedHeader, + activeHeader, + handleCheckedAll, + isCheckedAll, + handleCheckedRow, + handleIsCheckedRow, + rowKey, + getChildren, + useIndexPathAsKey, +}: PropsTable) { + const tableRef = useRef(null); + const thRefs = useRef<(HTMLTableCellElement | null)[]>([]); + + const [expandedRows, setExpandedRows] = useState([]); + const [sortConfig, setSortConfig] = useState<{ + key: string; + direction: "asc" | "desc" | null; + }>({ key: "", direction: null }); + + /* ================= SORT ================= */ + const handleSort = (key: string) => { + setSortConfig((prev) => { + if (prev.key === key) { + const next = + prev.direction === "asc" + ? "desc" + : prev.direction === "desc" + ? null + : "asc"; + return { key, direction: next }; + } + return { key, direction: "asc" }; + }); + }; + + const sortedData = useMemo(() => { + if (!sortConfig.key || !sortConfig.direction) return data; + + const copy = [...data]; + + copy.sort((a: any, b: any) => { + const aVal = a?.[sortConfig.key]; + const bVal = b?.[sortConfig.key]; + + if (typeof aVal === "number" && typeof bVal === "number") { + return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal; + } + + if (typeof aVal === "string" && typeof bVal === "string") { + return sortConfig.direction === "asc" + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal); + } + + return 0; + }); + + return copy; + }, [data, sortConfig]); + + /* ================= EXPAND ================= */ + const toggleExpand = (key: React.Key) => { + setExpandedRows((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], + ); + }; + + /* ================= ROWS ================= */ + const renderRows = (rows: T[], path: number[] = []): React.ReactNode => + rows.map((row, i) => { + const currentPath = [...path, i]; + const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row); + + const children = getChildren?.(row); + const expanded = expandedRows.includes(keyPath); + + return ( + + + {getChildren && ( + + {!!children?.length && ( + + )} + + )} + + {column.map((col, idx) => ( + +
    + {col.checkBox && ( + handleCheckedRow?.(e, row)} + checked={handleIsCheckedRow?.(row) ?? false} + /> + )} + {col.render(row, i, currentPath)} +
    + + ))} + + + {expanded && children && renderRows(children, currentPath)} +
    + ); + }); + + return ( +
    table>thead]:sticky [&>table>thead]:top-0", + activeHeader && "[&>table>thead]:bg-[#F4F7FA]", + )} + > + + + + {getChildren && + ); + })} + + + + {renderRows(sortedData)} +
    } + + {column.map((col, i) => { + const isActive = col.sortTable === sortConfig.key; + + return ( + { + thRefs.current[i] = el; + }} + onClick={() => col.sortTable && handleSort(col.sortTable)} + className={clsx( + "px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap", + col.sortTable && "cursor-pointer", + isActive && "text-blue-600", + col.fixedLeft && "sticky left-0 bg-white z-10", + col.fixedRight && "sticky right-0 bg-white z-10", + )} + > +
    + {col.checkBox && ( + + )} + + {col.title} + + {col.sortTable && + (isActive ? ( + sortConfig.direction === "asc" ? ( + + + + ) : sortConfig.direction === "desc" ? ( + + + + ) : ( + + + + ) + ) : ( + + + + ))} +
    +
    +
    + ); +} + +export default CustomTable; diff --git a/src/components/customs/custom-tabs.tsx b/src/components/customs/custom-tabs.tsx new file mode 100644 index 0000000..419116e --- /dev/null +++ b/src/components/customs/custom-tabs.tsx @@ -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 ( +
    + {listHref.map((item, i) => { + const isActive = + currentValue === item.query || + (!currentValue && item.query === null && i === 0); + + return ( + + ); + })} +
    + ); +} + +export default CustomTabs; diff --git a/src/components/customs/custom-toast.tsx b/src/components/customs/custom-toast.tsx new file mode 100644 index 0000000..fb6cd4b --- /dev/null +++ b/src/components/customs/custom-toast.tsx @@ -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 ( +
    + +
    + ); +} diff --git a/src/components/customs/input-single.tsx b/src/components/customs/input-single.tsx new file mode 100644 index 0000000..03da343 --- /dev/null +++ b/src/components/customs/input-single.tsx @@ -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([]); + // update value + const updateOTPValue = () => { + const code = inputsRef.current.map((input) => input?.value || ""); + + onSetValue?.((prev: any) => ({ + ...prev, + [name]: code.join(""), + })); + }; + + // input change + const handleChange = (e: React.FormEvent) => { + const target = e.currentTarget; + + const index = Number(target.dataset.index); + + let value = target.value; + + if (value.length > 1) { + value = value.slice(0, 1); + + target.value = value; + } + + updateOTPValue(); + + if (value) { + inputsRef.current[index + 1]?.focus(); + } + }; + + // backspace + const handleKeyDown = (e: React.KeyboardEvent) => { + const index = Number(e.currentTarget.dataset.index); + + if (e.key === "Backspace") { + if (!e.currentTarget.value) { + inputsRef.current[index - 1]?.focus(); + } + + requestAnimationFrame(() => { + updateOTPValue(); + }); + } + }; + + // set refs + const setRef = (el: HTMLInputElement | null, index: number) => { + if (el) { + inputsRef.current[index] = el; + } + }; + + const renderInputs = useMemo(() => { + const list: ReactNode[] = []; + + for (let i = 0; i < length; i++) { + list.push( + setRef(el, i)} + data-index={i} + inputMode="numeric" + maxLength={1} + onInput={handleChange} + onKeyDown={handleKeyDown} + className={clsx( + "h-[72px] w-[56px]", + "rounded-xl border-2 border-[#dae1ea]", + "text-center text-[42px] font-medium", + "outline-none transition", + "focus:border-[#066bb1]", + )} + />, + ); + } + + return list; + }, [length]); + return ( +
    + {renderInputs} +
    + ); +} + +export default InputSingle; diff --git a/src/components/customs/slider-props.tsx b/src/components/customs/slider-props.tsx new file mode 100644 index 0000000..4acc218 --- /dev/null +++ b/src/components/customs/slider-props.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React from "react"; +import clsx from "clsx"; + +interface CustomSliderProps { + label?: string; + value: number; + min?: number; + max?: number; + step?: number; + + onChange?: (value: number) => void; + + showValue?: boolean; + disabled?: boolean; + + className?: string; + labelClassName?: string; + sliderClassName?: string; + thumbClassName?: string; + trackClassName?: string; + + color?: string; + trackColor?: string; + + renderValue?: (value: number) => React.ReactNode; +} + +const CustomSlider = ({ + label, + value, + min = 0, + max = 1, + step = 0.01, + + onChange, + + showValue = true, + disabled = false, + + className, + labelClassName, + sliderClassName, + + color = "#2563eb", + trackColor = "#e5e7eb", + + renderValue, +}: CustomSliderProps) => { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
    + {/* Header */} + {(label || showValue) && ( +
    + {label && ( + {label} + )} + + {showValue && ( + + {renderValue ? renderValue(value) : value} + + )} +
    + )} + + {/* Slider */} + onChange?.(Number(e.target.value))} + className={clsx( + ` + w-full + appearance-none + bg-transparent + cursor-pointer + + disabled:opacity-50 + disabled:cursor-not-allowed + + [&::-webkit-slider-runnable-track]:h-2 + [&::-webkit-slider-runnable-track]:rounded-full + + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:h-6 + [&::-webkit-slider-thumb]:w-6 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-white + [&::-webkit-slider-thumb]:border-4 + [&::-webkit-slider-thumb]:shadow-md + [&::-webkit-slider-thumb]:-mt-2 + + [&::-moz-range-track]:h-2 + [&::-moz-range-track]:rounded-full + + [&::-moz-range-thumb]:h-6 + [&::-moz-range-thumb]:w-6 + [&::-moz-range-thumb]:rounded-full + [&::-moz-range-thumb]:bg-white + [&::-moz-range-thumb]:border-4 + `, + sliderClassName, + )} + style={{ + background: `linear-gradient(to right, ${color} ${percentage}%, ${trackColor} ${percentage}%)`, + }} + /> +
    + ); +}; + +export default CustomSlider; diff --git a/src/components/layouts/BaseLayout/BaseLayout.tsx b/src/components/layouts/BaseLayout/BaseLayout.tsx new file mode 100644 index 0000000..30d9b9a --- /dev/null +++ b/src/components/layouts/BaseLayout/BaseLayout.tsx @@ -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({}); + +const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => { + const [showFull, setShowFull] = React.useState(true); + const [openMenuMobile, setOpenMenuMobile] = React.useState(false); + + return ( + // + +
    + {/* OVERLAY cho Mobile */} +
    setOpenMenuMobile(false)} + /> + + {/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */} + + + {/* HEADER */} +
    +
    +
    + + {/* MAIN CONTENT */} +
    + {children} +
    +
    + + ); +}; + +export default BaseLayout; diff --git a/src/components/layouts/BaseLayout/compoents/Header/Header.tsx b/src/components/layouts/BaseLayout/compoents/Header/Header.tsx new file mode 100644 index 0000000..f669aef --- /dev/null +++ b/src/components/layouts/BaseLayout/compoents/Header/Header.tsx @@ -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(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({ + // queryKey: [QUERY_KEY.table_list_profile], + // queryFn: async () => { + // const res = await httpRequest({ + // 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 ( +
    +
    + {/* Nút bấm Mobile: Mở menu mobile */} +
    context?.setOpenMenuMobile?.(!context?.openMenuMobile)} + > + menu +
    + {/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */} +
    context?.setShowFull?.(!context?.showFull)} + > + menu +
    + {breadcrumb ? ( + breadcrumb + ) : ( +

    {title}

    + )} +
    + +
    +
    + +
    + + {/* PROFILE CONTROL AREA */} +
    setOpenProfile(!openProfile)} + > +
    + avatar +
    +

    + { + // currentUserProfile?.fullName || + "User admin" + } +

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

    Thông tin cá nhân

    +

    Chi tiết tài khoản

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

    Đăng xuất

    +

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

    +
    +
    +
    + ); +} + +export default MenuProfile; diff --git a/src/components/layouts/BaseLayout/compoents/MenuProfile/index.ts b/src/components/layouts/BaseLayout/compoents/MenuProfile/index.ts new file mode 100644 index 0000000..12ae6c8 --- /dev/null +++ b/src/components/layouts/BaseLayout/compoents/MenuProfile/index.ts @@ -0,0 +1 @@ +export { default } from "./MenuProfile"; diff --git a/src/components/layouts/BaseLayout/compoents/MenuProfile/interface/index.ts b/src/components/layouts/BaseLayout/compoents/MenuProfile/interface/index.ts new file mode 100644 index 0000000..88f466b --- /dev/null +++ b/src/components/layouts/BaseLayout/compoents/MenuProfile/interface/index.ts @@ -0,0 +1,3 @@ +export interface PropsMenuProfile { + onClose: () => void; +} diff --git a/src/components/layouts/BaseLayout/compoents/Navbar/Navbar.tsx b/src/components/layouts/BaseLayout/compoents/Navbar/Navbar.tsx new file mode 100644 index 0000000..81db3d6 --- /dev/null +++ b/src/components/layouts/BaseLayout/compoents/Navbar/Navbar.tsx @@ -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 ( +
    + + Logo + +
    + {Menus.map((menu, i) => ( +
    + {/* TITLE */} +
    + {menu.title} +
    + +
    + {menu.group.map((tab, y) => { + const isActive = checkActive(tab.pathActive); + + return ( + + +

    {tab.title}

    + + ); + })} +
    +
    + ))} +
    +
    + ); +}; + +export default Navbar; diff --git a/src/components/layouts/BaseLayout/compoents/Navbar/index.ts b/src/components/layouts/BaseLayout/compoents/Navbar/index.ts new file mode 100644 index 0000000..8d95d66 --- /dev/null +++ b/src/components/layouts/BaseLayout/compoents/Navbar/index.ts @@ -0,0 +1 @@ +export { default } from "./Navbar"; diff --git a/src/components/layouts/BaseLayout/index.ts b/src/components/layouts/BaseLayout/index.ts new file mode 100644 index 0000000..cd391ad --- /dev/null +++ b/src/components/layouts/BaseLayout/index.ts @@ -0,0 +1 @@ +export { default } from "./BaseLayout"; diff --git a/src/components/layouts/BaseLayout/interface/index.ts b/src/components/layouts/BaseLayout/interface/index.ts new file mode 100644 index 0000000..3185206 --- /dev/null +++ b/src/components/layouts/BaseLayout/interface/index.ts @@ -0,0 +1,12 @@ +export interface PropsBaseLayout { + children: React.ReactNode; + title: string; + breadcrumb?: React.ReactNode; +} + +export interface TContextBaseLayout { + showFull?: boolean | undefined; + setShowFull?: (show: boolean) => void; + openMenuMobile?: boolean; + setOpenMenuMobile?: (show: boolean) => void; +} diff --git a/src/components/layouts/GridColumn/GridColumn.tsx b/src/components/layouts/GridColumn/GridColumn.tsx new file mode 100644 index 0000000..68d696b --- /dev/null +++ b/src/components/layouts/GridColumn/GridColumn.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React from "react"; +import { GridColumnProps } from "./interface"; +import clsx from "clsx"; + +const GridColumn = ({ + children, + col = 3, + sm, + tabletCol3, + mobile2, + scroll20, + scrollMobile85, + className, +}: GridColumnProps) => { + return ( +
    + {children} +
    + ); +}; + +export default GridColumn; diff --git a/src/components/layouts/GridColumn/index.ts b/src/components/layouts/GridColumn/index.ts new file mode 100644 index 0000000..6b7d9a9 --- /dev/null +++ b/src/components/layouts/GridColumn/index.ts @@ -0,0 +1 @@ +export { default } from "./GridColumn"; diff --git a/src/components/layouts/GridColumn/interface/index.ts b/src/components/layouts/GridColumn/interface/index.ts new file mode 100644 index 0000000..9d1c8f9 --- /dev/null +++ b/src/components/layouts/GridColumn/interface/index.ts @@ -0,0 +1,14 @@ +export interface GridColumnProps { + children: React.ReactNode; + + col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12; + sm?: boolean; + + tabletCol3?: boolean; + mobile2?: boolean; + + scroll20?: boolean; + scrollMobile85?: boolean; + + className?: string; +} diff --git a/src/components/layouts/LayoutAuth/LayoutAuth.tsx b/src/components/layouts/LayoutAuth/LayoutAuth.tsx new file mode 100644 index 0000000..67066b0 --- /dev/null +++ b/src/components/layouts/LayoutAuth/LayoutAuth.tsx @@ -0,0 +1,40 @@ +"use client"; + +import RequiredLogout from "@/components/protected/RequiredLogout"; +import React from "react"; + +interface PropsLayoutAuth { + children: React.ReactNode; +} + +function LayoutAuth({ children }: PropsLayoutAuth) { + return ( + +
    + {/* BACKGROUND */} +
    + + {/* MAIN */} +
    + {children} +
    +
    + + ); +} + +export default LayoutAuth; diff --git a/src/components/layouts/LayoutAuth/index.ts b/src/components/layouts/LayoutAuth/index.ts new file mode 100644 index 0000000..b43ee9c --- /dev/null +++ b/src/components/layouts/LayoutAuth/index.ts @@ -0,0 +1 @@ +export { default } from "./LayoutAuth"; diff --git a/src/components/layouts/LayoutPages/LayoutPages.tsx b/src/components/layouts/LayoutPages/LayoutPages.tsx new file mode 100644 index 0000000..b263476 --- /dev/null +++ b/src/components/layouts/LayoutPages/LayoutPages.tsx @@ -0,0 +1,57 @@ +"use client"; +import React, { useCallback } from "react"; +import { PropsLayoutPages } from "./interface"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import clsx from "clsx"; + +const LayoutPages = ({ children, listPages }: PropsLayoutPages) => { + const pathname = usePathname(); + const checkActive = useCallback( + (url: string) => { + return pathname === url; + }, + [pathname], + ); + return ( + <> +
    + {listPages.map((item, i) => { + const isActive = checkActive(item.url); + return ( + { + if (isActive) e.preventDefault(); + }} + className={clsx( + "inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer", + "text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]", + isActive + ? "bg-[#3772ff] text-white border-[#3772ff]" + : "bg-white text-[#202939] border-[#eaedf2]", + i !== 0 && "ml-2", + )} + > + {" "} +
    +
    +

    {item.title}

    +
    + + ); + })} +
    + {/* MAIN */} +
    {children}
    + + ); +}; + +export default LayoutPages; diff --git a/src/components/layouts/LayoutPages/index.ts b/src/components/layouts/LayoutPages/index.ts new file mode 100644 index 0000000..14b40c4 --- /dev/null +++ b/src/components/layouts/LayoutPages/index.ts @@ -0,0 +1 @@ +export { default } from "./LayoutPages"; diff --git a/src/components/layouts/LayoutPages/interface/index.ts b/src/components/layouts/LayoutPages/interface/index.ts new file mode 100644 index 0000000..c47ef62 --- /dev/null +++ b/src/components/layouts/LayoutPages/interface/index.ts @@ -0,0 +1,8 @@ +export interface PropsLayoutPages { + children: React.ReactNode; + listPages: { + title: string; + url: string; + color: string; + }[]; +} diff --git a/src/components/page/airlines/DetailAirlines/DetailAirlines.tsx b/src/components/page/airlines/DetailAirlines/DetailAirlines.tsx new file mode 100644 index 0000000..7847174 --- /dev/null +++ b/src/components/page/airlines/DetailAirlines/DetailAirlines.tsx @@ -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({ + queryKey: [QUERY_KEY.detail_airlines, airlinesId], + enabled: !!airlinesId, + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: airlinesServices.detailAirlines(airlinesId), + }); + }, + }); + + const airlines = airlinesDetailQuery.data; + + if (!airlines) { + return ( +
    + Không tìm thấy thông tin hãng hàng không. +
    + ); + } + + return ( +
    +
    +
    +
    +

    router.back()} + > + + {airlines.name} +

    + {airlines?.status === STATUS_CONTINENTS.ACTIVE && ( + } + onClick={() => + router.push(`${PATH.Airlines}/update?id=${airlines.id}`) + } + > + Chỉnh sửa + + )} +
    + +
    +
    +

    + Mã hãng hàng không +

    +

    + {airlines.code} +

    +
    + +
    +

    + Tên hãng hàng không +

    +

    + {airlines.name} +

    +
    + +
    +

    Mã IATA

    +

    + {airlines.iataCode} +

    +
    + +
    +

    Mã ICAO

    +

    + {airlines.icaoCode} +

    +
    + +
    +

    Website

    +

    + {airlines.website} +

    +
    + +
    +

    + Trạng thái +

    + + +
    +
    +
    +
    +
    + ); +} + +export default DetailAirlines; diff --git a/src/components/page/airlines/DetailAirlines/index.ts b/src/components/page/airlines/DetailAirlines/index.ts new file mode 100644 index 0000000..4975c69 --- /dev/null +++ b/src/components/page/airlines/DetailAirlines/index.ts @@ -0,0 +1 @@ +export { default } from "./DetailAirlines"; diff --git a/src/components/page/airlines/MainPageAirlines/MainPageAirlines.tsx b/src/components/page/airlines/MainPageAirlines/MainPageAirlines.tsx new file mode 100644 index 0000000..c53998f --- /dev/null +++ b/src/components/page/airlines/MainPageAirlines/MainPageAirlines.tsx @@ -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(""); + + 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, + 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({ + queryKey: [QUERY_KEY.table_list_airline, page, pageSize, keyword, status], + queryFn: async () => { + return await httpRequest({ + 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({ + 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 ( +
    +
    + {/* Header */} +
    +

    Hãng Hàng Không

    + } + href={PATH.CreateAirlines} + > + Thêm mới + +
    + + {/* Bộ lọc & Tìm kiếm */} +
    + {/* Search */} +
    + updateQuery({ _keyword: value, _page: 1 })} + placeholder="Tìm kiếm theo mã/iata/icao/tên" + /> +
    +
    + + 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", + }, + ]} + /> +
    +
    + + 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, + })) ?? [] + } + /> +
    +
    +
    + {/* Bảng dữ liệu */} +
    + + item.id} + data={airlinesQuery.data?.items ?? []} + column={[ + { + fixedLeft: true, + title: "Mã Hãng Hàng Không", + render: (item: AirlinesItem) => ( + + + + {/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */} + + {item.code} + + + +

    + Xem chi tiết +

    +
    +
    +
    + ), + }, + { + title: "Tên Hãng Hàng Không", + render: (item: AirlinesItem) => ( + {item.name || "---"} + ), + }, + { + title: "Mã IATA", + render: (item: AirlinesItem) => ( + {item.iataCode || "---"} + ), + }, + { + title: "Mã ICAO", + render: (item: AirlinesItem) => ( + {item.icaoCode || "---"} + ), + }, + // { + // title: "LOGO", + // render: (item: AirlinesItem) => ( + // {item.logoUrl || "---"} + // ), + // }, + { + title: "Website", + render: (item: AirlinesItem) => ( + {item.website || "---"} + ), + }, + + { + title: "TRẠNG THÁI", + render: (item: AirlinesItem) => ( + + ), + }, + { + fixedRight: true, + title: "HÀNH ĐỘNG", + render: (item: AirlinesItem) => ( +
    + } + tooltip="Xem chi tiết" + /> + {item?.status === STATUS_CONTINENTS.ACTIVE && ( + <> + } + tooltip="Chỉnh sửa" + onClick={() => + router.push(`${PATH.Airlines}/update?id=${item.id}`) + } + /> + } + tooltip="Xóa" + onClick={() => setUuidDelete(item?.id)} + /> + + )} +
    + ), + }, + ]} + /> +
    + {/* Phân trang */} + updateQuery({ _page: value })} + onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })} + dependencies={[pageSize, keyword]} + /> +
    + + {/* Dialog xác nhận xóa */} + 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); + }} + /> +
    + ); +} + +export default MainPageAirlines; diff --git a/src/components/page/airlines/MainPageAirlines/index.ts b/src/components/page/airlines/MainPageAirlines/index.ts new file mode 100644 index 0000000..f54d932 --- /dev/null +++ b/src/components/page/airlines/MainPageAirlines/index.ts @@ -0,0 +1 @@ +export { default } from "./MainPageAirlines"; diff --git a/src/components/page/airlines/PageCreateAirlines/PageCreateAirlines.tsx b/src/components/page/airlines/PageCreateAirlines/PageCreateAirlines.tsx new file mode 100644 index 0000000..8a1d6fd --- /dev/null +++ b/src/components/page/airlines/PageCreateAirlines/PageCreateAirlines.tsx @@ -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(null); + +// const [form, setForm] = useState({ +// 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({ +// queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], +// queryFn: async () => { +// return await httpRequest({ +// 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 ( +// +//
    +// {/* HEADER */} +//
    +//

    router.back()} +// > +// Thêm Hãng Hàng Không +//

    +//
    +// router.back()} +// disabled={isLoading} +// > +// Hủy bỏ +// + +// +// {isLoading ? "Đang xử lý..." : "Thêm hãng"} +// +//
    +//
    + +// {/* BODY */} +//
    +// {/* SELECT QUỐC GIA */} +//
    +// +// Quốc gia * +// +// } +// 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} +// /> +//
    + +// {/* MÃ HÃNG */} +//
    +// handleChangeField("code", val)} +// label={ +// +// Mã hãng * +// +// } +// /> +//
    + +// {/* TÊN HÃNG */} +//
    +// handleChangeField("name", val)} +// label={ +// +// Tên hãng * +// +// } +// /> +//
    + +// {/* MÃ IATA VÀ ICAO */} +//
    +// handleChangeField("iataCode", val)} +// label={Mã IATA} +// /> +// handleChangeField("icaoCode", val)} +// label={Mã ICAO} +// /> +//
    + +// {/* WEBSITE */} +//
    +// handleChangeField("website", val)} +// label={WEBSITE} +// /> +//
    + +// {/* UPLOAD LOGO */} +//
    +// +// Hình ảnh logo * +// +// } +// name="logoUrl" +// file={file} +// setFile={setFile} +// path={form.logoUrl} +// resetPath={() => +// setForm((prev) => ({ +// ...prev, +// logoUrl: "", +// })) +// } +// /> +//
    +//
    +//
    +//
    +// ); +// }; + +// 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(null); + + const [form, setForm] = useState({ + 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({ + queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + 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 ( + +
    + {/* HEADER */} +
    +

    router.back()} + > + Thêm Hãng Hàng Không +

    +
    + router.back()} + disabled={isLoading} + > + Hủy bỏ + + + + {isLoading ? "Đang xử lý..." : "Thêm hãng"} + +
    +
    + + {/* BODY */} +
    + {/* SELECT QUỐC GIA */} +
    + + Quốc gia * + + } + 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} + /> +
    + + {/* MÃ HÃNG */} +
    + handleChangeField("code", val)} + label={ + + Mã hãng * + + } + /> +
    + + {/* TÊN HÃNG */} +
    + handleChangeField("name", val)} + label={ + + Tên hãng * + + } + /> +
    + + {/* MÃ IATA VÀ ICAO */} +
    + handleChangeField("iataCode", val)} + label={Mã IATA} + /> + handleChangeField("icaoCode", val)} + label={Mã ICAO} + /> +
    + + {/* WEBSITE */} +
    + handleChangeField("website", val)} + label={WEBSITE} + /> +
    + + {/* UPLOAD LOGO */} + {/*
    + + Hình ảnh logo * + + } + name="logoUrl" + file={file} + setFile={setFile} + path={form.logoUrl} + resetPath={() => + setForm((prev) => ({ + ...prev, + logoUrl: "", + })) + } + /> +
    */} +
    +
    +
    + ); +}; + +export default PageCreateAirlines; diff --git a/src/components/page/airlines/PageCreateAirlines/index.ts b/src/components/page/airlines/PageCreateAirlines/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/page/airlines/PageUpdateAirlines/PageUpdateAirlines.tsx b/src/components/page/airlines/PageUpdateAirlines/PageUpdateAirlines.tsx new file mode 100644 index 0000000..65dafe8 --- /dev/null +++ b/src/components/page/airlines/PageUpdateAirlines/PageUpdateAirlines.tsx @@ -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(null); + const [form, setForm] = useState({ + countryId: "", + code: "", + iataCode: "", + icaoCode: "", + name: "", + logoUrl: "string", + website: "", + }); + + const { data, isLoading } = useQuery({ + queryKey: [QUERY_KEY.detail_airlines, airlinesId], + enabled: !!airlinesId, + queryFn: async () => { + return await httpRequest({ + 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({ + queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + 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 ( +
    + Đang tải thông tin hãng hàng không... +
    + ); + } + return ( + +
    +
    +
    + + Chỉnh Sửa Hãng Hàng Không +
    + +
    + + Hủy bỏ + + + + {funcUpdateAirlines.isPending ? "Đang lưu..." : "Lưu thay đổi"} + +
    +
    + +
    + {/* SELECT QUỐC GIA */} +
    + + Quốc gia * + + } + 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} + /> +
    + + {/* MÃ HÃNG */} +
    + + setForm((prev) => ({ ...prev, code: String(val) })) + } + label={ + + Mã hãng * + + } + /> +
    + + {/* TÊN HÃNG */} +
    + + setForm((prev) => ({ ...prev, name: String(val) })) + } + label={ + + Tên hãng * + + } + /> +
    + + {/* MÃ IATA VÀ ICAO */} +
    + + setForm((prev) => ({ ...prev, iataCode: String(val) })) + } + label={Mã IATA} + /> + + setForm((prev) => ({ ...prev, icaoCode: String(val) })) + } + label={Mã ICAO} + /> +
    + + {/* WEBSITE */} +
    + + setForm((prev) => ({ ...prev, website: String(val) })) + } + label={WEBSITE} + /> +
    + + {/* UPLOAD LOGO */} + {/*
    + + Hình ảnh logo * + + } + name="logoUrl" + file={file} + setFile={setFile} + path={form.logoUrl} + resetPath={() => setForm((prev) => ({ ...prev, logoUrl: "" }))} + /> +
    */} +
    +
    +
    + ); +} diff --git a/src/components/page/airlines/PageUpdateAirlines/index.ts b/src/components/page/airlines/PageUpdateAirlines/index.ts new file mode 100644 index 0000000..d1eaea2 --- /dev/null +++ b/src/components/page/airlines/PageUpdateAirlines/index.ts @@ -0,0 +1 @@ +export { default } from "./PageUpdateAirlines"; diff --git a/src/components/page/continents/CreateContinents/CreateContinents.tsx b/src/components/page/continents/CreateContinents/CreateContinents.tsx new file mode 100644 index 0000000..2f5837f --- /dev/null +++ b/src/components/page/continents/CreateContinents/CreateContinents.tsx @@ -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({ + 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 ( + + +
    + {/* Header */} +

    + Thêm Mới Châu Lục +

    + + {/* Form */} + +
    + + Mã châu lục * + + } + /> +
    + + Tên châu lục * + + } + /> +
    +
    + + {/* Footer */} +
    + + Hủy bỏ + + + + {({ isDone }) => ( + } + onClick={handleSubmit} + > + Lưu lại + + )} + +
    + + {/* Close */} + +
    +
    + ); +} + +export default CreateContinents; diff --git a/src/components/page/continents/CreateContinents/index.ts b/src/components/page/continents/CreateContinents/index.ts new file mode 100644 index 0000000..f4e4b36 --- /dev/null +++ b/src/components/page/continents/CreateContinents/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateContinents"; diff --git a/src/components/page/continents/DetailContinent/DetailContinent.tsx b/src/components/page/continents/DetailContinent/DetailContinent.tsx new file mode 100644 index 0000000..dca38a5 --- /dev/null +++ b/src/components/page/continents/DetailContinent/DetailContinent.tsx @@ -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, + 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({ + queryKey: [QUERY_KEY.detail_continent, continentId], + enabled: !!continentId, + queryFn: async () => { + const res = await httpRequest({ + showMessageFailed: true, + http: continentsServices.detailContinents(continentId), + }); + + return ( + res || { + id: "", + code: "", + name: "", + status: "string", + sortOrder: 0, + createdAt: "", + updatedAt: "", + } + ); + }, + }); + + const continent = ContinentDetailQuery.data; + + return ( +
    +
    +
    +
    +
    +

    router.back()} + > + {continent?.name} +

    + {continent?.status === STATUS_CONTINENTS.ACTIVE && ( +
    + } + onClick={() => + updateQuery( + { + _uuidContinent: continent?.id, + }, + true, + ) + } + > + Chỉnh sửa + +
    + )} +
    + + {/* Thông tin chi tiết bệnh nhân */} +
    +
    +

    + Mã châu lục +

    +

    + {continent?.code} +

    +
    + +
    +

    + Tên châu lục +

    +

    + {continent?.name} +

    +
    +
    +

    + TRẠNG THÁI: +

    + +
    +
    +
    +
    +
    + + updateQuery( + { + _uuidContinent: null, + }, + true, + ) + } + > + {_uuidContinent && ( + + updateQuery( + { + _uuidContinent: null, + }, + true, + ) + } + /> + )} + +
    + ); +} + +export default DetailContinent; diff --git a/src/components/page/continents/DetailContinent/index.ts b/src/components/page/continents/DetailContinent/index.ts new file mode 100644 index 0000000..05f9db9 --- /dev/null +++ b/src/components/page/continents/DetailContinent/index.ts @@ -0,0 +1 @@ +export { default } from "./DetailContinent"; diff --git a/src/components/page/continents/MainPageContinents/MainPageContinents.tsx b/src/components/page/continents/MainPageContinents/MainPageContinents.tsx new file mode 100644 index 0000000..bc84419 --- /dev/null +++ b/src/components/page/continents/MainPageContinents/MainPageContinents.tsx @@ -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(""); + + 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, + 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({ + queryKey: [ + QUERY_KEY.table_list_continents, + page, + pageSize, + keyword, + status, + ], + queryFn: async () => { + return await httpRequest({ + 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 ( +
    +
    + {/* Header */} +
    +

    Châu Lục

    + } + 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 + +
    + + {/* Bộ lọc & Tìm kiếm */} +
    +
    + updateQuery({ _keyword: value, _page: 1 })} + placeholder="Tìm kiếm theo tên, ID châu lục" + /> +
    + + 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", + }, + ]} + /> +
    +
    + {/* Bảng dữ liệu */} +
    + + item.id} + data={continentQuery.data?.items ?? []} + column={[ + { + fixedLeft: true, + title: "MÃ CHÂU LỤC", + render: (item: ContinentsItem) => ( + + + + {/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */} + + {item.code} + + + +

    + Xem chi tiết +

    +
    +
    +
    + ), + }, + { + title: "TÊN CHÂU LỤC", + render: (item: ContinentsItem) => ( + {item.name || "---"} + ), + }, + { + title: "TRẠNG THÁI", + render: (item: ContinentsItem) => ( + + ), + }, + { + fixedRight: true, + title: "HÀNH ĐỘNG", + render: (item: ContinentsItem) => ( +
    + } + tooltip="Xem chi tiết" + /> + {item?.status === STATUS_CONTINENTS.ACTIVE && ( + <> + } + tooltip="Chỉnh sửa" + onClick={() => + updateQuery( + { + _uuidContinent: item.id, + }, + true, + ) + } + /> + } + tooltip="Xóa" + onClick={() => setUuidDelete(item?.id)} + /> + + )} +
    + ), + }, + ]} + /> +
    + {/* Phân trang */} + updateQuery({ _page: value })} + onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })} + dependencies={[pageSize, keyword]} + /> +
    + + + updateQuery( + { + action: null, + }, + true, + ) + } + > + {action === "create" && ( + + updateQuery( + { + action: null, + }, + true, + ) + } + /> + )} + + + + updateQuery( + { + _uuidContinent: null, + }, + true, + ) + } + > + {_uuidContinent && ( + + updateQuery( + { + _uuidContinent: null, + }, + true, + ) + } + /> + )} + + + {/* Dialog xác nhận xóa */} + 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); + }} + /> +
    + ); +} + +export default MainPageContinents; diff --git a/src/components/page/continents/MainPageContinents/index.ts b/src/components/page/continents/MainPageContinents/index.ts new file mode 100644 index 0000000..b2fbb43 --- /dev/null +++ b/src/components/page/continents/MainPageContinents/index.ts @@ -0,0 +1 @@ +export { default } from "./MainPageContinents"; diff --git a/src/components/page/continents/UpdateContinents/UpdateContinents.tsx b/src/components/page/continents/UpdateContinents/UpdateContinents.tsx new file mode 100644 index 0000000..3f13a14 --- /dev/null +++ b/src/components/page/continents/UpdateContinents/UpdateContinents.tsx @@ -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({ + name: "", + code: "", + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: [QUERY_KEY.detail_continent, uuidContinent], + enabled: !!uuidContinent, + queryFn: async () => { + return await httpRequest({ + 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 ( + + + +
    + {/* Header */} +

    + Chỉnh sửa châu lục +

    + + {/* Form */} + +
    + + Mã châu lục * + + } + /> +
    + + Tên châu lục * + + } + /> +
    +
    + + {/* Footer */} +
    + + Hủy bỏ + + + + {({ isDone }) => ( + } + onClick={handleSubmit} + > + Lưu lại + + )} + +
    + + {/* Close */} + +
    +
    + ); +} diff --git a/src/components/page/continents/UpdateContinents/index.ts b/src/components/page/continents/UpdateContinents/index.ts new file mode 100644 index 0000000..86d4b6d --- /dev/null +++ b/src/components/page/continents/UpdateContinents/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdateContinents"; diff --git a/src/components/page/countries/CreateCountries/CreateCountries.tsx b/src/components/page/countries/CreateCountries/CreateCountries.tsx new file mode 100644 index 0000000..17755f0 --- /dev/null +++ b/src/components/page/countries/CreateCountries/CreateCountries.tsx @@ -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({ + continentId: "", + code: "", + name: "", + iso2Code: "", + iso3Code: "", + phoneCode: "", + currencyCode: "", + timezone: "", + }); + + /* ========================= + GET LIST SPECIALTY + ========================= */ + const continentQuery = useQuery({ + queryKey: [QUERY_KEY.table_list_continents, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + 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 ( + +
    +
    +

    router.back()} + > + Thêm Quốc Gia +

    +
    + router.back()} + > + Hủy bỏ + + + + {funcCreateCountries.isPending ? "Đang thêm..." : "Thêm quốc gia"} + +
    +
    + + {/* 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 */} +
    +
    + + Châu lục * + + } + // 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} + /> +
    + +
    +
    + + Mã Quốc Gia * + + } + name="code" + type="text" + value={form.code} + placeholder="Nhập mã quốc gia" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, code: String(v) })) + } + /> + + Tên Quốc Gia * + + } + name="name" + type="text" + value={form.name} + placeholder="Nhập tên quốc gia" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, name: String(v) })) + } + /> + + Mã ISO 2 * + + } + 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 + /> + + Mã ISO 3 * + + } + 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 + /> +
    + + Mã Điện thoại * + + } + 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 + /> +
    + + + Mã Tiền Tệ * + + } + name="currencyCode" + type="text" + value={form.currencyCode} + placeholder="VND" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, currencyCode: String(v) })) + } + /> +
    + + {/* Phần Múi Giờ dưới cùng */} +
    + + Múi Giờ * + + } + 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 + /> +
    +
    +
    +
    +
    + ); +}; + +export default CreateCountries; diff --git a/src/components/page/countries/CreateCountries/index.ts b/src/components/page/countries/CreateCountries/index.ts new file mode 100644 index 0000000..8b51fec --- /dev/null +++ b/src/components/page/countries/CreateCountries/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateCountries"; diff --git a/src/components/page/countries/DetailCountries/DetailCountries.tsx b/src/components/page/countries/DetailCountries/DetailCountries.tsx new file mode 100644 index 0000000..470b1d0 --- /dev/null +++ b/src/components/page/countries/DetailCountries/DetailCountries.tsx @@ -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({ + queryKey: [QUERY_KEY.detail_countries, countriesId], + enabled: !!countriesId, + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: countriesServices.detailCountries(countriesId), + }); + }, + }); + + const countries = countriesDetailQuery.data; + + if (!countries) { + return ( +
    + Không tìm thấy thông tin quốc gia. +
    + ); + } + + return ( +
    +
    +
    +
    +

    router.back()} + > + + {countries.name} +

    + {countries?.status === STATUS_CONTINENTS.ACTIVE && ( + } + onClick={() => router.push(`/countries/update/${countries.id}`)} + > + Chỉnh sửa + + )} +
    + +
    +
    +

    + Mã quốc gia +

    +

    + {countries.code} +

    +
    + +
    +

    + Tên quốc gia +

    +

    + {countries.name} +

    +
    + +
    +

    Mã ISO 2

    +

    + {countries.iso2Code} +

    +
    + +
    +

    Mã ISO 3

    +

    + {countries.iso3Code} +

    +
    + +
    +

    + Mã điện thoại +

    +

    + {countries.phoneCode} +

    +
    + +
    +

    + Mã tiền tệ +

    +

    + {countries.currencyCode} +

    +
    + +
    +

    Múi giờ

    +

    + {countries.timezone} +

    +
    + +
    +

    + Trạng thái +

    + + +
    +
    +
    +
    +
    + ); +} + +export default DetailCountries; diff --git a/src/components/page/countries/DetailCountries/index.ts b/src/components/page/countries/DetailCountries/index.ts new file mode 100644 index 0000000..ab0e737 --- /dev/null +++ b/src/components/page/countries/DetailCountries/index.ts @@ -0,0 +1 @@ +export { default } from "./DetailCountries"; diff --git a/src/components/page/countries/MainPageCountries/MainPageCountries.tsx b/src/components/page/countries/MainPageCountries/MainPageCountries.tsx new file mode 100644 index 0000000..eceee1e --- /dev/null +++ b/src/components/page/countries/MainPageCountries/MainPageCountries.tsx @@ -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(""); + + 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, + 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({ + queryKey: [QUERY_KEY.table_list_countries, page, pageSize, keyword, status], + queryFn: async () => { + return await httpRequest({ + 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({ + 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 ( +
    +
    + {/* Header */} +
    +

    Quốc Gia

    + + } + href={PATH.CreateCountries} + className="w-full sm:w-auto" + > + Thêm mới + +
    + + {/* Bộ lọc & Tìm kiếm */} +
    + {/* Search */} +
    + + updateQuery({ + _keyword: value, + _page: 1, + }) + } + placeholder="Tìm kiếm theo mã/tên/ISO2/ISO3" + /> +
    + + {/* Status */} +
    + + 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", + }, + ]} + /> +
    + + {/* Continent */} +
    + + 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, + })) ?? [] + } + /> +
    +
    +
    + {/* Bảng dữ liệu */} +
    + + item.id} + data={countriesQuery.data?.items ?? []} + column={[ + { + fixedLeft: true, + title: "MÃ QUỐC GIA", + render: (item: CountriesItem) => ( + + + + {/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */} + + {item.code} + + + +

    + Xem chi tiết +

    +
    +
    +
    + ), + }, + { + title: "TÊN QUỐC GIA", + render: (item: CountriesItem) => ( + {item.name || "---"} + ), + }, + { + title: "MÃ ISO 2", + render: (item: CountriesItem) => ( + {item.iso2Code || "---"} + ), + }, + { + title: "MÃ ISO 3", + render: (item: CountriesItem) => ( + {item.iso3Code || "---"} + ), + }, + { + title: "MÃ ĐIỆN THOẠI", + render: (item: CountriesItem) => ( + {item.phoneCode || "---"} + ), + }, + { + title: "MÃ TIỀN TỆ", + render: (item: CountriesItem) => ( + {item.currencyCode || "---"} + ), + }, + { + title: "MÚI GIỜ", + render: (item: CountriesItem) => ( + {item.timezone || "---"} + ), + }, + { + title: "TRẠNG THÁI", + render: (item: CountriesItem) => ( + + ), + }, + { + fixedRight: true, + title: "HÀNH ĐỘNG", + render: (item: CountriesItem) => ( +
    + } + tooltip="Xem chi tiết" + /> + {item?.status === STATUS_CONTINENTS.ACTIVE && ( + <> + } + tooltip="Chỉnh sửa" + href={`/countries/update?id=${item.id}`} + /> + } + tooltip="Xóa" + onClick={() => setUuidDelete(item?.id)} + /> + + )} +
    + ), + }, + ]} + /> +
    + {/* Phân trang */} + updateQuery({ _page: value })} + onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })} + dependencies={[pageSize, keyword]} + /> +
    + + {/* Dialog xác nhận xóa */} + 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); + }} + /> +
    + ); +}; + +export default MainPageCountries; diff --git a/src/components/page/countries/MainPageCountries/index.ts b/src/components/page/countries/MainPageCountries/index.ts new file mode 100644 index 0000000..f8ce6ee --- /dev/null +++ b/src/components/page/countries/MainPageCountries/index.ts @@ -0,0 +1 @@ +export { default } from "./MainPageCountries"; diff --git a/src/components/page/countries/UpdateCountries/UpdateCountries.tsx b/src/components/page/countries/UpdateCountries/UpdateCountries.tsx new file mode 100644 index 0000000..09d860c --- /dev/null +++ b/src/components/page/countries/UpdateCountries/UpdateCountries.tsx @@ -0,0 +1,403 @@ +"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 { 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, { + CountriesDetail, +} 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 } from "react"; + +export interface IUpdateCountries { + continentId: string; + code: string; + name: string; + iso2Code: string; + iso3Code: string; + phoneCode: string; + currencyCode: string; + timezone: string; +} + +function UpdateCountries() { + const router = useRouter(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + + // Lấy từ dynamic route parameter hoặc query parameter + const countriesId = searchParams.get("id") || ""; + + const [form, setForm] = useState({ + continentId: "", + code: "", + name: "", + iso2Code: "", + iso3Code: "", + phoneCode: "", + currencyCode: "", + timezone: "", + }); + + /* ========================= + GET DETAIL API + ========================= */ + const { data, isLoading } = useQuery({ + queryKey: [QUERY_KEY.detail_countries, countriesId], + enabled: !!countriesId, + queryFn: async () => { + return await httpRequest({ + http: countriesServices.detailCountries(countriesId), + }); + }, + }); + + useEffect(() => { + if (!data) return; + + setForm({ + continentId: data?.continentId || "", + code: data?.code || "", + name: data?.name || "", + iso2Code: data?.iso2Code || "", + iso3Code: data?.iso3Code || "", + phoneCode: data?.phoneCode || "", + currencyCode: data?.currencyCode || "", + timezone: data?.timezone || "", + }); + }, [data]); + + /* ========================= + GET LIST CONTINENTS + ========================= */ + const continentQuery = useQuery({ + queryKey: [QUERY_KEY.table_list_continents, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: continentsServices.getContinents({ + status: STATUS_CONTINENTS.ACTIVE, + }), + }); + }, + }); + + const continentOptions = useMemo(() => { + return continentQuery.data?.items || []; + }, [continentQuery.data]); + + // SỬA TẠI ĐÂY: Loại bỏ việc chặn bởi continentOptions.length + // để form hiển thị ngay thông tin quốc gia khi vừa load xong chi tiết + + /* ========================= + UPDATE MUTATION + ========================= */ + const funcUpdateCountries = useMutation({ + mutationFn: async () => { + return await httpRequest({ + showMessageFailed: false, + http: countriesServices.updateCountries(countriesId, { + continentId: form?.continentId, + code: form?.code, + name: form?.name, + iso2Code: form?.iso2Code, + iso3Code: form?.iso3Code, + phoneCode: form?.phoneCode, + currencyCode: form?.currencyCode, + timezone: form?.timezone, + }), + }); + }, + + onSuccess: () => { + toastSuccess({ + msg: "Chỉnh sửa quốc gia thành công!", + }); + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.table_list_countries], + }); + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.detail_countries, countriesId], + }); + + 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?.continentId) { + return toastWarn({ msg: "Vui lòng chọn châu lục" }); + } + funcUpdateCountries.mutate(); + }; + + if (isLoading) { + return ( +
    + Đang tải thông tin quốc gia... +
    + ); + } + + return ( + +
    +
    +

    router.back()} + > + Chỉnh Sửa Quốc Gia +

    +
    + router.back()} + > + Hủy bỏ + + + + {/* SỬA: Đổi text hiển thị đúng ngữ cảnh cập nhật quốc gia */} + {funcUpdateCountries.isPending ? "Đang lưu..." : "Lưu thay đổi"} + +
    +
    + +
    +
    + + Châu lục * + + } + 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} + /> +
    + +
    +
    + + Mã Quốc Gia * + + } + name="code" + type="text" + value={form.code} + placeholder="Nhập mã quốc gia" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, code: String(v) })) + } + /> + + Tên Quốc Gia * + + } + name="name" + type="text" + value={form.name} + placeholder="Nhập tên quốc gia" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, name: String(v) })) + } + /> + + Mã ISO 2 * + + } + 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} + /> + + Mã ISO 3 * + + } + 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} + /> +
    + + Mã Điện thoại * + + } + 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})`} + getOptionValue={(item) => item.phoneCode} + /> +
    + + + Mã Tiền Tệ * + + } + name="currencyCode" + type="text" + value={form.currencyCode} + placeholder="VND" + isRequired + onChangeValue={(v) => + setForm((prev) => ({ ...prev, currencyCode: String(v) })) + } + /> +
    + +
    + + Múi Giờ * + + } + 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})`} + getOptionValue={(item) => item.timezone} + /> +
    +
    +
    +
    +
    + ); +} + +export default UpdateCountries; diff --git a/src/components/page/countries/UpdateCountries/index.ts b/src/components/page/countries/UpdateCountries/index.ts new file mode 100644 index 0000000..ff74272 --- /dev/null +++ b/src/components/page/countries/UpdateCountries/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdateCountries"; diff --git a/src/components/page/country-regions/CreateCountryRegions/CreateCountryRegions.tsx b/src/components/page/country-regions/CreateCountryRegions/CreateCountryRegions.tsx new file mode 100644 index 0000000..65176f3 --- /dev/null +++ b/src/components/page/country-regions/CreateCountryRegions/CreateCountryRegions.tsx @@ -0,0 +1,213 @@ +"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 SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm"; +import { ContextFormCustom } from "@/components/utils/FormCustom/contexts"; +import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum"; +import { httpRequest } from "@/services"; +import countriesServices, { + CountriesItem, + CountriesResponse, +} from "@/services/countriesServices"; +import countryServices from "@/services/countryServices"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CloseCircle, FolderOpen } from "iconsax-react"; +import React, { useMemo, useState } from "react"; + +export interface PropsCreateCountryRegions { + onClose: () => void; +} + +export interface IFormCreateCountryRegions { + code: string; + name: string; + countryId: string; +} + +function CreateCountryRegions({ onClose }: PropsCreateCountryRegions) { + const queryClient = useQueryClient(); + + const [form, setForm] = useState({ + code: "", + name: "", + countryId: "", + }); + + const countriesQuery = useQuery({ + queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: countriesServices.getCountries({ + status: STATUS_CONTINENTS.ACTIVE, // active | inactive | undefined + }), + }); + }, + }); + + const countriesOptions = useMemo(() => { + return countriesQuery.data?.items || []; + }, [countriesQuery.data]); + console.log(countriesOptions); + + const funcCreateCountry = useMutation({ + mutationFn: async () => { + return await httpRequest({ + showMessageFailed: true, + showMessageSuccess: true, + msgSuccess: "Thêm vùng thành công!", + + http: countryServices.createCountry({ + code: form?.code, + name: form?.name, + countryId: form?.countryId, + }), + }); + }, + 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_country_regions], + }); + + onClose(); + }, + }); + + const handleSubmit = () => { + const cleanCode = form.code?.trim(); + const cleanName = form.name?.trim(); + const cleanCountryId = form.countryId?.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!" }); + } + if (!cleanCountryId) { + return toastWarn({ msg: "Vui lòng chọn quốc gia!" }); + } + + // Cập nhật lại state chuẩn trước khi gửi + setForm({ code: cleanCode, name: cleanName, countryId: cleanCountryId }); + + return funcCreateCountry.mutate(); + }; + + return ( + + +
    + {/* Header */} +

    + Thêm Mới Vùng +

    + + {/* Form */} + +
    + + Quốc gia * + + } + readOnly={!!form?.countryId} + 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: "", + })) + } + // ✅ SỬA TẠI ĐÂY: Truyền trực tiếp item.name hoặc dùng đúng dấu huyền ` + getOptionLabel={(item: CountriesItem) => item.name} + getOptionValue={(item: CountriesItem) => item.id} + /> +
    + + Mã vùng * + + } + /> +
    +
    + + Tên vùng * + + } + /> +
    +
    + + {/* Footer */} +
    + + Hủy bỏ + + + + {({ isDone }) => ( + } + onClick={handleSubmit} + > + Lưu lại + + )} + +
    + + {/* Close */} + +
    +
    + ); +} + +export default CreateCountryRegions; diff --git a/src/components/page/country-regions/CreateCountryRegions/index.ts b/src/components/page/country-regions/CreateCountryRegions/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/page/country-regions/DetailCountryRegions/DetailCountryRegions.tsx b/src/components/page/country-regions/DetailCountryRegions/DetailCountryRegions.tsx new file mode 100644 index 0000000..54d50a0 --- /dev/null +++ b/src/components/page/country-regions/DetailCountryRegions/DetailCountryRegions.tsx @@ -0,0 +1,182 @@ +"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 { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Edit } from "iconsax-react"; +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from "next/navigation"; +import React from "react"; +import UpdateCountryRegions from "../UpdateCountryRegions"; +import countryServices, { CountryDetail } from "@/services/countryServices"; + +function DetailCountryRegions() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const countryRegionId = params?.id as string; + + const _uuidCountryRegion = searchParams.get("_uuidCountryRegion"); + + const updateQuery = ( + updates: Record, + 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 CountryDetailQuery = useQuery({ + queryKey: [QUERY_KEY.detail_country_region, countryRegionId], + enabled: !!countryRegionId, + queryFn: async () => { + const res = await httpRequest({ + showMessageFailed: true, + http: countryServices.detailCountry(countryRegionId), + }); + + return ( + res || { + id: "", + code: "", + status: "", + } + ); + }, + }); + + const countryRegions = CountryDetailQuery.data; + + return ( +
    +
    +
    +
    +
    +

    router.back()} + > + {countryRegions?.name} +

    + {countryRegions?.status === STATUS_CONTINENTS.ACTIVE && ( +
    + } + onClick={() => + updateQuery( + { + _uuidCountryRegion: countryRegions?.id, + }, + true, + ) + } + > + Chỉnh sửa + +
    + )} +
    + + {/* Thông tin chi tiết bệnh nhân */} +
    +
    +

    + Mã vùng miền +

    +

    + {countryRegions?.code} +

    +
    + +
    +

    + Tên vùng miền +

    +

    + {countryRegions?.name} +

    +
    +
    +

    + TRẠNG THÁI: +

    + +
    +
    +
    +
    +
    + + updateQuery( + { + _uuidCountryRegion: null, + }, + true, + ) + } + > + {_uuidCountryRegion && ( + + updateQuery( + { + _uuidCountryRegion: null, + }, + true, + ) + } + /> + )} + +
    + ); +} + +export default DetailCountryRegions; diff --git a/src/components/page/country-regions/DetailCountryRegions/index.ts b/src/components/page/country-regions/DetailCountryRegions/index.ts new file mode 100644 index 0000000..ce8389b --- /dev/null +++ b/src/components/page/country-regions/DetailCountryRegions/index.ts @@ -0,0 +1 @@ +export { default } from "./DetailCountryRegions"; diff --git a/src/components/page/country-regions/MainPageCountryRegions/MainPageCountryRegions.tsx b/src/components/page/country-regions/MainPageCountryRegions/MainPageCountryRegions.tsx new file mode 100644 index 0000000..b9e1de5 --- /dev/null +++ b/src/components/page/country-regions/MainPageCountryRegions/MainPageCountryRegions.tsx @@ -0,0 +1,386 @@ +"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 * 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, Hierarchy, 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 countryServices, { + CountryItem, + CountryResponse, +} from "@/services/countryServices"; +import countriesServices, { + CountriesResponse, +} from "@/services/countriesServices"; +import CreateCountryRegions from "../CreateCountryRegions/CreateCountryRegions"; +import UpdateCountryRegion from "../UpdateCountryRegions"; + +function MainPageCountryRegions() { + const router = useRouter(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const [uuidDelete, setUuidDelete] = useState(""); + + 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, + 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 countryQuery = useQuery({ + queryKey: [ + QUERY_KEY.table_list_country_regions, + page, + pageSize, + keyword, + status, + ], + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: countryServices.getCountry({ + page, + limit: pageSize, + search: keyword, + status: status || undefined, // active | inactive | undefined + }), + }); + }, + }); + + const countriesQuery = useQuery({ + queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + const res = await httpRequest({ + http: countriesServices.getCountries({ + status: STATUS_CONTINENTS.ACTIVE, + }), + showMessageFailed: true, + }); + + return res; + }, + }); + + const deleteCountryMutation = useMutation({ + mutationFn: async (id: string) => { + return await httpRequest({ + http: countryServices.deleteCountry(id), + showMessageSuccess: true, + msgSuccess: "Xóa vùng thành công!", + showMessageFailed: true, + }); + }, + + onSuccess: () => { + setUuidDelete(""); + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.table_list_country_regions], + }); + }, + + onError: (error: any) => { + console.log(error); + }, + }); + + return ( +
    +
    + {/* Header */} +
    +

    Vùng Miền

    + } + 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 + +
    + + {/* Bộ lọc & Tìm kiếm */} +
    + {/* Search */} +
    + updateQuery({ _keyword: value, _page: 1 })} + placeholder="Tìm kiếm theo tên, mã vùng" + /> +
    +
    + + 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", + }, + ]} + /> +
    +
    + + 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, + })) ?? [] + } + /> +
    +
    +
    + {/* Bảng dữ liệu */} +
    + + item.id} + data={countryQuery.data?.items ?? []} + column={[ + { + fixedLeft: true, + title: "MÃ VÙNG MIỀN", + render: (item: CountryItem) => ( + + + + {/* SỬA: Link dẫn tới chi tiết châu lục thay vì bệnh nhân (patient) */} + + {item.code} + + + +

    + Xem chi tiết +

    +
    +
    +
    + ), + }, + { + title: "TÊN VÙNG MIỀN", + render: (item: CountryItem) => ( + {item.name || "---"} + ), + }, + { + title: "TRẠNG THÁI", + render: (item: CountryItem) => ( + + ), + }, + { + fixedRight: true, + title: "HÀNH ĐỘNG", + render: (item: CountryItem) => ( +
    + } + tooltip="Xem chi tiết" + /> + {item?.status === STATUS_CONTINENTS.ACTIVE && ( + <> + } + tooltip="Chỉnh sửa" + onClick={() => + updateQuery( + { + _uuidCountryRegion: item.id, + }, + true, + ) + } + /> + } + tooltip="Xóa" + onClick={() => setUuidDelete(item?.id)} + /> + + )} +
    + ), + }, + ]} + /> +
    + {/* Phân trang */} + updateQuery({ _page: value })} + onSetPageSize={(value) => updateQuery({ _pageSize: value, _page: 1 })} + dependencies={[pageSize, keyword]} + /> +
    + + + updateQuery( + { + action: null, + }, + true, + ) + } + > + {action === "create" && ( + + updateQuery( + { + action: null, + }, + true, + ) + } + /> + )} + + + + updateQuery( + { + _uuidCountryRegion: null, + }, + true, + ) + } + > + {_uuidCountryRegion && ( + + updateQuery( + { + _uuidCountryRegion: null, + }, + true, + ) + } + /> + )} + + + {/* Dialog xác nhận xóa */} + 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 vùng này?" + onSubmit={() => { + if (!uuidDelete) return; + + deleteCountryMutation.mutate(uuidDelete); + }} + /> +
    + ); +} + +export default MainPageCountryRegions; diff --git a/src/components/page/country-regions/MainPageCountryRegions/index.ts b/src/components/page/country-regions/MainPageCountryRegions/index.ts new file mode 100644 index 0000000..c3bda8c --- /dev/null +++ b/src/components/page/country-regions/MainPageCountryRegions/index.ts @@ -0,0 +1 @@ +export { default } from "./MainPageCountryRegions"; diff --git a/src/components/page/country-regions/UpdateCountryRegions/UpdateCountryRegions.tsx b/src/components/page/country-regions/UpdateCountryRegions/UpdateCountryRegions.tsx new file mode 100644 index 0000000..faf1d3f --- /dev/null +++ b/src/components/page/country-regions/UpdateCountryRegions/UpdateCountryRegions.tsx @@ -0,0 +1,232 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { QUERY_KEY, STATUS_CONTINENTS } from "@/constant/config/enum"; +import { httpRequest } from "@/services"; + +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"; +import countryServices, { CountryDetail } from "@/services/countryServices"; +import countriesServices, { + CountriesItem, + CountriesResponse, +} from "@/services/countriesServices"; +import SelectForm from "@/components/utils/FormCustom/compoents/SelectForm/SelectForm"; + +export interface PropsUpdateCountryRegion { + onClose: () => void; +} + +export interface IUpdateCountryRegion { + name: string; + code: string; + countryId: string; +} + +export default function UpdateCountryRegion({ + onClose, +}: PropsUpdateCountryRegion) { + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + + const uuidCountryRegion = searchParams.get("_uuidCountryRegion"); + + const [form, setForm] = useState({ + name: "", + code: "", + countryId: "", + }); + + const countriesQuery = useQuery({ + queryKey: [QUERY_KEY.table_list_countries, STATUS_CONTINENTS.ACTIVE], + queryFn: async () => { + return await httpRequest({ + showMessageFailed: true, + http: countriesServices.getCountries({ + status: STATUS_CONTINENTS.ACTIVE, // active | inactive | undefined + }), + }); + }, + }); + + const countriesOptions = useMemo(() => { + return countriesQuery.data?.items || []; + }, [countriesQuery.data]); + console.log(countriesOptions); + + const { data, isLoading, isError } = useQuery({ + queryKey: [QUERY_KEY.detail_country_region, uuidCountryRegion], + enabled: !!uuidCountryRegion, + queryFn: async () => { + return await httpRequest({ + http: countryServices.detailCountry(uuidCountryRegion!), + }); + }, + }); + + useEffect(() => { + if (!data) return; + + setForm({ + name: data.name ?? "", + code: data.code ?? "", + countryId: data.countryId ?? "", + }); + }, [data]); + + const updateCountryMutation = useMutation({ + mutationFn: async () => { + return await httpRequest({ + showMessageFailed: false, + http: countryServices.updateCountry(uuidCountryRegion as string, { + name: form.name, + code: form.code, + countryId: form.countryId, + }), + }); + }, + + onSuccess: () => { + toastSuccess({ + msg: "Chỉnh sửa vùng thành công!", + }); + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.table_list_country_regions], + }); + + queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.detail_country_region, uuidCountryRegion], + }); + + onClose(); + }, + + onError: (error: any) => { + toastWarn({ + msg: + error?.response?.data?.error?.message ?? + error?.message ?? + "Có lỗi xảy ra.", + }); + }, + }); + + const handleSubmit = () => { + updateCountryMutation.mutate(); + }; + + return ( + + + +
    + {/* Header */} +

    + Chỉnh Sửa Vùng +

    + + {/* Form */} + +
    + + Quốc gia * + + } + readOnly={false} + 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: "", + })) + } + // ✅ SỬA TẠI ĐÂY: Truyền trực tiếp item.name hoặc dùng đúng dấu huyền ` + getOptionLabel={(item: CountriesItem) => item.name} + getOptionValue={(item: CountriesItem) => item.id} + /> +
    + + Mã vùng * + + } + /> +
    +
    + + Tên vùng * + + } + /> +
    +
    + + {/* Footer */} +
    + + Hủy bỏ + + + {/* + {({ isDone }) => ( + + )} + */} + } + onClick={handleSubmit} + > + Lưu lại + +
    + + {/* Close */} + +
    +
    + ); +} diff --git a/src/components/page/country-regions/UpdateCountryRegions/index.ts b/src/components/page/country-regions/UpdateCountryRegions/index.ts new file mode 100644 index 0000000..f259c69 --- /dev/null +++ b/src/components/page/country-regions/UpdateCountryRegions/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdateCountryRegions"; diff --git a/src/components/protected/LoadingTopBar/LoadingTopBar.tsx b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx new file mode 100644 index 0000000..fd14374 --- /dev/null +++ b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx @@ -0,0 +1,32 @@ +"use client"; + +import NProgress from "nprogress"; +import { useEffect } from "react"; +import "nprogress/nprogress.css"; + +function LoadingTopBar() { + useEffect(() => { + NProgress.configure({ showSpinner: false }); + const handleClick = (e: any) => { + const target = e.target.closest("a"); + if (target?.href && target.href.startsWith(window.location.origin)) { + NProgress.start(); + } + }; + + const handleStop = () => { + NProgress.done(); + }; + + window.addEventListener("click", handleClick); + window.addEventListener("load", handleStop); + + return () => { + window.removeEventListener("click", handleClick); + window.removeEventListener("load", handleStop); + }; + }, []); + return null; +} + +export default LoadingTopBar; diff --git a/src/components/protected/LoadingTopBar/index.ts b/src/components/protected/LoadingTopBar/index.ts new file mode 100644 index 0000000..6219428 --- /dev/null +++ b/src/components/protected/LoadingTopBar/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadingTopBar"; diff --git a/src/components/protected/RequireAuth/index.tsx b/src/components/protected/RequireAuth/index.tsx new file mode 100644 index 0000000..d6bf150 --- /dev/null +++ b/src/components/protected/RequireAuth/index.tsx @@ -0,0 +1,28 @@ +"use client"; +import { PATH } from "@/constant/config"; +import { RootState } from "@/redux/store"; +import { useRouter } from "next/navigation"; +import React, { useEffect } from "react"; +import { useSelector } from "react-redux"; + +interface IRequireAuthProps { + children: React.ReactNode; +} + +export default function RequireAuth(props: IRequireAuthProps) { + const router = useRouter(); + const { loading } = useSelector((state: RootState) => state.site); + const { isLogin } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (!isLogin && !loading) { + router.replace(PATH.Login); + } + }, [isLogin, loading, router]); + + if (isLogin && !loading) { + return <>{props.children}; + } + + return
    ; +} diff --git a/src/components/protected/RequiredLogout/index.tsx b/src/components/protected/RequiredLogout/index.tsx new file mode 100644 index 0000000..0e3cb5c --- /dev/null +++ b/src/components/protected/RequiredLogout/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PATH } from "@/constant/config"; +import { RootState } from "@/redux/store"; +import { useRouter } from "next/navigation"; +import { ReactNode, useEffect } from "react"; +import { useSelector } from "react-redux"; + +interface Props { + children: ReactNode; +} + +function RequiredLogout({ children }: Props) { + const { replace } = useRouter(); + const { isLogin } = useSelector((state: RootState) => state.auth); + const { loading } = useSelector((state: RootState) => state.site); + + useEffect(() => { + if (!isLogin && !loading) replace(PATH.Home); + }, [isLogin, loading, replace]); + if (!isLogin && !loading) { + return <>{children}; + } + + return
    ; +} + +export default RequiredLogout; diff --git a/src/components/protected/SplashScreen/SplashScreen.tsx b/src/components/protected/SplashScreen/SplashScreen.tsx new file mode 100644 index 0000000..883895d --- /dev/null +++ b/src/components/protected/SplashScreen/SplashScreen.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RootState, store } from "@/redux/store"; +import { useSelector } from "react-redux"; +import clsx from "clsx"; +import Image from "next/image"; +import icons from "@/constant/images/icons"; + +import { getItemStorage, setItemStorage } from "@/common/funcs/localStorage"; +import { KEY_STORE } from "@/constant/config"; + +import { setLoading, setRememberPassword } from "@/redux/reducer/site"; +import { + setDataLoginStorage, + setStateLogin, + setToken, +} from "@/redux/reducer/auth"; +import { setInfoUser } from "@/redux/reducer/user"; + +// Import trực tiếp file JSON cấu hình của bạn +import loadingAnim from "@/../public/static/anim/loading_screen.json"; + +function SplashScreen() { + const [isClosing, setIsClosing] = useState(false); + const [progress, setProgress] = useState(0); // Quản lý phần trăm loading thực tế + + const { loading, isRememberPassword } = useSelector( + (state: RootState) => state.site, + ); + const { infoUser } = useSelector((state: RootState) => state.user); + const { token, isLogin, dataLoginStorage } = useSelector( + (state: RootState) => state.auth, + ); + + // Đọc dữ liệu cấu hình động trực tiếp từ file JSON của bạn + const config = loadingAnim?.vna_loading_animation; + + // Trích xuất các biến từ file JSON + const bgColors = config?.background?.colors || ["#004b5e", "#007b85"]; + const aircraftAnim = config?.aircraft?.animation || { + duration: "3s", + timing: "ease-in-out", + }; + const loadingBarConfig = config?.loading_bar || { + color: "#c89f3c", + track_color: "rgba(255, 255, 255, 0.2)", + animation: { duration: "4s" }, + }; + const atmosphere = config?.atmosphere || { + clouds: { opacity: 0.3 }, + speed_lines: { opacity: 0.4 }, + }; + + // 🔥 Khởi tạo tiến trình chạy loading bar thực tế dựa trên thời gian (duration) trong JSON + useEffect(() => { + if (!loading) return; + + // Lấy thời gian từ cấu hình JSON (ví dụ "4s" -> 4000ms) + const durationSeconds = + parseFloat(loadingBarConfig.animation.duration) || 4; + const totalDuration = durationSeconds * 1000; + const intervalTime = 40; + const step = 100 / (totalDuration / intervalTime); + + const timer = setInterval(() => { + setProgress((prev) => { + if (prev >= 100) { + clearInterval(timer); + // Tắt loading sau khi thanh tiến trình chạy đầy 100% + setTimeout(() => { + store.dispatch(setLoading(false)); + }, 200); + return 100; + } + return prev + step; + }); + }, intervalTime); + + return () => clearInterval(timer); + }, [loading, loadingBarConfig.animation.duration]); + + // 🔥 Load state từ localStorage + useEffect(() => { + (async () => { + const encryptedState = await getItemStorage(KEY_STORE); + const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key"; + + const decode = (data: string): any => { + try { + const decoded = decodeURIComponent(atob(data)); + const clean = decoded.startsWith(SECRET_KEY) + ? decoded.slice(SECRET_KEY.length) + : null; + return clean ? JSON.parse(clean) : null; + } catch (err) { + console.error("Decode error:", err); + return null; + } + }; + + const state = + typeof encryptedState === "string" ? decode(encryptedState) : null; + + if (state) { + store.dispatch(setToken(state.token)); + store.dispatch(setStateLogin(state.isLogin)); + store.dispatch(setInfoUser(state.infoUser)); + store.dispatch(setRememberPassword(state.isRememberPassword)); + store.dispatch(setDataLoginStorage(state.dataLoginStorage)); + } + })(); + }, []); + + // 🔥 Lưu lại trạng thái khi hoàn tất + useEffect(() => { + if (!loading) { + const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key"; + + const encode = (data: string): string => { + const textToEncode = SECRET_KEY + data; + return btoa(encodeURIComponent(textToEncode)); + }; + + const hashedData = encode( + JSON.stringify({ + isLogin, + token, + infoUser, + isRememberPassword, + dataLoginStorage, + }), + ); + + setItemStorage(KEY_STORE, hashedData); + + // 👉 trigger animation fade out + setTimeout(() => setIsClosing(true), 0); + } + }, [loading, isLogin, token, infoUser, isRememberPassword, dataLoginStorage]); + + if (!loading && isClosing) return null; + + return ( +
    + {/* CSS Keyframes sử dụng các tham số động từ file JSON cấu hình */} + + + {/* --- HIỆU ỨNG BẦU TRỜI & ĐƯỜNG BAY (Atmosphere) --- */} + {/* Vệt gió lướt hoàng hôn */} +
    +
    + + {/* Các tầng mây mờ ảo phía sau máy bay */} +
    +
    + + {/* --- KHU VỰC TRUNG TÂM (Máy bay & Loading Bar) --- */} +
    + {/* Máy bay dập dềnh tích hợp ảnh trong suốt hoàn hảo */} +
    + {config?.aircraft?.brand +
    + + {/* --- THANH TIẾN TRÌNH (Loading Bar) --- */} +
    +
    + {/* Tiến trình tăng dần tự động dựa vào state progress */} +
    +
    +
    +
    +
    + ); +} + +export default SplashScreen; diff --git a/src/components/protected/SplashScreen/index.ts b/src/components/protected/SplashScreen/index.ts new file mode 100644 index 0000000..076d132 --- /dev/null +++ b/src/components/protected/SplashScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./SplashScreen"; diff --git a/src/components/utils/FilterDateRage/FilterDateRage.tsx b/src/components/utils/FilterDateRage/FilterDateRage.tsx new file mode 100644 index 0000000..8824d01 --- /dev/null +++ b/src/components/utils/FilterDateRage/FilterDateRage.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import clsx from "clsx"; +import * as Popover from "@radix-ui/react-popover"; + +import { PropsFilterDateRange } from "./interface"; +import Moment from "react-moment"; +import { TYPE_DATE } from "@/constant/config/enum"; +import { getDateRange } from "@/common/funcs/selectData"; +import { ListOptionFilterDate } from "@/constant/config"; +import DateOption from "./component/DateOption"; + +function FilterDateRange({ + styleRounded = false, + showOptionAll = false, + date, + setDate, + typeDate, + setTypeDate, + title = "Thời gian", +}: PropsFilterDateRange) { + const [openDate, setOpenDate] = useState(false); + + useEffect(() => { + if (typeDate !== null && typeDate !== TYPE_DATE.LUA_CHON) { + setDate(getDateRange(typeDate)); + } + }, [typeDate, setDate]); + + return ( + + + + + + + + + + ); +} + +export default FilterDateRange; diff --git a/src/components/utils/FilterDateRage/component/CalendarMain/CalendarMain.tsx b/src/components/utils/FilterDateRage/component/CalendarMain/CalendarMain.tsx new file mode 100644 index 0000000..a9308c3 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/CalendarMain/CalendarMain.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { memo, useCallback, useContext, useMemo } from "react"; +import clsx from "clsx"; +import type { PropsCalendarMain } from "./interface"; +import { ContextCalendar } from "../RangeDatePicker/RangeDatePicker"; + +const months = Array.from({ length: 12 }, (_, i) => i + 1); + +function CalendarMain({ + date, + setDate, + setType, + type, + year, +}: PropsCalendarMain) { + const context = useContext(ContextCalendar); + + // const hover = useMemo(() => { + // if (!context?.datePicker.from || !context?.dateHover) return null; + + // return context.dateHover > context.datePicker.from + // ? { + // start: context.datePicker.from, + // end: context.dateHover, + // } + // : { + // start: context.dateHover, + // end: context.datePicker.from, + // }; + // }, [context?.dateHover, context?.datePicker.from]); + + const hover = (() => { + if (!context?.datePicker.from || !context?.dateHover) { + return null; + } + + return context.dateHover > context.datePicker.from + ? { + start: context.datePicker.from, + end: context.dateHover, + } + : { + start: context.dateHover, + end: context.datePicker.from, + }; + })(); + + /** + * Chọn ngày + */ + const handleDatePick = useCallback( + (datePick: Date) => { + const { from, to } = context.datePicker; + + // chọn ngày bắt đầu + if (!from || to) { + context.setDatePicker({ + from: datePick, + to: null, + }); + return; + } + + // chọn ngày kết thúc + context.setDateHover(null); + + if (datePick < from) { + context.setDatePicker({ + from: datePick, + to: from, + }); + } else { + context.setDatePicker({ + from, + to: datePick, + }); + } + }, + [context], + ); + + /** + * Hover + */ + const handleHover = useCallback( + (value: Date) => { + if (context.datePicker.from && !context.datePicker.to) { + context.setDateHover(value); + } + }, + [context], + ); + + /** + * Calendar + */ + const rows = useMemo(() => { + const currentDate = new Date(); + + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0); + + const cursor = new Date(monthStart); + + while (cursor.getDay() !== 0) { + cursor.setDate(cursor.getDate() - 1); + } + + const weeks: React.ReactNode[] = []; + + while (cursor <= monthEnd) { + const week: React.ReactNode[] = []; + + for (let i = 0; i < 7; i++) { + const currDate = new Date(cursor); + + const isCurrentMonth = currDate.getMonth() === date.getMonth(); + + const isToday = currDate.toDateString() === currentDate.toDateString(); + + const isStart = + currDate.getTime() === context.datePicker.from?.getTime(); + + const isEnd = currDate.getTime() === context.datePicker.to?.getTime(); + + const isBetween = + context.datePicker.from && + context.datePicker.to && + currDate > context.datePicker.from && + currDate < context.datePicker.to; + + const isHover = hover && currDate > hover.start && currDate < hover.end; + + week.push( + , + ); + + cursor.setDate(cursor.getDate() + 1); + } + + weeks.push( +
    + {week} +
    , + ); + } + + return weeks; + }, [context, date, hover, handleDatePick, handleHover]); + + /** + * List year + */ + const listYear = useMemo(() => { + return Array.from( + { length: year.last - year.first + 1 }, + (_, i) => year.first + i, + ); + }, [year]); + + return ( +
    context.setDateHover(null)}> + {/* Calendar */} + {type === 0 && rows} + + {/* Month */} + {type === 1 && ( +
    + {months.map((month) => ( + + ))} +
    + )} + + {/* Year */} + {type === 2 && ( +
    + {listYear.map((item) => ( + + ))} +
    + )} +
    + ); +} + +export default memo(CalendarMain); diff --git a/src/components/utils/FilterDateRage/component/CalendarMain/index.ts b/src/components/utils/FilterDateRage/component/CalendarMain/index.ts new file mode 100644 index 0000000..cadb07e --- /dev/null +++ b/src/components/utils/FilterDateRage/component/CalendarMain/index.ts @@ -0,0 +1 @@ +export { default } from "./CalendarMain"; diff --git a/src/components/utils/FilterDateRage/component/CalendarMain/interface/index.ts b/src/components/utils/FilterDateRage/component/CalendarMain/interface/index.ts new file mode 100644 index 0000000..bd62012 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/CalendarMain/interface/index.ts @@ -0,0 +1,7 @@ +export interface PropsCalendarMain { + date: Date; + setDate: (date: Date) => void; + type: number; + setType: (num: number) => void; + year: any; +} diff --git a/src/components/utils/FilterDateRage/component/DateOption/DateOption.tsx b/src/components/utils/FilterDateRage/component/DateOption/DateOption.tsx new file mode 100644 index 0000000..e7b385e --- /dev/null +++ b/src/components/utils/FilterDateRage/component/DateOption/DateOption.tsx @@ -0,0 +1,89 @@ +"use client"; + +import clsx from "clsx"; + +import { PropsDateOption } from "./interface"; + +import { TYPE_DATE } from "@/constant/config/enum"; +import { getDateRange } from "@/common/funcs/selectData"; +import { ListOptionFilterDate } from "@/constant/config"; + +import RangeDatePicker from "../RangeDatePicker"; + +export default function DateOption({ + showOptionAll, + date, + setDate, + typeDate, + setTypeDate, + show, + setShow, +}: PropsDateOption) { + const isCustom = Number(typeDate) === TYPE_DATE.LUA_CHON; + + return ( +
    + {/* MENU */} +
    + {ListOptionFilterDate.map((item) => { + const active = item.value === typeDate; + + return ( +
    { + setTypeDate(item.value); + + if (item.value !== TYPE_DATE.LUA_CHON) { + setDate(getDateRange(item.value)); + setShow(false); + } + }} + className={clsx( + "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm", + active ? "bg-blue-50 text-blue-600" : "hover:bg-gray-50", + )} + > + {item.name} + + {active && item.value !== TYPE_DATE.LUA_CHON && ( + + + + )} +
    + ); + })} +
    + + {/* DATE PICKER */} + {isCustom && ( + setShow(false)} + /> + )} +
    + ); +} diff --git a/src/components/utils/FilterDateRage/component/DateOption/index.ts b/src/components/utils/FilterDateRage/component/DateOption/index.ts new file mode 100644 index 0000000..09e27e5 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/DateOption/index.ts @@ -0,0 +1 @@ +export { default } from "./DateOption"; diff --git a/src/components/utils/FilterDateRage/component/DateOption/interface/index.ts b/src/components/utils/FilterDateRage/component/DateOption/interface/index.ts new file mode 100644 index 0000000..5a52221 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/DateOption/interface/index.ts @@ -0,0 +1,12 @@ +export interface PropsDateOption { + showOptionAll?: boolean; + date: { + from: Date | null; + to: Date | null; + } | null; + setDate: (any: any) => void; + typeDate: number | null; + setTypeDate: (any: any) => void; + show: boolean; + setShow: (any: any) => void; +} diff --git a/src/components/utils/FilterDateRage/component/RangeDatePicker/RangeDatePicker.tsx b/src/components/utils/FilterDateRage/component/RangeDatePicker/RangeDatePicker.tsx new file mode 100644 index 0000000..c6e0096 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/RangeDatePicker/RangeDatePicker.tsx @@ -0,0 +1,398 @@ +"use client"; +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { PropsRangeDatePicker } from "./interface"; +import clsx from "clsx"; +import Moment from "react-moment"; +import CustomButton from "@/components/customs/custom-button"; +import CalendarMain from "../CalendarMain"; + +const daysOfWeek = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"]; +export const ContextCalendar = createContext(null); + +function RangeDatePicker({ + onClose, + onSetValue, + value, + onSubmit, + open, +}: PropsRangeDatePicker) { + const [typeCalendarLeft, setTypeCalendarLeft] = useState(0); + const [typeCalendarRight, setTypeCalendarRight] = useState(0); + const [dateLeft, setDateLeft] = useState(new Date()); + const [dateRight, setDateRight] = useState( + new Date(new Date().getFullYear(), new Date().getMonth() + 1), + ); + + const [dateHover, setDateHover] = useState(null); + // const [yearTableLeft, setYearTableLeft] = useState(); + // const [yearTableRight, setYearTableRight] = useState(); + const [datePicker, setDatePicker] = useState<{ + to: Date | null; + from: Date | null; + }>({ + from: null, + to: null, + }); + + const handleNext = useCallback( + (isCalendarRight?: boolean) => { + if (isCalendarRight) { + if (typeCalendarRight == 0) + setDateRight((prevDate) => { + const month = prevDate.getMonth() + 1; + const year = prevDate.getFullYear(); + const newDate = new Date(year, month); + return newDate; + }); + } else { + if (typeCalendarLeft == 0) + setDateLeft((prevDate) => { + const month = prevDate.getMonth() + 1; + const year = prevDate.getFullYear(); + const newDate = new Date(year, month); + if (newDate < dateRight) return newDate; + else return prevDate; + }); + } + }, + [dateRight, typeCalendarLeft, typeCalendarRight], + ); + + const handlePrev = useCallback( + (isCalendarRight?: boolean) => { + if (isCalendarRight) { + if (typeCalendarRight == 0) + setDateRight((prevDate) => { + const month = prevDate.getMonth() - 1; + const year = prevDate.getFullYear(); + const newDate = new Date(year, month); + if (dateLeft < newDate) return newDate; + else return prevDate; + }); + } else { + if (typeCalendarLeft == 0) + setDateLeft((prevDate) => { + const month = prevDate.getMonth() - 1; + const year = prevDate.getFullYear(); + const newDate = new Date(year, month); + return newDate; + }); + } + }, + [dateLeft, typeCalendarLeft, typeCalendarRight], + ); + + const titleMonthYear = (date: Date, type: number, year: any) => { + if (type == 1) { + return `${"Năm"} ${date.getFullYear()}`; + } + if (type == 2) { + return `${"Từ"} ${year.first} - ${year.last}`; + } + return `${"Tháng"} ${date.getMonth() + 1}, ${date.getFullYear()}`; + }; + + const handleSubmit = useCallback(() => { + if (datePicker.from && datePicker.to) { + onSetValue(datePicker); + onClose(); + onSubmit && onSubmit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [datePicker]); + + const handleChangeType = ( + setType: (number: number) => void, + type: number, + ) => { + if (type == 0) { + setType(1); + } + if (type == 1) { + setType(2); + } + if (type == 2) { + setType(0); + } + }; + + //Cap nhat value neu co + useEffect(() => { + if (!open) { + setDatePicker({ + from: value?.from, + to: value?.to, + }); + } else { + if (!!value?.from && !!value?.to) { + if ( + value?.from.getMonth() == value?.to.getMonth() && + value?.from.getFullYear() == value?.to.getFullYear() + ) { + setDateLeft(new Date(value?.from.getTime())); + setDateRight( + new Date(value?.from.getFullYear(), value?.from.getMonth() + 1), + ); + } else { + setDateLeft(new Date(value?.from.getTime())); + setDateRight(new Date(value?.to.getTime())); + } + } + } + }, [open, value?.from, value?.to]); + + const yearTableLeft = useMemo( + () => ({ + first: dateLeft.getFullYear() - 5, + last: dateLeft.getFullYear() + 6, + }), + [dateLeft], + ); + + const yearTableRight = useMemo( + () => ({ + first: dateRight.getFullYear() - 5, + last: dateRight.getFullYear() + 6, + }), + [dateRight], + ); + + return ( + +
    + {/* Show Date Header */} +
    + + {datePicker.from ? ( + + ) : ( + "Ngày bắt đầu" + )} + + + + + + + {datePicker.to ? ( + + ) : ( + "Ngày kết thúc" + )} + +
    + + {/* Group Calendar */} +
    + {/* Left Calendar */} +
    +
    +
    handlePrev(false)} + > + + + +
    +

    + handleChangeType(setTypeCalendarLeft, typeCalendarLeft) + } + > + {titleMonthYear(dateLeft, typeCalendarLeft, yearTableLeft)} +

    +
    handleNext(false)} + > + + + +
    +
    + {typeCalendarLeft === 0 ? ( +
    + {daysOfWeek.map((day, i) => ( +
    + {day} +
    + ))} +
    + ) : null} + +
    + + {/* Right Calendar */} +
    +
    +
    handlePrev(true)} + > + + + +
    +

    + handleChangeType(setTypeCalendarRight, typeCalendarRight) + } + > + {titleMonthYear(dateRight, typeCalendarRight, yearTableRight)} +

    +
    handleNext(true)} + > + + + +
    +
    + {typeCalendarRight === 0 ? ( +
    + {daysOfWeek.map((day, i) => ( +
    + {day} +
    + ))} +
    + ) : null} + +
    +
    + + {/* Group Buttons */} +
    + + Hủy bỏ + + + Áp dụng + +
    +
    +
    + ); +} + +export default RangeDatePicker; diff --git a/src/components/utils/FilterDateRage/component/RangeDatePicker/index.ts b/src/components/utils/FilterDateRage/component/RangeDatePicker/index.ts new file mode 100644 index 0000000..68339b9 --- /dev/null +++ b/src/components/utils/FilterDateRage/component/RangeDatePicker/index.ts @@ -0,0 +1 @@ +export { default } from "./RangeDatePicker"; diff --git a/src/components/utils/FilterDateRage/component/RangeDatePicker/interface/index.ts b/src/components/utils/FilterDateRage/component/RangeDatePicker/interface/index.ts new file mode 100644 index 0000000..1a4febd --- /dev/null +++ b/src/components/utils/FilterDateRage/component/RangeDatePicker/interface/index.ts @@ -0,0 +1,7 @@ +export interface PropsRangeDatePicker { + onClose: () => void; + onSetValue: (any: any) => void; + onSubmit?: () => void; + value: any; + open?: boolean; +} diff --git a/src/components/utils/FilterDateRage/index.ts b/src/components/utils/FilterDateRage/index.ts new file mode 100644 index 0000000..aee99cb --- /dev/null +++ b/src/components/utils/FilterDateRage/index.ts @@ -0,0 +1 @@ +export { default } from "./FilterDateRage"; diff --git a/src/components/utils/FilterDateRage/interface/index.ts b/src/components/utils/FilterDateRage/interface/index.ts new file mode 100644 index 0000000..5e6d459 --- /dev/null +++ b/src/components/utils/FilterDateRage/interface/index.ts @@ -0,0 +1,17 @@ +import { Dispatch, SetStateAction } from "react"; +import { TYPE_DATE } from "@/constant/config/enum"; + +export interface PropsFilterDateRange { + styleRounded?: boolean; + showOptionAll?: boolean; + date: { + from: Date | null; + to: Date | null; + } | null; + setDate: Dispatch< + SetStateAction<{ from: Date | null; to: Date | null } | null> + >; + typeDate: TYPE_DATE; + setTypeDate: Dispatch>; + title?: string; +} diff --git a/src/components/utils/FormCustom/FormCustom.tsx b/src/components/utils/FormCustom/FormCustom.tsx new file mode 100644 index 0000000..d555071 --- /dev/null +++ b/src/components/utils/FormCustom/FormCustom.tsx @@ -0,0 +1,69 @@ +"use client"; +import React, { useCallback, useMemo } from "react"; + +import { PropsFormCustom } from "./interface"; +import { ContextFormCustom } from "./contexts"; + +function FormCustom>({ + form, + setForm, + onSubmit, + children, +}: PropsFormCustom) { + // tạo object error ban đầu + const initialError = useMemo(() => { + return Object.fromEntries( + Object.keys(form).map((key) => [key, null]), + ) as Record; + }, [form]); + + const [countValidate, setCountValidate] = React.useState(0); + const [errorText, setErrorText] = + React.useState>(initialError); + + const [validate, setValidate] = React.useState | null>(null); + + // 👉 check form hợp lệ + const isDone = useMemo(() => { + if (!validate) return false; + return Object.values(validate).every(Boolean); + }, [validate]); + + // Check form hop le + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (isDone) { + onSubmit?.(); + } else { + setCountValidate((prev) => prev + 1); + } + }, + [isDone, onSubmit], + ); + + return ( + +
    + {children} +
    +
    + ); +} + +export default FormCustom; diff --git a/src/components/utils/FormCustom/compoents/InputForm/InputForm.tsx b/src/components/utils/FormCustom/compoents/InputForm/InputForm.tsx new file mode 100644 index 0000000..aa3e0a6 --- /dev/null +++ b/src/components/utils/FormCustom/compoents/InputForm/InputForm.tsx @@ -0,0 +1,437 @@ +"use client"; +import React, { useContext, useMemo, useState } from "react"; +import { PropsInputForm } from "./interface"; +import { ContextFormCustom, IContextFormCustom } from "../../contexts"; +import { validateEmail, validatePhoneNumber } from "@/common/funcs/validate"; +import { convertCoin, price } from "@/common/funcs/convertCoin"; +import clsx from "clsx"; + +function InputForm({ + type, + name, + placeholder, + icon, + label, + value, + unit, + note, + textRequired, + textConfirm, + valueConfirm, + isBlur = true, + onClean, + showDone, + readOnly, + max, + min, + isRequired, + isNumber, + isUppercase, + isPhone, + isEmail, + isMoney, + onBlur, + onChangeValue, +}: PropsInputForm) { + const isPassword = type === "password"; + + const [showPass, setShowPass] = useState(false); + const [isFocus, setIsFocus] = useState(false); + + const { + form, + countValidate, + errorText, + isDone, + setForm, + setValidate, + setErrorText, + } = useContext>(ContextFormCustom); + + // ========================= + // VALUE + // ========================= + + const currentValue = useMemo(() => { + return value ?? form[name] ?? ""; + }, [value, form, name]); + + // ========================= + // VALIDATE + // ========================= + + const handleValidate = (inputValue?: string | number) => { + const finalValue = String(inputValue ?? currentValue ?? ""); + + if (isRequired && finalValue.trim() === "") { + return { + valid: false, + message: textRequired || "Vui lòng nhập trường này", + }; + } + + if (finalValue) { + if (isNumber && isNaN(Number(finalValue))) { + return { + valid: false, + message: "Vui lòng chỉ nhập số", + }; + } + + if (isPhone && !validatePhoneNumber(finalValue)) { + return { + valid: false, + message: "Định dạng số điện thoại không đúng", + }; + } + + if (isEmail && !validateEmail(finalValue)) { + return { + valid: false, + message: "Định dạng email không chính xác", + }; + } + + if (valueConfirm && finalValue !== valueConfirm) { + return { + valid: false, + message: textConfirm || "Mật khẩu không trùng khớp", + }; + } + + if (max && finalValue.length > max) { + return { + valid: false, + message: `Nhập tối đa ${max} kí tự`, + }; + } + + if (min && finalValue.length < min) { + return { + valid: false, + message: `Nhập tối thiểu ${min} kí tự`, + }; + } + } + }; + + // ========================= + // ERROR + // ========================= + + const handleSetMessage = (inputValue?: string | number) => { + const result = handleValidate(inputValue); + setErrorText((prev) => ({ + ...prev, + [name]: result?.message || "", + })); + setValidate((prev) => ({ + ...prev, + [name]: result?.valid ?? true, + })); + }; + + // ========================= + // HANDLERS + // ========================= + + const handleFocus = () => { + setIsFocus(true); + setErrorText((prev) => ({ + ...prev, + [name]: null, + })); + }; + + const handleBlur = () => { + setIsFocus(false); + onBlur?.(); + if (isBlur) { + handleSetMessage(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + let inputValue = e.target.value; + + if (isUppercase) { + inputValue = inputValue.toUpperCase(); + } + + // Money + if (isMoney) { + const numberIsValue = Number(price(inputValue)); + inputValue = numberIsValue ? convertCoin(numberIsValue) : "0"; + } + + //Custom onChangeValue + if (onChangeValue) { + onChangeValue(inputValue); + return; + } + + //Update form + setForm((prev: any) => ({ + ...prev, + [name]: inputValue, + })); + + // Validate live + if (countValidate > 0) { + handleSetMessage(inputValue); + } + }; + + const handleClean = () => { + setForm((prev: any) => ({ + ...prev, + [name]: "", + })); + + setErrorText((prev) => ({ + ...prev, + [name]: null, + })); + + setValidate((prev) => ({ + ...prev, + [name]: !isRequired, + })); + }; + + // ========================= + // RENDER + // ========================= + + return ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* INPUT WRAPPER */} +
    + {/* ICON */} + {icon && ( +
    + {icon} +
    + )} + + {/* INPUT */} + + + {/* UNIT */} + {unit ? ( +
    + {unit} +
    + ) : ( +
    + {/* CLEAN */} + {onClean && !!currentValue && ( + + )} + + {/* DONE */} + {!isPassword && + showDone && + isDone && + !errorText?.[name] && + !!currentValue && ( + + + + )} + + {/* PASSWORD */} + {isPassword && ( + + )} +
    + )} +
    + + {/* ERROR */} + {errorText?.[name] && ( +

    + {errorText[name]} +

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

    + {note} +

    + )} +
    + ); +} + +export default InputForm; diff --git a/src/components/utils/FormCustom/compoents/InputForm/index.ts b/src/components/utils/FormCustom/compoents/InputForm/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/utils/FormCustom/compoents/InputForm/interface/index.ts b/src/components/utils/FormCustom/compoents/InputForm/interface/index.ts new file mode 100644 index 0000000..971e957 --- /dev/null +++ b/src/components/utils/FormCustom/compoents/InputForm/interface/index.ts @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; + +export interface PropsInputForm { + type: string; + name: string; + placeholder?: string; + + icon?: ReactNode; + label?: string | ReactNode; + + value?: string | number; + unit?: string; + note?: string; + textRequired?: string; + valueConfirm?: string; + textConfirm?: string; + + isBlur?: boolean; + onClean?: boolean; + showDone?: boolean; + readOnly?: boolean; + + max?: number; + min?: number; + + isRequired?: boolean; + isNumber?: boolean; + isPhone?: boolean; + isEmail?: boolean; + isMoney?: boolean; + isUppercase?: boolean; + + onBlur?: () => void; + + onChangeValue?: (value: string | number) => void; +} diff --git a/src/components/utils/FormCustom/compoents/SelectForm/SelectForm.tsx b/src/components/utils/FormCustom/compoents/SelectForm/SelectForm.tsx new file mode 100644 index 0000000..792c66b --- /dev/null +++ b/src/components/utils/FormCustom/compoents/SelectForm/SelectForm.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import Image from "next/image"; +import { IoIosArrowDown } from "react-icons/io"; +import { GrSearch } from "react-icons/gr"; +import { RiCloseCircleFill } from "react-icons/ri"; + +import { removeVietnameseTones } from "@/common/funcs/optionConvert"; +import icons from "@/constant/images/icons"; +import { PropsSelectForm } from "./interface"; + +function SelectForm({ + label, + placeholder = "Chọn...", + isSearch = true, + readOnly = false, + value, + options = [], + onClean, + onSelect, + getOptionLabel, + getOptionValue, +}: PropsSelectForm) { + const refWrapper = useRef(null); + const refInputSearch = useRef(null); + + const [isFocus, setIsFocus] = useState(false); + const [keyword, setKeyword] = useState(""); + + /* ================= CLICK OUTSIDE (SỬA LẠI ĐỂ TRÁNH XUNG ĐỘT) ================= */ + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + // Nếu click bên ngoài Wrapper thì mới đóng Dropdown + if ( + refWrapper.current && + !refWrapper.current.contains(e.target as Node) + ) { + setIsFocus(false); + setKeyword(""); + } + }; + + // Dùng 'click' thay vì 'mousedown' để tránh tranh chấp sự kiện với onClick của Select Box + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + }, []); + + /* ================= AUTO FOCUS SEARCH ================= */ + useEffect(() => { + if (isFocus && isSearch) { + const timer = setTimeout(() => { + refInputSearch.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [isFocus, isSearch]); + + /* ================= DATA FILTER ================= */ + const safeOptions = options ?? []; + + const selectedOption = safeOptions.find( + (opt) => getOptionValue(opt) === value, + ); + + const filteredOptions = safeOptions.filter((opt) => { + const labelText = removeVietnameseTones(getOptionLabel(opt) || ""); + const key = removeVietnameseTones(keyword || ""); + return labelText.includes(key); + }); + + /* ================= HANDLERS ================= */ + const handleToggleFocus = (e: React.MouseEvent) => { + e.stopPropagation(); // Dừng bọt sự kiện để không bị clickOutside bắt nhầm + if (!readOnly) { + setIsFocus((prev) => !prev); + } + }; + + const handleOptionClick = (e: React.MouseEvent, option: OptionType) => { + e.stopPropagation(); // Dừng bọt sự kiện + onSelect?.(option); + setIsFocus(false); + setKeyword(""); + }; + + return ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* MAIN SELECT BOX */} +
    + {/* VALUE / PLACEHOLDER */} +

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

    + + {/* CLEAR BUTTON */} + {onClean && !!value && !readOnly && ( +
    { + e.stopPropagation(); + onClean(); + setKeyword(""); + }} + > + +
    + )} + + {/* ARROW ICON */} +
    + +
    +
    + + {/* DROPDOWN OPTIONS */} + {isFocus && !readOnly && ( +
    e.stopPropagation()} + className="absolute left-0 top-[calc(100%+6px)] z-50 w-full bg-white border border-[#0019f6] rounded-[20px] p-2.5 shadow-lg" + > + {/* SEARCH INPUT */} + {isSearch && ( +
    +
    + +
    + setKeyword(e.target.value)} + className="flex-1 h-full bg-transparent outline-none border-none text-sm font-medium text-gray-800 placeholder-[#9ea5c0] px-1" + /> +
    + )} + + {/* LIST OPTION */} + {filteredOptions.length > 0 ? ( +
    + {filteredOptions.map((opt) => { + const isActive = getOptionValue(opt) === value; + + return ( +
    handleOptionClick(e, opt)} + className={clsx( + "px-4 py-2.5 text-sm font-medium rounded-full cursor-pointer transition-colors duration-200 select-none", + isActive + ? "bg-[#0019f6] text-white" + : "text-gray-900 hover:bg-[#0019f6] hover:text-white", + )} + > + {getOptionLabel(opt)} +
    + ); + })} +
    + ) : ( + /* EMPTY STATE */ +
    + image empty +

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

    +
    + )} +
    + )} +
    + ); +} + +export default SelectForm; diff --git a/src/components/utils/FormCustom/compoents/SelectForm/index.ts b/src/components/utils/FormCustom/compoents/SelectForm/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/utils/FormCustom/compoents/SelectForm/interface/index.ts b/src/components/utils/FormCustom/compoents/SelectForm/interface/index.ts new file mode 100644 index 0000000..22e082a --- /dev/null +++ b/src/components/utils/FormCustom/compoents/SelectForm/interface/index.ts @@ -0,0 +1,15 @@ +export interface PropsSelectForm { + placeholder: string; + label?: string | React.ReactNode; + + isSearch?: boolean; + readOnly?: boolean; + + value: string | number | React.ReactNode; + options: OptionType[]; + + onClean?: () => void; + onSelect: (option: OptionType) => void; + getOptionLabel: (option: OptionType) => string; + getOptionValue: (option: OptionType) => string | number; +} diff --git a/src/components/utils/FormCustom/compoents/SelectMany/SelectMany.tsx b/src/components/utils/FormCustom/compoents/SelectMany/SelectMany.tsx new file mode 100644 index 0000000..a6fb076 --- /dev/null +++ b/src/components/utils/FormCustom/compoents/SelectMany/SelectMany.tsx @@ -0,0 +1,316 @@ +"use client"; + +import React, { Fragment, useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +// import { CirclePlus, Trash, Search, X } from "lucide-react"; +import { removeVietnameseTones } from "@/common/funcs/optionConvert"; +import Image from "next/image"; +import icons from "@/constant/images/icons"; +import Popup from "@/components/customs/custom-popup"; +import CustomButton from "@/components/customs/custom-button"; + +import { PropsSelectMany } from "./interface"; + +function SelectMany({ + text, + label, + placeholder, + isSearch = true, + showSelectedItems = false, + readOnly, + disabledItems = [], + selectedItems = [], + options = [], + title, + onClickSelect, + setSelectedItems, + getOptionLabel, + getOptionValue, + onRemove, + action, + renderOption, + selectedItemFields, + selectedItemFieldLabels, +}: PropsSelectMany) { + const refInputSearch = useRef(null); + + const [keyword, setKeyword] = useState(""); + const [isFocus, setIsFocus] = useState(false); + const [listDataTerm, setListDataTerm] = + useState>(selectedItems); + + useEffect(() => { + if (isFocus) { + setTimeout(() => refInputSearch.current?.focus(), 0); + } + }, [isFocus]); + + const filteredOptions = options.filter((opt) => + removeVietnameseTones(String(getOptionLabel(opt) || "")).includes( + removeVietnameseTones(keyword), + ), + ); + + const handleOptionClick = (option: OptionType) => { + const value = getOptionValue(option); + if (disabledItems.includes(value)) return; + + setListDataTerm((prev) => + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value], + ); + + setKeyword(""); + }; + + const handleRemoveSelected = (value: string | number) => { + const updated = listDataTerm.filter((v) => v !== value); + setListDataTerm(updated); + + onRemove ? onRemove(value) : setSelectedItems?.(updated); + }; + + const handleClose = () => { + setKeyword(""); + setIsFocus(false); + + // reset lại data cũ + setTimeout(() => { + setListDataTerm(selectedItems); + }, 0); + }; + + const handleSaveData = () => { + setIsFocus(false); + setSelectedItems?.(listDataTerm); + }; + + return ( + +
    + {label && ( + + )} + + {/* Select box */} +
    !readOnly && setIsFocus(true)} + className={clsx( + "flex items-center justify-between h-12 px-4 rounded-full border cursor-pointer transition", + "border-gray-300 bg-white hover:border-blue-700", + isFocus && "border-blue-700", + readOnly && "bg-gray-100 border-gray-200 cursor-not-allowed", + )} + > +

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

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

    {title || "Danh sách"}

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

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

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

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

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

    + {selectedItem ? selectedLabel : placeholder} +

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

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

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

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

    +
    + )} + + {/* Footer */} +
    + + Hủy bỏ + + + + Xác nhận + +
    + + {/* Close */} +
    + + + + {/* */} +
    +
    +
    +
    + ); +} + +export default SelectSingle; diff --git a/src/components/utils/FormCustom/compoents/SelectSingle/index.ts b/src/components/utils/FormCustom/compoents/SelectSingle/index.ts new file mode 100644 index 0000000..5e9f46e --- /dev/null +++ b/src/components/utils/FormCustom/compoents/SelectSingle/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectSingle"; diff --git a/src/components/utils/FormCustom/compoents/SelectSingle/interface/index.ts b/src/components/utils/FormCustom/compoents/SelectSingle/interface/index.ts new file mode 100644 index 0000000..99c80ab --- /dev/null +++ b/src/components/utils/FormCustom/compoents/SelectSingle/interface/index.ts @@ -0,0 +1,17 @@ +export interface PropsSelectSingle { + text?: string; + label?: React.ReactNode; + placeholder?: string; + isSearch?: boolean; + readOnly?: boolean; + disabledItems?: (string | number)[]; + selectedItem?: string | number | null; + options: OptionType[]; + title?: string; + action?: React.ReactNode; + getOptionLabel: (opt: OptionType) => string; + getOptionValue: (opt: OptionType) => string | number; + setSelectedItem?: (value: string | number | null) => void; + renderOption?: (option: OptionType) => React.ReactNode; + onClickSelect?: () => void; +} diff --git a/src/components/utils/FormCustom/compoents/TextArea/TextArea.tsx b/src/components/utils/FormCustom/compoents/TextArea/TextArea.tsx new file mode 100644 index 0000000..98f98d5 --- /dev/null +++ b/src/components/utils/FormCustom/compoents/TextArea/TextArea.tsx @@ -0,0 +1,208 @@ +"use client"; + +import React, { useContext, useEffect, useState } from "react"; +import clsx from "clsx"; +import { PropsTextArea } from "./interface"; +import { ContextFormCustom, IContextFormCustom } from "../../contexts"; + +function TextArea({ + name, + value, + placeholder, + label, + isBlur = true, + showDone, + readOnly, + max, + min, + isRequired, + textRequired, + onChangeValue, // Nhận callback custom từ cha +}: PropsTextArea) { + const [isFocus, setIsFocus] = useState(false); + + const { + form, + countValidate, + errorText, + isDone, + setForm, + setValidate, + setErrorText, + } = useContext>(ContextFormCustom); + + // Lấy giá trị hiện tại (ưu tiên value từ props, sau đó là value trong context) + const currentValue = value ?? form?.[name] ?? ""; + + // ========================= + // VALIDATE LOGIC + // ========================= + + const handleValidate = (inputValue?: string) => { + const finalValue = (inputValue ?? String(currentValue)).trim(); + + if (isRequired && finalValue === "") { + return { + valid: false, + message: textRequired || "Vui lòng nhập trường này", + }; + } + + if (max && finalValue.length > max) { + return { + valid: false, + message: `Nhập tối đa ${max} kí tự`, + }; + } + + if (min && finalValue.length < min) { + return { + valid: false, + message: `Nhập tối thiểu ${min} kí tự`, + }; + } + + return { + valid: true, + message: null, + }; + }; + + // Cập nhật lỗi và trạng thái validate vào Context + const handleSetMessage = (inputValue?: string) => { + const result = handleValidate(inputValue); + + setErrorText((prev) => ({ + ...prev, + [name]: result.message, + })); + + setValidate((prev) => ({ + ...prev, + [name]: result.valid, + })); + }; + + // ========================= + // EFFECTS + // ========================= + + // Chạy validate toàn cục khi Form yêu cầu (countValidate tăng) + useEffect(() => { + if (countValidate > 0) { + handleSetMessage(); + } + }, [countValidate]); + + // Cập nhật trạng thái valid thầm lặng vào context mỗi khi dữ liệu thay đổi + useEffect(() => { + const result = handleValidate(); + setValidate((prev) => ({ + ...prev, + [name]: result.valid, + })); + }, [currentValue]); + + // ========================= + // EVENT HANDLERS + // ========================= + + const handleFocus = () => { + setIsFocus(true); + // Xóa thông báo lỗi khi người dùng bắt đầu sửa lại + setErrorText((prev) => ({ + ...prev, + [name]: null, + })); + }; + + const handleBlur = () => { + setIsFocus(false); + if (isBlur) { + handleSetMessage(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + // 1. Nếu có hàm xử lý riêng (như cập nhật vào mảng ở component cha) + if (onChangeValue) { + onChangeValue(inputValue); + return; + } + + // 2. Mặc định: cập nhật vào state chung của FormCustom qua Context + setForm((prev: any) => ({ + ...prev, + [name]: inputValue, + })); + + // Nếu form đã từng submit (countValidate > 0), thực hiện validate ngay lập tức (live validation) + if (countValidate > 0) { + handleSetMessage(inputValue); + } + }; + + // ========================= + // RENDER + // ========================= + + return ( +
    + {/* LABEL */} + {label && ( + + )} + + {/* TEXTAREA WRAPPER */} +