feat:update web

This commit is contained in:
TuanVT 2026-05-24 23:33:43 +07:00
parent b6ccde2c74
commit b0baa509d9
58 changed files with 4920 additions and 292 deletions

View File

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

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailConsultation from "@/components/page/consultation/DetailConsultation";
const Page = () => {
return (
<BaseLayout title="Chi tiết phiên khám">
<DetailConsultation />
</BaseLayout>
);
};
export default Page;

View File

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

View File

@ -1,11 +1,10 @@
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout"; import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
import MainPageHome from "@/components/page/Home/MainPageHome/MainPageHome"; import MainPagePatient from "@/components/page/patient/MainPagePatient";
import Image from "next/image";
export default function Home() { export default function Home() {
return ( return (
<BaseLayout title="Dashboard"> <BaseLayout title="Bệnh nhân">
<MainPageHome /> <MainPagePatient />
</BaseLayout> </BaseLayout>
); );
} }

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import DetailPatient from "@/components/page/patient/DetailPatient";
import BaseLayout from "@/components/layouts/BaseLayout";
const Page = () => {
return (
<BaseLayout title="Chi tiết bệnh nhân">
<DetailPatient />
</BaseLayout>
);
};
export default Page;

View File

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

View File

@ -0,0 +1,14 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import DetailPrescription from "@/components/page/prescription/DetailPrescription";
import React from "react";
const page = () => {
return (
<BaseLayout title="Chi tiết đơn thuốc">
<DetailPrescription />
</BaseLayout>
);
};
export default page;

View File

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

View File

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

View File

@ -0,0 +1,14 @@
"use client";
import BaseLayout from "@/components/layouts/BaseLayout";
import UpdatePrescription from "@/components/page/prescription/UpdatePrescription";
import React from "react";
const page = () => {
return (
<BaseLayout title="Cập nhật đơn thuốc">
<UpdatePrescription />
</BaseLayout>
);
};
export default page;

View File

@ -13,6 +13,8 @@ export interface PropsButton {
target?: string; target?: string;
disabled?: boolean; disabled?: boolean;
type?: "button" | "submit" | "reset";
variant?: variant?:
| "default" | "default"
| "midnightBlue" | "midnightBlue"
@ -36,6 +38,9 @@ export default function CustomButton({
className, className,
target, target,
disabled = false, disabled = false,
type = "button",
variant = "default", variant = "default",
size = "md", size = "md",
rounded = "md", rounded = "md",
@ -112,7 +117,7 @@ export default function CustomButton({
// 🔘 Button mode // 🔘 Button mode
return ( return (
<button <button
type="button" type={type}
onClick={handleClick} onClick={handleClick}
className={baseClass} className={baseClass}
disabled={disabled} disabled={disabled}

View File

@ -12,59 +12,59 @@ const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
const [showFull, setShowFull] = React.useState(false); const [showFull, setShowFull] = React.useState(false);
const [openMenuMobile, setOpenMenuMobile] = React.useState(false); const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
return ( return (
// <RequireAuth> <RequireAuth>
<ContextBaseLayout <ContextBaseLayout
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }} value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
> >
<div className="min-h-screen bg-[#f8f8f8]"> <div className="min-h-screen bg-[#f8f8f8]">
{/* OVERLAY cho Mobile */} {/* OVERLAY cho Mobile */}
<div <div
className={clsx( className={clsx(
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden", "fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible", openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
)} )}
onClick={() => setOpenMenuMobile(false)} onClick={() => setOpenMenuMobile(false)}
/> />
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */} {/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
<nav <nav
className={clsx( className={clsx(
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]", "fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
// Desktop logic // Desktop logic
showFull ? "xl:translate-x-0" : "xl:-translate-x-full", showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
// Mobile logic // Mobile logic
openMenuMobile ? "translate-x-0" : "-translate-x-full", openMenuMobile ? "translate-x-0" : "-translate-x-full",
)} )}
> >
<Navbar /> <Navbar />
</nav> </nav>
{/* HEADER */} {/* HEADER */}
<header <header
className={clsx( className={clsx(
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]", "fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
showFull showFull
? "xl:left-[240px] xl:w-[calc(100%-240px)]" ? "xl:left-[240px] xl:w-[calc(100%-240px)]"
: "left-0 w-full", : "left-0 w-full",
"left-0 w-full", // Mặc định full width trên mobile "left-0 w-full", // Mặc định full width trên mobile
)} )}
> >
<Header title={title} breadcrumb={breadcrumb} /> <Header title={title} breadcrumb={breadcrumb} />
</header> </header>
{/* MAIN CONTENT */} {/* MAIN CONTENT */}
<main <main
className={clsx( className={clsx(
"pt-[92px] pb-6 px-6 transition-all duration-300", "pt-[92px] pb-6 px-6 transition-all duration-300",
showFull ? "xl:pl-[264px]" : "xl:pl-6", showFull ? "xl:pl-[264px]" : "xl:pl-6",
"pl-6", // Mặc định padding trên mobile "pl-6", // Mặc định padding trên mobile
)} )}
> >
{children} {children}
</main> </main>
</div> </div>
</ContextBaseLayout> </ContextBaseLayout>
// </RequireAuth> </RequireAuth>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { use, useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { PropsMenuProfile } from "./interface"; import { PropsMenuProfile } from "./interface";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@ -11,15 +11,12 @@ import { PATH } from "@/constant/config";
import Link from "next/link"; import Link from "next/link";
import clsx from "clsx"; import clsx from "clsx";
import { LogOut, ShieldCog, ShieldUser } from "lucide-react"; import { LogOut, ShieldCog, ShieldUser } from "lucide-react";
import CustomDialog from "@/components/customs/custom-dialog";
const MenuProfile = ({ onClose }: PropsMenuProfile) => { const MenuProfile = ({ onClose }: PropsMenuProfile) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [openLogout, setOpenLogout] = useState(false);
/* ================== CHECK ACTIVE ================== */ /* ================== CHECK ACTIVE ================== */
const checkActive = useCallback( const checkActive = useCallback(
(path: string) => { (path: string) => {
@ -32,16 +29,18 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => mutationFn: () =>
httpRequest({ httpRequest({
showMessageFailed: true, showMessageFailed: false,
showMessageSuccess: false, showMessageSuccess: false,
http: authServices.logout(), http: authServices.logout(),
}), }),
onSuccess(data) {
if (data) { onSettled() {
store.dispatch(logout()); store.dispatch(logout());
store.dispatch(setInfoUser(null)); store.dispatch(setInfoUser(null));
router.push(PATH.LOGIN);
} onClose();
router.replace(PATH.LOGIN);
}, },
}); });
@ -90,8 +89,7 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
{/* LOGOUT */} {/* LOGOUT */}
<div <div
onClick={() => { onClick={() => {
setOpenLogout(true); handleLogout();
onClose();
}} }}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition" className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
> >
@ -101,18 +99,6 @@ const MenuProfile = ({ onClose }: PropsMenuProfile) => {
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p> <p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
</div> </div>
</div> </div>
{/* DIALOG */}
<CustomDialog
open={openLogout}
onClose={() => setOpenLogout(false)}
onSubmit={handleLogout}
title="Đăng xuất"
note="Bạn có muốn đăng xuất khỏi hệ thống không?"
titleCancel="Không"
titleSubmit="Đăng xuất"
type="error"
/>
</div> </div>
); );
}; };

View File

@ -7,6 +7,7 @@ import { Menus, PATH } from "@/constant/config";
import Image from "next/image"; import Image from "next/image";
import icons from "@/constant/images/icons"; import icons from "@/constant/images/icons";
import clsx from "clsx"; import clsx from "clsx";
import { Hospital } from "lucide-react";
const Navbar = () => { const Navbar = () => {
const pathname = usePathname(); const pathname = usePathname();
@ -22,10 +23,11 @@ const Navbar = () => {
return ( return (
<div className="h-full w-full flex flex-col items-center p-3 bg-white"> <div className="h-full w-full flex flex-col items-center p-3 bg-white">
<Link href={PATH.HOME} className="flex items-center justify-center py-2"> <Link href={PATH.HOME} className="flex items-center justify-center py-2">
<Image alt="Logo" src={icons.avatar} width={40} height={40} /> {/* <Image alt="Logo" src={icons.avatar} width={40} height={40} /> */}
<h4 className="ml-2 text-sm font-semibold text-blue-900 select-none"> <Hospital className="text-blue-900" />
Quản <h2 className="ml-2 text-sm font-semibold text-blue-900 select-none">
</h4> Quản khám bệnh
</h2>
</Link> </Link>
<div className="flex-1 w-full overflow-auto pt-3"> <div className="flex-1 w-full overflow-auto pt-3">
{Menus.map((menu, i) => ( {Menus.map((menu, i) => (

View File

@ -57,13 +57,9 @@ export default function FormPassword() {
http: authServices.login({ http: authServices.login({
username: dataLoginStorage?.usernameStorage || "", username: dataLoginStorage?.usernameStorage || "",
password: md5( password: md5(`${form.password}_${process.env.NEXT_PUBLIC_KEY_PASS}`),
`${form.password}_${process.env.NEXT_PUBLIC_KEY_PASSWORD}`,
),
ip: "", deviceId: dataLoginStorage?.deviceIdStorage || "web-browser",
address: "",
type: 0,
}), }),
}); });
}, },
@ -73,11 +69,11 @@ export default function FormPassword() {
store.dispatch(setStateLogin(true)); store.dispatch(setStateLogin(true));
store.dispatch(setToken(data.accessToken)); store.dispatch(setToken(data.token));
store.dispatch( store.dispatch(
setInfoUser({ setInfoUser({
accessToken: data?.accessToken || "", accessToken: data?.token || "",
refreshToken: data?.refreshToken || "", refreshToken: data?.refreshToken || "",
accessExpiresAt: data?.accessExpiresAt || "", accessExpiresAt: data?.accessExpiresAt || "",
refreshExpiresAt: data?.refreshExpiresAt || "", refreshExpiresAt: data?.refreshExpiresAt || "",
@ -92,6 +88,7 @@ export default function FormPassword() {
usernameStorage: dataLoginStorage?.usernameStorage || "", usernameStorage: dataLoginStorage?.usernameStorage || "",
passwordStorage: form.password, passwordStorage: form.password,
deviceIdStorage: dataLoginStorage?.deviceIdStorage || "web-browser",
}), }),
); );
} else { } else {

View File

@ -26,7 +26,7 @@ import { PATH } from "@/constant/config";
import FormCustom from "@/components/utils/FormCustom"; import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm"; import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomLoading from "@/components/customs/custom-loading"; import CustomLoading from "@/components/customs/custom-loading";
// import FormCustom, { InputForm } from "@/components/common/FormCustom"; import CustomButton from "@/components/customs/custom-button";
export default function MainLogin() { export default function MainLogin() {
const router = useRouter(); const router = useRouter();
@ -39,6 +39,8 @@ export default function MainLogin() {
username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "", username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "",
password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "", password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "",
deviceId: isRememberPassword ? dataLoginStorage?.deviceIdStorage || "" : "",
}); });
const loginMutation = useMutation({ const loginMutation = useMutation({
@ -50,9 +52,7 @@ export default function MainLogin() {
http: authServices.login({ http: authServices.login({
username: form.username, username: form.username,
password: form.password, password: form.password,
ip: "", deviceId: form.deviceId,
address: "",
type: 0,
}), }),
}); });
}, },
@ -62,11 +62,11 @@ export default function MainLogin() {
store.dispatch(setStateLogin(true)); store.dispatch(setStateLogin(true));
store.dispatch(setToken(data.accessToken)); store.dispatch(setToken(data.token));
store.dispatch( store.dispatch(
setInfoUser({ setInfoUser({
accessToken: data?.accessToken || "", accessToken: data?.token || "",
refreshToken: data?.refreshToken || "", refreshToken: data?.refreshToken || "",
accessExpiresAt: data?.accessExpiresAt || "", accessExpiresAt: data?.accessExpiresAt || "",
refreshExpiresAt: data?.refreshExpiresAt || "", refreshExpiresAt: data?.refreshExpiresAt || "",
@ -80,6 +80,7 @@ export default function MainLogin() {
setDataLoginStorage({ setDataLoginStorage({
usernameStorage: form.username, usernameStorage: form.username,
passwordStorage: form.password, passwordStorage: form.password,
deviceIdStorage: form.deviceId,
}), }),
); );
} else { } else {
@ -141,6 +142,25 @@ export default function MainLogin() {
icon={<User size={22} />} icon={<User size={22} />}
/> />
<div className="mt-5">
<InputForm
label={
<span>
deviceId
<span className="text-red-500"> *</span>
</span>
}
placeholder="deviceId"
type="text"
name="deviceId"
onClean
isRequired
isBlur
showDone
icon={<ShieldPlus size={22} />}
/>
</div>
{/* PASSWORD */} {/* PASSWORD */}
<div className="mt-5"> <div className="mt-5">
<InputForm <InputForm
@ -202,42 +222,42 @@ export default function MainLogin() {
{/* BUTTONS */} {/* BUTTONS */}
<div className="mt-6"> <div className="mt-6">
{/* LOGIN */} <div className="flex gap-2">
<button {/* LOGIN */}
type="submit" <CustomButton
disabled={loginMutation.isPending} variant="midnightBlue"
className=" disabled={loginMutation.isPending}
flex rounded="full"
h-[52px] type="submit"
w-full className="font-bold text-2xl disabled:cursor-not-allowed
items-center disabled:opacity-50 h-[52px]"
justify-center >
rounded-full Đăng nhập
bg-[#0011AB] </CustomButton>
px-6 {/* REGISTER */}
text-white <CustomButton
font-bold variant="default"
transition-all rounded="full"
hover:opacity-90 className="font-bold text-2xl disabled:cursor-not-allowed
disabled:cursor-not-allowed disabled:opacity-50 h-[52px]"
disabled:opacity-50 onClick={() => router.push(PATH.REGISTER)}
" >
> Đăng
Đăng nhập </CustomButton>
</button> </div>
{/* LINE */} {/* LINE */}
<div {/* <div
className=" className="
my-5 my-5
h-[1px] h-[1px]
w-full w-full
bg-[#E5E5E5] bg-[#E5E5E5]
" "
/> /> */}
{/* FORGOT PASSWORD */} {/* FORGOT PASSWORD */}
<button {/* <button
type="button" type="button"
onClick={() => router.push(PATH.FORGOT_PASSWORD)} onClick={() => router.push(PATH.FORGOT_PASSWORD)}
className=" className="
@ -260,7 +280,7 @@ export default function MainLogin() {
> >
<LockKeyhole size={22} /> <LockKeyhole size={22} />
Quên mật khẩu Quên mật khẩu
</button> </button> */}
</div> </div>
</div> </div>
</FormCustom> </FormCustom>

View File

@ -0,0 +1,200 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { ShieldPlus, User, Mail, BadgeInfo } from "lucide-react";
import authServices from "@/services/authServices";
import { httpRequest } from "@/services";
import { PATH } from "@/constant/config";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import CustomLoading from "@/components/customs/custom-loading";
import CustomButton from "@/components/customs/custom-button";
export default function MainRegister() {
const router = useRouter();
const [form, setForm] = useState({
username: "",
password: "",
fullName: "",
email: "",
});
const registerMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Đăng ký thành công!",
http: authServices.register({
username: form.username,
password: form.password,
fullName: form.fullName,
email: form.email,
}),
});
},
onSuccess(data: any) {
if (!data) return;
router.replace(PATH.LOGIN);
},
});
const handleRegister = () => {
registerMutation.mutate();
};
return (
<div className="w-full">
<CustomLoading loading={registerMutation.isPending} />
<FormCustom form={form} setForm={setForm} onSubmit={handleRegister}>
{/* TITLE */}
<h3
className="
text-[34px]
font-semibold
text-[#1A1B2D]
"
>
Đăng
</h3>
<p
className="
mt-1
text-[14px]
font-medium
text-[#6F767E]
"
>
Tạo tài khoản mới cho hệ thống
</p>
{/* FORM */}
<div className="mt-6">
{/* FULL NAME */}
<InputForm
label={
<span>
Họ tên
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập họ và tên"
type="text"
name="fullName"
onClean
isRequired
isBlur
showDone
icon={<BadgeInfo size={22} />}
/>
{/* EMAIL */}
<div className="mt-5">
<InputForm
label={
<span>
Email
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập email"
type="email"
name="email"
onClean
isRequired
isBlur
showDone
icon={<Mail size={22} />}
/>
</div>
{/* USERNAME */}
<div className="mt-5">
<InputForm
label={
<span>
Tài khoản
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập tài khoản"
type="text"
name="username"
onClean
isRequired
isBlur
showDone
icon={<User size={22} />}
/>
</div>
{/* PASSWORD */}
<div className="mt-5">
<InputForm
label={
<span>
Mật khẩu
<span className="text-red-500"> *</span>
</span>
}
placeholder="Nhập mật khẩu"
type="password"
name="password"
onClean
isRequired
isBlur
showDone
icon={<ShieldPlus size={22} />}
/>
</div>
{/* BUTTON */}
<div className="mt-6 flex gap-2">
{/* REGISTER */}
<CustomButton
type="submit"
variant="midnightBlue"
disabled={registerMutation.isPending}
rounded="full"
className="
h-[52px]
font-bold
text-2xl
disabled:cursor-not-allowed
disabled:opacity-50
"
>
Đăng
</CustomButton>
{/* LOGIN */}
<CustomButton
type="button"
variant="default"
rounded="full"
className="
h-[52px]
font-bold
text-2xl
"
onClick={() => router.push(PATH.LOGIN)}
>
Đăng nhập
</CustomButton>
</div>
</div>
</FormCustom>
</div>
);
}

View File

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

View File

@ -0,0 +1,237 @@
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { ArrowLeft } from "lucide-react";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import StateActive from "@/components/customs/StateActive";
const DetailConsultation = () => {
const params = useParams();
const router = useRouter();
const consultationId = params?.id as string;
const consulDetailQuery = useQuery<ConsultationDetail>({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, consultationId],
enabled: !!consultationId,
queryFn: async () => {
const res = await httpRequest<ConsultationDetail>({
showMessageFailed: true,
http: consultationServices.detailConsultations(consultationId),
});
return (
res || {
id: "",
patientId: "",
sessionCode: "",
visitDate: "",
status: 1,
chiefComplaint: null,
symptomsText: null,
vitalSigns: null,
diagnosis: null,
treatmentPlan: null,
doctorNotes: null,
createdBy: "",
isDeleted: false,
}
);
},
});
const consultation = consulDetailQuery.data;
if (consulDetailQuery.isLoading) {
return <div>Đang tải dữ liệu...</div>;
}
if (!consultation) {
return <div>Không tìm thấy dữ liệu phiên khám</div>;
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-6">
{/* HEADER */}
<div className="flex items-center gap-3">
<ArrowLeft
onClick={() => router.back()}
className="cursor-pointer"
/>
<h2 className="text-[28px] font-semibold text-[#111827]">
Chi tiết phiên khám
</h2>
</div>
{/* CONTENT */}
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{/* ID PHIÊN KHÁM */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
ID PHIÊN KHÁM
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.id}
</p>
</div>
{/* MÃ PHIÊN KHÁM */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
PHIÊN KHÁM
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.sessionCode}
</p>
</div>
{/* NGÀY GIỜ KHÁM */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
NGÀY, GIỜ KHÁM
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.visitDate}
</p>
</div>
{/* TRIỆU CHỨNG */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">DẤU HIỆU</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.symptomsText || "---"}
</p>
</div>
{/* KHIẾU NẠI CHÍNH */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
KHIẾU NẠI CHÍNH
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.chiefComplaint || "---"}
</p>
</div>
{/* DẤU HIỆU SINH TỒN */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
DẤU HIỆU SINH TỒN
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.vitalSigns || "---"}
</p>
</div>
{/* CHẨN ĐOÁN */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
CHUẨN ĐOÁN
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.diagnosis || "---"}
</p>
</div>
{/* KẾ HOẠCH ĐIỀU TRỊ */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
KẾ HOẠCH ĐIỀU TRỊ
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.treatmentPlan || "---"}
</p>
</div>
{/* GHI CHÚ BÁC SĨ */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
GHI CHÚ BÁC
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.doctorNotes || "---"}
</p>
</div>
{/* NGƯỜI TẠO */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
NGƯỜI TẠO
</p>
<p className="text-[15px] font-medium text-[#202939]">
{consultation.createdBy || "---"}
</p>
</div>
{/* TRẠNG THÁI */}
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
TRẠNG THÁI
</p>
<StateActive
stateActive={consultation.status}
listState={[
{
state: TYPE_STATUS.Draft,
text: "Đang chờ",
textColor: "#000",
backgroundColor: "#FFE4C4",
},
{
state: TYPE_STATUS.InProgress,
text: "Đang thực hiện",
textColor: "#000",
backgroundColor: "#ff3300",
},
{
state: TYPE_STATUS.Completed,
text: "Hoàn thành",
textColor: "#000",
backgroundColor: "#3d69eb",
},
{
state: TYPE_STATUS.Cancelled,
text: "Đã hủy",
textColor: "#000",
backgroundColor: "#cccc",
},
]}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default DetailConsultation;

View File

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

View File

@ -0,0 +1,481 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import CustomPopup from "@/components/customs/custom-popup";
import DataWrapper from "@/components/customs/DataWrapper";
import Search from "@/components/customs/custom-search";
import StateActive from "@/components/customs/StateActive";
import Table from "@/components/customs/custom-table";
import { QUERY_KEY, TYPE_STATUS } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Pencil, ClipboardPlus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import PopupUpdateConsultation from "../PopupUpdateConsultation";
import PopupCreateConsultation from "../PopupCreateConsultation";
const MainPageConsultation = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
/* =========================
QUERY PARAMS
========================= */
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
const _create = searchParams.get("_create");
const _update = searchParams.get("_update");
/* =========================
STATE
========================= */
const [selectedConsultationId, setSelectedConsultationId] =
useState<string>("");
const [openCancelDialog, setOpenCancelDialog] = useState(false);
const [openCompleteDialog, setOpenCompleteDialog] = useState(false);
/* =========================
GET LIST
========================= */
const consultationQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation, _page, _pageSize, _keyword],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
/* =========================
DATA
========================= */
const consultationData = useMemo(() => {
return consultationQuery.data?.items || [];
}, [consultationQuery.data]);
/* =========================
UPDATE QUERY
========================= */
const updateQuery = (key: string, value: string | number) => {
const params = new URLSearchParams(searchParams.toString());
if (!value || value === "" || (key === "_page" && value === 1)) {
params.delete(key);
} else {
params.set(key, String(value));
}
if (key === "_pageSize") {
params.delete("_page");
}
const queryString = params.toString();
router.push(queryString ? `?${queryString}` : pathname);
};
/* =========================
CREATE POPUP
========================= */
const handleOpenCreate = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("_create", "true");
router.push(`?${params.toString()}`);
};
/* =========================
UPDATE POPUP
========================= */
const handleOpenUpdate = (item: ConsultationItem) => {
const params = new URLSearchParams(searchParams.toString());
params.set("_update", item.id);
router.push(`?${params.toString()}`);
};
/* =========================
CLOSE POPUP
========================= */
const handleClosePopup = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_create");
params.delete("_update");
const queryString = params.toString();
router.push(queryString ? `?${queryString}` : pathname);
};
/* =========================
DIALOG
========================= */
const handleOpenCancel = (item: ConsultationItem) => {
setSelectedConsultationId(item.id);
setOpenCancelDialog(true);
};
const handleOpenComplete = (item: ConsultationItem) => {
setSelectedConsultationId(item.id);
setOpenCompleteDialog(true);
};
const handleCloseDialog = () => {
setOpenCancelDialog(false);
setOpenCompleteDialog(false);
};
/* =========================
CANCEL
========================= */
const cancelConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Hủy phiên khám thành công!",
http: consultationServices.cancelConsultations(selectedConsultationId),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
handleCloseDialog();
},
});
/* =========================
COMPLETE
========================= */
const completeConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Hoàn thành phiên khám thành công!",
http: consultationServices.completeConsultations(
selectedConsultationId,
),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
handleCloseDialog();
},
});
/* =========================
CONFIRM
========================= */
const handleConfirmCancel = () => {
cancelConsultationMutation.mutate();
};
const handleConfirmComplete = () => {
completeConsultationMutation.mutate();
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Phiên khám</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<ClipboardPlus />}
onClick={handleOpenCreate}
>
Thêm mới
</CustomButton>
</div>
{/* SEARCH */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm kiếm phiên khám..."
/>
</div>
</div>
{/* TABLE */}
<DataWrapper
data={consultationData}
loading={consultationQuery.isLoading}
title="Không có dữ liệu"
note="Vui lòng thử lại sau"
>
<Table
rowKey={(item) => item.id}
data={consultationData}
column={[
{
fixedLeft: true,
title: "ID PHIÊN KHÁM",
render: (item: ConsultationItem) => (
<Link
href={`/consultation/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.id}
</Link>
),
},
{
title: "MÃ PHIÊN KHÁM",
render: (item: ConsultationItem) => (
<span>{item.sessionCode}</span>
),
},
{
title: "NGÀY KHÁM",
render: (item: ConsultationItem) => <span>{item.visitDate}</span>,
},
{
title: "DẤU HIỆU",
render: (item: ConsultationItem) => (
<span>{item.symptomsText || "---"}</span>
),
},
{
title: "CHUẨN ĐOÁN",
render: (item: ConsultationItem) => (
<span>{item.diagnosis || "---"}</span>
),
},
{
title: "KẾ HOẠCH ĐIỀU TRỊ",
render: (item: ConsultationItem) => (
<span>{item.treatmentPlan || "---"}</span>
),
},
{
title: "GHI CHÚ BÁC SĨ",
render: (item: ConsultationItem) => (
<span>{item.doctorNotes || "---"}</span>
),
},
{
title: "TRẠNG THÁI",
render: (item: ConsultationItem) => (
<StateActive
stateActive={item.status}
listState={[
{
state: 1,
text: "Đang khám",
textColor: "#000",
backgroundColor: "#FFE4C4",
},
{
state: 2,
text: "Hoàn thành",
textColor: "#fff",
backgroundColor: "#22c55e",
},
{
state: 3,
text: "Đã hủy",
textColor: "#fff",
backgroundColor: "#ef4444",
},
]}
/>
),
},
{
fixedRight: true,
title: "ACTION",
render: (item: ConsultationItem) => {
const isCancelled = item.status === TYPE_STATUS.Cancelled;
const isCompleted = item.status === TYPE_STATUS.Completed;
const isDisabled = isCancelled || isCompleted;
return (
<div className="flex items-center gap-2">
{/* UPDATE */}
<CustomButton
size="sm"
disabled={isDisabled}
icon={<Pencil className="text-blue-400" />}
onClick={() => handleOpenUpdate(item)}
/>
{/* CANCEL */}
<CustomButton
size="sm"
variant="red"
disabled={isDisabled}
onClick={() => handleOpenCancel(item)}
>
Hủy
</CustomButton>
{/* COMPLETE */}
<CustomButton
size="sm"
variant="green"
disabled={isDisabled}
onClick={() => handleOpenComplete(item)}
>
Hoàn thành
</CustomButton>
</div>
);
},
},
]}
/>
</DataWrapper>
{/* CANCEL DIALOG */}
<CustomDialog
open={openCancelDialog}
title="Hủy phiên khám"
note="Bạn có chắc chắn muốn hủy phiên khám này không?"
type="error"
onClose={handleCloseDialog}
onSubmit={handleConfirmCancel}
titleCancel="Đóng"
titleSubmit="Xác nhận"
/>
{/* COMPLETE DIALOG */}
<CustomDialog
open={openCompleteDialog}
title="Hoàn thành phiên khám"
note="Bạn có chắc chắn muốn hoàn thành phiên khám này không?"
type="success"
onClose={handleCloseDialog}
onSubmit={handleConfirmComplete}
titleCancel="Đóng"
titleSubmit="Xác nhận"
/>
{/* CREATE */}
<CustomPopup open={!!_create} onClose={handleClosePopup}>
<PopupCreateConsultation onClose={handleClosePopup} />
</CustomPopup>
{/* UPDATE */}
<CustomPopup open={!!_update} onClose={handleClosePopup}>
<PopupUpdateConsultation
id={_update || ""}
onClose={handleClosePopup}
/>
</CustomPopup>
</div>
);
};
export default MainPageConsultation;

View File

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

View File

@ -0,0 +1,222 @@
"use client";
import React, { useMemo, useState } from "react";
import { X } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import FormCustom from "@/components/utils/FormCustom/FormCustom";
import SelectForm from "@/components/utils/FormCustom/components/SelectForm";
import { httpRequest } from "@/services";
import consultationServices from "@/services/consultationServices";
import patientServices, {
PatientItem,
PatientResponse,
} from "@/services/patientServices";
import { QUERY_KEY } from "@/constant/config/enum";
import CustomButton from "@/components/customs/custom-button";
export interface PopupCreateConsultationProps {
onClose: () => void;
}
const PopupCreateConsultation = ({ onClose }: PopupCreateConsultationProps) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<{
patientId: string;
}>({
patientId: "",
});
/* =========================
GET LIST PATIENT
========================= */
const patientQuery = useQuery<PatientResponse>({
queryKey: [QUERY_KEY.table_list_patient],
queryFn: async () => {
const res = await httpRequest<PatientResponse>({
showMessageFailed: true,
http: patientServices.getPatient({
Page: 1,
PageSize: 1000,
Search: "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
/* =========================
OPTIONS SELECT
========================= */
const patientOptions = useMemo(() => {
return patientQuery.data?.items || [];
}, [patientQuery.data]);
/* =========================
CREATE CONSULTATION
========================= */
const createConsultationMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo phiên khám thành công!",
http: consultationServices.createConsultations({
patientId: form.patientId,
}),
});
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
onClose();
},
});
const handleSubmit = () => {
createConsultationMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[400px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-10
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Thêm phiên khám
</h2>
</div>
{/* BODY */}
<div className="flex-1 overflow-y-auto px-7 py-5">
<div className="space-y-5">
<SelectForm
label={
<span className="text-[14px] font-medium text-[#374151]">
Bệnh nhân
<span className="text-red-500">*</span>
</span>
}
placeholder="Chọn bệnh nhân"
value={form.patientId}
options={patientOptions}
// loading={patientQuery.isLoading}
onSelect={(item: PatientItem) =>
setForm((prev) => ({
...prev,
patientId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
patientId: "",
}))
}
getOptionLabel={(item: PatientItem) =>
`${item.fullName} - ${item.identificationNumber}`
}
getOptionValue={(item: PatientItem) => item.id}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={!form.patientId || createConsultationMutation.isPending}
variant="midnightBlue"
rounded="md"
onClick={handleSubmit}
>
{createConsultationMutation.isPending
? "Đang tạo..."
: "Tạo phiên khám"}
</CustomButton>
</div>
</div>
</FormCustom>
);
};
export default PopupCreateConsultation;

View File

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

View File

@ -0,0 +1,281 @@
"use client";
import React, { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import consultationServices, {
ConsultationDetail,
} from "@/services/consultationServices";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
interface PopupUpdateConsultationProps {
id: string;
onClose: () => void;
}
interface IUpdateConsul {
chiefComplaint: string;
symptomsText: string;
vitalSigns: string;
diagnosis: string;
treatmentPlan: string;
doctorNotes: string;
}
const defaultForm: IUpdateConsul = {
chiefComplaint: "",
symptomsText: "",
vitalSigns: "",
diagnosis: "",
treatmentPlan: "",
doctorNotes: "",
};
const PopupUpdateConsultation = ({
id,
onClose,
}: PopupUpdateConsultationProps) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<IUpdateConsul>(defaultForm);
const consulDetailQuery = useQuery<ConsultationDetail>({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
enabled: !!id,
queryFn: async () => {
const res = await httpRequest<ConsultationDetail>({
showMessageFailed: true,
http: consultationServices.detailConsultations(id),
});
return (
res || {
id: "",
patientId: "",
sessionCode: "",
visitDate: "",
status: 1,
chiefComplaint: null,
symptomsText: null,
vitalSigns: null,
diagnosis: null,
treatmentPlan: null,
doctorNotes: null,
createdBy: "",
isDeleted: false,
}
);
},
});
useEffect(() => {
const consul = consulDetailQuery.data;
if (!consul) return;
setForm({
chiefComplaint: consul.chiefComplaint || "",
symptomsText: consul.symptomsText || "",
vitalSigns: consul.vitalSigns || "",
diagnosis: consul.diagnosis || "",
treatmentPlan: consul.treatmentPlan || "",
doctorNotes: consul.doctorNotes || "",
});
}, [consulDetailQuery.data]);
/* =========================
SUBMIT
========================= */
const updateConsultationMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật phiên khám thành công!",
http: consultationServices.putConsultations(id, {
chiefComplaint: form.chiefComplaint,
symptomsText: form.symptomsText,
vitalSigns: form.vitalSigns,
diagnosis: form.diagnosis,
treatmentPlan: form.treatmentPlan,
doctorNotes: form.doctorNotes,
}),
});
},
onSuccess(data) {
if (!data) return;
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_consultation],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_phien_kham, id],
});
onClose();
},
});
const handleSubmit = () => {
updateConsultationMutation.mutate();
};
if (consulDetailQuery.isLoading) {
return (
<div className="flex items-center justify-center p-10">
Đang tải dữ liệu...
</div>
);
}
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[680px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-10
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Chỉnh sửa phiên khám
</h2>
</div>
{/* BODY */}
<div className="flex-1 overflow-y-auto px-7 py-5">
<div className="space-y-5">
<InputForm
label="Lý do khám"
name="chiefComplaint"
type="text"
value={form.chiefComplaint}
placeholder="Nhập lý do khám"
/>
<TextArea
label="Triệu chứng"
name="symptomsText"
placeholder="Nhập triệu chứng"
value={form.symptomsText}
/>
<TextArea
label="Dấu hiệu sinh tồn"
name="vitalSigns"
placeholder="Nhập dấu hiệu sinh tồn"
value={form.vitalSigns}
/>
<TextArea
label="Chẩn đoán"
name="diagnosis"
placeholder="Nhập chẩn đoán"
value={form.diagnosis}
/>
<TextArea
label="Kế hoạch điều trị"
name="treatmentPlan"
placeholder="Nhập kế hoạch điều trị"
value={form.treatmentPlan}
/>
<TextArea
label="Ghi chú bác sĩ"
name="doctorNotes"
placeholder="Nhập ghi chú"
value={form.doctorNotes}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone || updateConsultationMutation.isPending}
variant="midnightBlue"
rounded="md"
type="submit"
>
{updateConsultationMutation.isPending
? "Đang cập nhật..."
: "Cập nhật"}
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</div>
</FormCustom>
);
};
export default PopupUpdateConsultation;

View File

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

View File

@ -0,0 +1,189 @@
"use client";
import React from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import patientServices, { PatientDetail } from "@/services/patientServices";
import { httpRequest } from "@/services";
import { QUERY_KEY } from "@/constant/config/enum";
import { ArrowLeft } from "lucide-react";
const DetailPatient = () => {
const params = useParams();
const router = useRouter();
const patientId = params?.id as string;
const patientDetailQuery = useQuery<PatientDetail>({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, patientId],
enabled: !!patientId,
queryFn: async () => {
const res = await httpRequest<PatientDetail>({
showMessageFailed: true,
http: patientServices.detailPatient(patientId),
});
return (
res || {
id: "",
patientCode: "",
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: null,
emergencyContactPhone: null,
allergyNotes: null,
chronicDiseaseNotes: null,
insuranceNumber: null,
notes: null,
isActive: false,
}
);
},
});
const patient = patientDetailQuery.data;
if (patientDetailQuery.isLoading) {
return <div>Đang tải dữ liệu...</div>;
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-5">
<h2 className="text-[28px] flex items-center gap-2 font-semibold text-[#111827]">
<ArrowLeft
onClick={() => router.back()}
className="cursor-pointer"
/>{" "}
{patient?.fullName}
</h2>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
bệnh nhân
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.patientCode}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Họ tên</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.fullName}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
đnh danh
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.identificationNumber}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Ngày sinh
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.dateOfBirth}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Giới tính
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.gender}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Số điện thoại
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.phone}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Email</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.email}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Đa chỉ</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.address || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
Người liên hệ khẩn cấp
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.emergencyContactName || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">
SĐT khẩn cấp
</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.emergencyContactPhone || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Dị ng</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.allergyNotes || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[#697586]">Bệnh nền</p>
<p className="text-[15px] font-medium text-[#202939]">
{patient?.chronicDiseaseNotes || "-"}
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default DetailPatient;

View File

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

View File

@ -0,0 +1,279 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import CustomDialog from "@/components/customs/custom-dialog";
import CustomPopup from "@/components/customs/custom-popup";
import DataWrapper from "@/components/customs/DataWrapper";
import Search from "@/components/customs/custom-search";
import Table from "@/components/customs/custom-table";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import patientServices, {
PatientItem,
PatientResponse,
} from "@/services/patientServices";
import { useQuery } from "@tanstack/react-query";
import { Pencil, Trash2, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import PopupCreatePatient from "../PopupCreatePatient";
import PopupUpdatePatient from "../PopupUpdatePatient";
const MainPagePatient = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [deleteItemId, setDeleteItemId] = useState<string>("");
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
/**
* PARAMS
*/
const createParam = searchParams.get("_create");
const updateParam = searchParams.get("_update");
/**
* OPEN POPUP
*/
const openCreatePopup = createParam !== null;
const openUpdatePopup = !!updateParam;
/**
* QUERY LIST PATIENT
*/
const patientQuery = useQuery<PatientResponse>({
queryKey: [QUERY_KEY.table_list_patient, _page, _pageSize, _keyword],
queryFn: async () => {
const res = await httpRequest<PatientResponse>({
showMessageFailed: true,
http: patientServices.getPatient({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
/**
* UPDATE QUERY PARAMS
*/
const updateQuery = (key: string, value?: string | number) => {
const params = new URLSearchParams(searchParams.toString());
if (!value || value === "") {
params.delete(key);
} else {
params.set(key, String(value));
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
/**
* OPEN CREATE
*/
const handleOpenCreate = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("_create", "");
router.push(`${pathname}?${params.toString()}`);
};
/**
* OPEN UPDATE
*/
const handleOpenUpdate = (id: string) => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_create");
params.set("_update", id);
router.push(`${pathname}?${params.toString()}`);
};
/**
* CLOSE ALL POPUP
*/
const handleClosePopup = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("_create");
params.delete("_update");
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
setOpenDeleteDialog(false);
setDeleteItemId("");
};
/**
* DELETE
*/
const handleOpenDelete = (id: string) => {
setDeleteItemId(id);
setOpenDeleteDialog(true);
};
/**
* DATA
*/
const patientData = useMemo(() => {
return patientQuery.data?.items || [];
}, [patientQuery.data]);
return (
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Bệnh nhân</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Users />}
onClick={handleOpenCreate}
>
Tạo mới
</CustomButton>
</div>
{/* SEARCH */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm kiếm theo tên người dùng, ID"
/>
</div>
</div>
{/* TABLE */}
<DataWrapper
data={patientData}
loading={patientQuery.isLoading}
title="No New Requests"
note="Please check back later or view your schedule"
>
<Table
rowKey={(item) => item.id}
data={patientData}
column={[
{
fixedLeft: true,
title: "TÊN BỆNH NHÂN",
render: (item: PatientItem) => (
<Link
href={`/patient/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.fullName}
</Link>
),
},
{
title: "MÃ ĐỊNH DANH",
render: (item: PatientItem) => (
<span>{item.identificationNumber}</span>
),
},
{
title: "SỐ ĐIỆN THOẠI",
render: (item: PatientItem) => <span>{item.phone}</span>,
},
{
title: "EMAIL",
render: (item: PatientItem) => <span>{item.email}</span>,
},
{
title: "GIỚI TÍNH",
render: (item: PatientItem) => <span>{item.gender}</span>,
},
{
fixedRight: true,
title: "ACTION",
render: (item: PatientItem) => (
<div className="flex items-center gap-2">
{/* UPDATE */}
<CustomButton
size="sm"
icon={<Pencil className="text-blue-400" />}
onClick={() => handleOpenUpdate(item.id)}
/>
{/* DELETE */}
<CustomButton
size="sm"
icon={<Trash2 className="text-red-400" />}
onClick={() => handleOpenDelete(item.id)}
/>
</div>
),
},
]}
/>
</DataWrapper>
{/* DELETE DIALOG */}
<CustomDialog
open={openDeleteDialog}
title="Xóa bệnh nhân"
note="Bạn có chắc chắn muốn xóa bệnh nhân này?"
type="error"
onClose={handleClosePopup}
onSubmit={() => {}}
titleCancel="Hủy"
titleSubmit="Xác nhận"
/>
{/* CREATE POPUP */}
<CustomPopup open={openCreatePopup} onClose={handleClosePopup}>
<PopupCreatePatient onClose={handleClosePopup} />
</CustomPopup>
{/* UPDATE POPUP */}
<CustomPopup open={openUpdatePopup} onClose={handleClosePopup}>
<PopupUpdatePatient onClose={handleClosePopup} id={updateParam || ""} />
</CustomPopup>
</div>
);
};
export default MainPagePatient;

View File

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

View File

@ -0,0 +1,259 @@
"use client";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { QUERY_KEY, TYPE_GENDER } from "@/constant/config/enum";
import { X } from "lucide-react";
import React, { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { httpRequest } from "@/services";
import patientServices from "@/services/patientServices";
import CustomLoading from "@/components/customs/custom-loading";
export interface ICreatePatient {
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
}
export interface PropsPopupCreatePatient {
onClose: () => void;
}
/* QUERY KEY */
// export const QUERY_KEY = {
// table_list_patient: "table_list_patient",
// };
const PopupCreatePatient = ({ onClose }: PropsPopupCreatePatient) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<ICreatePatient>({
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
});
const createPatientMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo bệnh nhân thành công!",
http: patientServices.createPatient({
identificationNumber: form.identificationNumber,
fullName: form.fullName,
dateOfBirth: form.dateOfBirth,
gender: form.gender,
phone: form.phone,
email: form.email,
address: form.address,
}),
});
},
onSuccess() {
/* REFRESH LIST */
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_patient],
});
onClose();
},
});
const handleSubmit = () => {
createPatientMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[680px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-10
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Thêm bệnh nhân
</h2>
</div>
{/* BODY */}
<div
className="
flex-1
overflow-y-auto
px-7
py-5
"
>
<div className="space-y-5">
<InputForm
label={
<span>
đnh danh
<span className="text-red-500"> *</span>
</span>
}
placeholder="Mã định danh"
name="identificationNumber"
type="text"
value={form.identificationNumber}
isRequired
/>
<InputForm
label={
<span>
Tên bệnh nhân
<span className="text-red-500"> *</span>
</span>
}
placeholder="Tên bệnh nhân"
name="fullName"
type="text"
value={form.fullName}
isRequired
/>
<InputForm
label={
<span>
Ngày sinh
<span className="text-red-500"> *</span>
</span>
}
placeholder="Ngày sinh"
name="dateOfBirth"
type="Date"
value={form.dateOfBirth}
isRequired
/>
<InputForm
label={
<span>
Số điện thoại
<span className="text-red-500"> *</span>
</span>
}
placeholder="Số điện thoại"
name="phone"
type="text"
value={form.phone}
isRequired
/>
<InputForm
label={
<span>
Email
<span className="text-red-500"> *</span>
</span>
}
placeholder="Email"
name="email"
type="text"
value={form.email}
isRequired
/>
<TextArea
name="address"
placeholder="Địa chỉ"
label="Địa chỉ"
isBlur
max={5000}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone || createPatientMutation.isPending}
variant="midnightBlue"
rounded="md"
type="submit"
>
{createPatientMutation.isPending ? "Đang tạo..." : "Tạo"}
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</div>
</FormCustom>
);
};
export default PopupCreatePatient;

View File

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

View File

@ -0,0 +1,419 @@
"use client";
import React, { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
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/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
import { QUERY_KEY, TYPE_GENDER } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import patientServices, { PatientDetail } from "@/services/patientServices";
interface PopupUpdatePatientProps {
onClose: () => void;
id: string;
}
interface IUpdatePatient {
fullName: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string;
emergencyContactPhone: string;
allergyNotes: string;
chronicDiseaseNotes: string;
insuranceNumber: string;
notes: string;
}
const defaultForm: IUpdatePatient = {
fullName: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: "",
emergencyContactPhone: "",
allergyNotes: "",
chronicDiseaseNotes: "",
insuranceNumber: "",
notes: "",
};
const PopupUpdatePatient = ({ onClose, id }: PopupUpdatePatientProps) => {
const queryClient = useQueryClient();
const [form, setForm] = useState<IUpdatePatient>(defaultForm);
const patientDetailQuery = useQuery<PatientDetail>({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, id],
enabled: !!id,
queryFn: async () => {
const res = await httpRequest<PatientDetail>({
showMessageFailed: true,
http: patientServices.detailPatient(id),
});
return (
res || {
id: "",
patientCode: "",
identificationNumber: "",
fullName: "",
dateOfBirth: "",
gender: "",
phone: "",
email: "",
address: "",
emergencyContactName: null,
emergencyContactPhone: null,
allergyNotes: null,
chronicDiseaseNotes: null,
insuranceNumber: null,
notes: null,
isActive: false,
}
);
},
});
useEffect(() => {
const patient = patientDetailQuery.data;
if (!patient) return;
setForm((prev) => {
/**
* tránh render loop
*/
if (
prev.fullName === patient.fullName &&
prev.phone === patient.phone &&
prev.email === patient.email
) {
return prev;
}
return {
fullName: patient.fullName || "",
gender: patient.gender || TYPE_GENDER.MALE,
phone: patient.phone || "",
email: patient.email || "",
address: patient.address || "",
emergencyContactName: patient.emergencyContactName || "",
emergencyContactPhone: patient.emergencyContactPhone || "",
allergyNotes: patient.allergyNotes || "",
chronicDiseaseNotes: patient.chronicDiseaseNotes || "",
insuranceNumber: patient.insuranceNumber || "",
notes: patient.notes || "",
};
});
}, [patientDetailQuery.data]);
const updatePatientMutation = useMutation({
mutationFn: async () => {
return httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật bệnh nhân thành công!",
http: patientServices.putPatient(id, {
fullName: form.fullName,
gender: form.gender,
phone: form.phone,
email: form.email,
address: form.address,
emergencyContactName: form.emergencyContactName,
emergencyContactPhone: form.emergencyContactPhone,
allergyNotes: form.allergyNotes,
chronicDiseaseNotes: form.chronicDiseaseNotes,
insuranceNumber: form.insuranceNumber,
notes: form.notes,
}),
});
},
onSuccess(data) {
if (!data) return;
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.table_list_patient],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEY.chi_tiet_benh_nhan, id],
});
onClose();
},
});
const handleSubmit = () => {
updatePatientMutation.mutate();
};
if (patientDetailQuery.isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<CustomLoading />
</div>
);
}
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div
className="
relative
h-[680px]
w-[640px]
max-w-[95vw]
rounded-3xl
bg-white
shadow-xl
flex
flex-col
overflow-hidden
"
>
{/* CLOSE */}
<button
type="button"
onClick={onClose}
className="
absolute
right-5
top-5
z-10
text-gray-400
transition
hover:text-gray-600
"
>
<X size={22} />
</button>
{/* HEADER */}
<div
className="
shrink-0
border-b
px-7
py-5
bg-white
"
>
<h2 className="text-[30px] font-semibold text-[#111827]">
Cập nhật thông tin bệnh nhân
</h2>
</div>
{/* BODY */}
<div
className="
flex-1
overflow-y-auto
px-7
py-5
"
>
<div className="space-y-5">
<InputForm
label={
<span>
Tên bệnh nhân
<span className="text-red-500"> *</span>
</span>
}
placeholder="Tên bệnh nhân"
name="fullName"
type="text"
value={form.fullName}
isRequired
/>
{/* GENDER */}
<div>
<label className="text-base font-medium">Giới tính</label>
<div className="mt-2 flex items-center gap-6">
{/* NAM */}
<div className="flex items-center gap-2">
<input
type="radio"
id="male"
checked={form.gender === TYPE_GENDER.MALE}
onChange={() =>
setForm((prev) => ({
...prev,
gender: TYPE_GENDER.MALE,
}))
}
/>
<label htmlFor="male">Nam</label>
</div>
{/* NỮ */}
<div className="flex items-center gap-2">
<input
type="radio"
id="female"
checked={form.gender === TYPE_GENDER.FEMALE}
onChange={() =>
setForm((prev) => ({
...prev,
gender: TYPE_GENDER.FEMALE,
}))
}
/>
<label htmlFor="female">Nữ</label>
</div>
{/* KHÁC */}
<div className="flex items-center gap-2">
<input
type="radio"
id="other"
checked={form.gender === TYPE_GENDER.OTHER}
onChange={() =>
setForm((prev) => ({
...prev,
gender: TYPE_GENDER.OTHER,
}))
}
/>
<label htmlFor="other">Khác</label>
</div>
</div>
</div>
<InputForm
label={
<span>
Số điện thoại
<span className="text-red-500"> *</span>
</span>
}
placeholder="Số điện thoại"
name="phone"
type="text"
value={form.phone}
isRequired
/>
<InputForm
label={
<span>
Email
<span className="text-red-500"> *</span>
</span>
}
placeholder="Email"
name="email"
type="text"
value={form.email}
isRequired
/>
<TextArea
name="address"
placeholder="Địa chỉ"
label="Địa chỉ"
value={form.address}
isBlur
max={5000}
/>
<InputForm
label="Người liên hệ khẩn cấp"
placeholder="Người liên hệ khẩn cấp"
name="emergencyContactName"
type="text"
value={form.emergencyContactName}
/>
<InputForm
label="SĐT khẩn cấp"
placeholder="SĐT khẩn cấp"
name="emergencyContactPhone"
type="text"
value={form.emergencyContactPhone}
/>
<TextArea
name="allergyNotes"
placeholder="Dị ứng"
label="Dị ứng"
value={form.allergyNotes}
isBlur
max={5000}
/>
<TextArea
name="chronicDiseaseNotes"
placeholder="Bệnh nền"
label="Bệnh nền"
value={form.chronicDiseaseNotes}
isBlur
max={5000}
/>
<InputForm
label="Số bảo hiểm"
placeholder="Số bảo hiểm"
name="insuranceNumber"
type="text"
value={form.insuranceNumber}
/>
<TextArea
name="notes"
placeholder="Ghi chú"
label="Ghi chú"
value={form.notes}
isBlur
max={5000}
/>
</div>
</div>
{/* FOOTER */}
<div
className="
shrink-0
border-t
bg-white
px-7
py-5
flex
items-center
justify-end
gap-3
"
>
<CustomButton variant="grey" rounded="md" onClick={onClose}>
Hủy bỏ
</CustomButton>
<ContextFormCustom.Consumer>
{({ isDone }) => (
<CustomButton
disabled={!isDone || updatePatientMutation.isPending}
variant="midnightBlue"
rounded="md"
type="submit"
>
{updatePatientMutation.isPending
? "Đang cập nhật..."
: "Cập nhật"}
</CustomButton>
)}
</ContextFormCustom.Consumer>
</div>
</div>
</FormCustom>
);
};
export default PopupUpdatePatient;

View File

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

View File

@ -0,0 +1,409 @@
"use client";
import React, { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import SelectForm from "@/components/utils/FormCustom/components/SelectForm";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
import { httpRequest } from "@/services";
import prescriptionServices from "@/services/prescriptionServices";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { toastWarn } from "@/common/funcs/toast";
import { PATH } from "@/constant/config";
import { useRouter } from "next/navigation";
interface PrescriptionItem {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}
interface PrescriptionForm {
examinationSessionId: string;
notes: string;
prescriptionImg: string;
items: PrescriptionItem[];
}
interface UploadImageItem {
url?: string;
path?: string;
file?: File;
img?: string;
}
const defaultMedicine: PrescriptionItem = {
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
};
const CreatePrescription = () => {
const router = useRouter();
const [images, setImages] = useState<UploadImageItem[]>([]);
const [form, setForm] = useState<PrescriptionForm>({
examinationSessionId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
});
/**
* GET EXAMINATION SESSION
*/
const examinationSessionQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 100,
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const examinationSessionOptions = examinationSessionQuery.data?.items || [];
const createPrescriptionMutation = useMutation({
mutationFn: async () => {
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Tạo đơn thuốc thành công!",
http: prescriptionServices.createPrescription({
examinationSessionId: form.examinationSessionId,
notes: form.notes,
prescriptionImg: form.prescriptionImg,
items: form.items,
}),
});
},
onSuccess: () => {
router.push(PATH.PRESCRIPTION);
},
onError: (error: any) => {
// 👇 BACKEND ERROR HERE
const message =
error?.message || error?.error?.message || "Có lỗi xảy ra";
const code = error?.code || error?.error?.code;
toastWarn({
msg: message,
});
console.log("ERROR CODE:", code);
},
});
/**
* CHANGE MEDICINE FIELD
*/
const handleChangeMedicine = (
index: number,
key: keyof PrescriptionItem,
value: string,
) => {
const cloneItems = [...form.items];
cloneItems[index] = {
...cloneItems[index],
[key]: value,
};
setForm((prev) => ({
...prev,
items: cloneItems,
}));
};
/**
* ADD MEDICINE
*/
const handleAddMedicine = () => {
setForm((prev) => ({
...prev,
items: [
...prev.items,
{
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
},
],
}));
};
/**
* REMOVE MEDICINE
*/
const handleRemoveMedicine = (index: number) => {
const newItems = form.items.filter((_, i) => i !== index);
setForm((prev) => ({
...prev,
items: newItems,
}));
};
/**
* SUBMIT
*/
const handleSubmit = () => {
createPrescriptionMutation.mutate();
};
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-[28px] font-semibold text-[#111827]">
Thêm đơn thuốc
</h2>
<p className="mt-1 text-sm text-[#697586]">
Tạo mới đơn thuốc cho bệnh nhân
</p>
</div>
<div>
<CustomButton
type="submit"
variant="midnightBlue"
disabled={createPrescriptionMutation.isPending}
>
{createPrescriptionMutation.isPending
? "Đang lưu..."
: "Lưu đơn thuốc"}
</CustomButton>
</div>
</div>
{/* GENERAL INFO */}
<div className="rounded-2xl border border-gray-200 p-5">
<h3 className="mb-5 text-lg font-semibold text-[#111827]">
Thông tin chung
</h3>
<div className="grid grid-cols-1 gap-5">
{/* EXAMINATION SESSION */}
<SelectForm
label={
<span className="text-[14px] font-medium text-[#374151]">
Phiên khám
<span className="text-red-500">*</span>
</span>
}
placeholder="Chọn phiên khám"
value={form.examinationSessionId}
options={examinationSessionOptions}
onSelect={(item: ConsultationItem) =>
setForm((prev) => ({
...prev,
examinationSessionId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
examinationSessionId: "",
}))
}
getOptionLabel={(item: ConsultationItem) =>
`${item.sessionCode} `
}
getOptionValue={(item: ConsultationItem) => item.id}
/>
{/* IMAGE */}
<div className="flex flex-col gap-2">
<UploadMultipleFile
images={images}
setImages={(files: UploadImageItem[]) => {
setImages(files);
const imagePath =
files?.[0]?.path ||
files?.[0]?.url ||
files?.[0]?.img ||
"";
setForm((prev) => ({
...prev,
prescriptionImg: imagePath,
}));
}}
/>
</div>
{/* NOTES */}
<TextArea
label="Ghi chú"
name="notes"
placeholder="Nhập ghi chú"
value={form.notes}
isBlur
max={5000}
/>
</div>
</div>
{/* MEDICINE LIST */}
<div className="rounded-2xl border border-gray-200 p-5">
<div className="mb-5 flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#111827]">
Danh sách thuốc
</h3>
<div>
<CustomButton
type="button"
variant="midnightBlue"
icon={<Plus size={18} />}
onClick={handleAddMedicine}
>
Thêm thuốc
</CustomButton>
</div>
</div>
<div className="flex flex-col gap-6">
{form.items.map((medicine, index) => (
<div
key={index}
className="rounded-2xl border border-gray-200 p-5"
>
{/* TITLE */}
<div className="mb-4 flex items-center justify-between">
<h4 className="font-semibold text-[#111827]">
Thuốc #{index + 1}
</h4>
{form.items.length > 1 && (
<button
type="button"
onClick={() => handleRemoveMedicine(index)}
className="text-red-500 transition hover:text-red-600"
>
<Trash2 size={18} />
</button>
)}
</div>
{/* FORM */}
<div className="grid grid-cols-2 gap-5">
<InputForm
label="Tên thuốc"
name={`items.${index}.medicineName`}
placeholder="Nhập tên thuốc"
value={medicine.medicineName}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "medicineName", String(value))
}
/>
<InputForm
label="Liều lượng"
name={`items.${index}.dosage`}
placeholder="Ví dụ: 500mg"
value={medicine.dosage}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "dosage", String(value))
}
/>
<InputForm
label="Tần suất"
name={`items.${index}.frequency`}
placeholder="Ví dụ: 2 lần/ngày"
value={medicine.frequency}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "frequency", String(value))
}
/>
<InputForm
label="Thời gian dùng"
name={`items.${index}.duration`}
placeholder="Ví dụ: 7 ngày"
value={medicine.duration}
type="text"
onChangeValue={(value) =>
handleChangeMedicine(index, "duration", String(value))
}
/>
</div>
<div className="mt-5">
<TextArea
label="Hướng dẫn sử dụng"
name={`items.${index}.instructions`}
placeholder="Nhập hướng dẫn sử dụng thuốc"
value={medicine.instructions}
isBlur
max={5000}
onChangeValue={(value) =>
handleChangeMedicine(index, "instructions", String(value))
}
/>
</div>
</div>
))}
</div>
</div>
</div>
</FormCustom>
);
};
export default CreatePrescription;

View File

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

View File

@ -0,0 +1,123 @@
"use client";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import prescriptionServices, {
PrescriptionDetail,
} from "@/services/prescriptionServices";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import React from "react";
const DetailPrescription = () => {
const params = useParams();
const PrescriptionId = params?.id as string;
const prescriptionDetailQuery = useQuery<PrescriptionDetail>({
queryKey: [QUERY_KEY.chi_tiet_don_thuoc, PrescriptionId],
enabled: !!PrescriptionId,
queryFn: async () => {
const res = await httpRequest<PrescriptionDetail>({
showMessageFailed: true,
http: prescriptionServices.detailPrescription(PrescriptionId),
});
return (
res || {
id: "",
examinationSessionId: "",
prescriptionCode: "",
prescriptionImg: null,
notes: "",
createdBy: "",
items: [],
}
);
},
});
const data = prescriptionDetailQuery.data;
return (
<div className="p-6 bg-white rounded-lg shadow">
<h1 className="text-xl font-semibold mb-4">Chi tiết đơn thuốc</h1>
{/* LOADING */}
{prescriptionDetailQuery.isLoading && <p>Đang tải...</p>}
{/* ERROR */}
{prescriptionDetailQuery.isError && (
<p className="text-red-500">Lỗi tải dữ liệu</p>
)}
{/* CONTENT */}
{data && (
<div className="space-y-4">
<div>
<strong> đơn thuốc:</strong> {data.prescriptionCode}
</div>
<div>
<strong>Phiên khám:</strong> {data.examinationSessionId}
</div>
<div>
<strong>Ghi chú:</strong> {data.notes || "-"}
</div>
{/* IMAGE */}
<div>
<strong>nh đơn thuốc:</strong>
<div className="mt-2">
{data.prescriptionImg ? (
<img
src={data.prescriptionImg}
alt="prescription"
className="h-32 w-32 object-cover rounded"
/>
) : (
<span className="text-gray-400">Không nh</span>
)}
</div>
</div>
{/* ITEMS */}
<div>
<strong>Danh sách thuốc:</strong>
<div className="mt-2 space-y-2">
{data.items?.length ? (
data.items.map((item, index) => (
<div key={item.id || index} className="p-3 border rounded">
<p>
<b>Tên thuốc:</b> {item.medicineName}
</p>
<p>
<b>Liều lượng:</b> {item.dosage}
</p>
<p>
<b>Tần suất:</b> {item.frequency}
</p>
<p>
<b>Thời gian:</b> {item.duration}
</p>
<p>
<b>Hướng dẫn:</b> {item.instructions}
</p>
</div>
))
) : (
<p className="text-gray-400">Không thuốc</p>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default DetailPrescription;

View File

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

View File

@ -0,0 +1,180 @@
import CustomButton from "@/components/customs/custom-button";
import Search from "@/components/customs/custom-search";
import Table from "@/components/customs/custom-table";
import DataWrapper from "@/components/customs/DataWrapper";
import { PATH } from "@/constant/config";
import { QUERY_KEY } from "@/constant/config/enum";
import { httpRequest } from "@/services";
import prescriptionServices, {
PrescriptionItem,
PrescriptionResponse,
} from "@/services/prescriptionServices";
import { useQuery } from "@tanstack/react-query";
import { Pencil, Pill, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
const MainPrescription = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const _page = searchParams.get("_page");
const _pageSize = searchParams.get("_pageSize");
const _keyword = searchParams.get("_keyword");
const prescriptionQuery = useQuery<PrescriptionResponse>({
queryKey: [
QUERY_KEY.table_list_patientrescription,
_page,
_pageSize,
_keyword,
],
queryFn: async () => {
const res = await httpRequest<PrescriptionResponse>({
showMessageFailed: true,
http: prescriptionServices.getPrescription({
Page: Number(_page || 1),
PageSize: Number(_pageSize || 10),
Search: _keyword || "",
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const updateQuery = (key: string, value?: string | number) => {
const params = new URLSearchParams(searchParams.toString());
if (!value) {
params.delete(key);
} else {
params.set(key, String(value));
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-black">Đơn thuốc</h2>
<CustomButton
variant="midnightBlue"
fullWidth={false}
icon={<Pill />}
href={PATH.CREATE_PRESCRIPTION}
>
Tạo mới
</CustomButton>
</div>
{/* SEARCH */}
<div className="flex items-center justify-between gap-4">
<div className="w-full md:min-w-[400px]">
<Search
keyword={_keyword ?? ""}
setKeyword={(value) => updateQuery("_keyword", value)}
placeholder="Tìm theo mã đơn thuốc"
/>
</div>
</div>
<DataWrapper
data={prescriptionQuery.data?.items || []}
loading={prescriptionQuery.isLoading}
title="No Data"
note="Không có đơn thuốc nào"
>
<Table
rowKey={(item: any) => item.id}
data={prescriptionQuery.data?.items || []}
column={[
// ================= ID / CODE =================
{
title: "MÃ ĐƠN THUỐC",
render: (item: any) => (
<Link
href={`/prescription/${item.id}`}
className="text-blue-500 hover:underline"
>
{item.prescriptionCode}
</Link>
),
},
// ================= SESSION =================
{
title: "PHIÊN KHÁM",
render: (item: any) => <span>{item.examinationSessionId}</span>,
},
// ================= NOTES =================
{
title: "GHI CHÚ",
render: (item: any) => <span>{item.notes || "-"}</span>,
},
// ================= IMAGE =================
{
title: "ẢNH ĐƠN THUỐC",
render: (item: any) =>
item.prescriptionImg ? (
<Image
src={item.prescriptionImg}
alt="prescription"
className="h-10 w-10 rounded object-cover"
/>
) : (
<span className="text-gray-400">Không nh</span>
),
},
// ================= ITEMS COUNT =================
{
title: "SỐ THUỐC",
render: (item: any) => <span>{item.items?.length || 0}</span>,
},
// ================= ACTION =================
{
fixedRight: true,
title: "ACTION",
render: (item: any) => (
<div className="flex items-center gap-2">
<CustomButton
size="sm"
icon={<Pencil className="text-blue-400" />}
href={`${PATH.PRESCRIPTION}/update?_id=${item?.id}`}
/>
</div>
),
},
]}
/>
</DataWrapper>
</div>
);
};
export default MainPrescription;

View File

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

View File

@ -0,0 +1,514 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import CustomButton from "@/components/customs/custom-button";
import FormCustom from "@/components/utils/FormCustom";
import InputForm from "@/components/utils/FormCustom/components/InputForm";
import TextArea from "@/components/utils/FormCustom/components/TextArea";
import SelectForm from "@/components/utils/FormCustom/components/SelectForm";
import UploadMultipleFile from "@/components/utils/UploadMultipleFile";
import { httpRequest } from "@/services";
import prescriptionServices from "@/services/prescriptionServices";
import consultationServices, {
ConsultationItem,
ConsultationResponse,
} from "@/services/consultationServices";
import { QUERY_KEY } from "@/constant/config/enum";
import { PATH } from "@/constant/config";
import { toastWarn } from "@/common/funcs/toast";
import type { PrescriptionDetail } from "@/services/prescriptionServices";
/* =========================
TYPES
========================= */
interface PrescriptionItem {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}
interface PrescriptionForm {
examinationSessionId: string;
notes: string;
prescriptionImg: string;
items: PrescriptionItem[];
}
interface UploadImageItem {
url?: string;
path?: string;
img?: string;
}
/* =========================
DEFAULT
========================= */
const defaultMedicine: PrescriptionItem = {
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
};
/* =========================
COMPONENT
========================= */
const UpdatePrescription = () => {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const idFromParams = params?.id as string | undefined;
const idFromSearch = searchParams?.get("_id") ?? undefined;
const id = idFromParams || idFromSearch || "";
/* =========================
GET EXAMINATION SESSION
========================= */
const examinationSessionQuery = useQuery<ConsultationResponse>({
queryKey: [QUERY_KEY.table_list_consultation],
queryFn: async () => {
const res = await httpRequest<ConsultationResponse>({
showMessageFailed: true,
http: consultationServices.getConsultations({
Page: 1,
PageSize: 100,
SortBy: "id",
Desc: true,
}),
});
return (
res || {
items: [],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}
);
},
});
const examinationSessionOptions = examinationSessionQuery.data?.items || [];
/* =========================
GET DETAIL PRESCRIPTION
========================= */
const detailQuery = useQuery<PrescriptionDetail>({
queryKey: [QUERY_KEY.chi_tiet_don_thuoc, id],
enabled: !!id,
queryFn: async () => {
const res = await httpRequest<PrescriptionDetail>({
showMessageFailed: true,
http: prescriptionServices.detailPrescription(id),
});
return (
res || {
id: "",
examinationSessionId: "",
prescriptionCode: "",
prescriptionImg: null,
notes: "",
createdBy: "",
items: [],
}
);
},
});
/* =========================
MAP DATA
========================= */
const mappedForm: PrescriptionForm = useMemo(() => {
const data = detailQuery.data;
if (!data) {
return {
examinationSessionId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
};
}
return {
examinationSessionId: data.examinationSessionId ?? "",
notes: data.notes ?? "",
prescriptionImg: data.prescriptionImg ?? "",
items:
data.items && data.items.length > 0
? data.items.map((i) => ({
medicineName: i.medicineName ?? "",
dosage: i.dosage ?? "",
frequency: i.frequency ?? "",
duration: i.duration ?? "",
instructions: i.instructions ?? "",
}))
: [defaultMedicine],
};
}, [detailQuery.data]);
/* =========================
FORM STATE
========================= */
const [form, setForm] = useState<PrescriptionForm>({
examinationSessionId: "",
notes: "",
prescriptionImg: "",
items: [defaultMedicine],
});
const [images, setImages] = useState<UploadImageItem[]>([]);
const [initialized, setInitialized] = useState(false);
/* =========================
INIT FORM
========================= */
useEffect(() => {
if (!detailQuery.data || initialized) return;
queueMicrotask(() => {
setForm(mappedForm);
setImages(
mappedForm.prescriptionImg ? [{ url: mappedForm.prescriptionImg }] : [],
);
setInitialized(true);
});
}, [detailQuery.data, initialized, mappedForm]);
/* =========================
UPDATE MUTATION
========================= */
const updateMutation = useMutation({
mutationFn: async () => {
if (!id || id === "undefined") {
throw new Error("Không tìm thấy ID đơn thuốc!");
}
return await httpRequest({
showMessageFailed: true,
showMessageSuccess: true,
msgSuccess: "Cập nhật đơn thuốc thành công!",
http: prescriptionServices.putPrescription(id, {
// examinationSessionId: form.examinationSessionId,
notes: form.notes,
prescriptionImg: form.prescriptionImg,
items: form.items,
}),
});
},
onSuccess: () => {
router.push(PATH.PRESCRIPTION);
},
onError: (error: any) => {
const message =
error?.message || error?.error?.message || "Có lỗi xảy ra";
toastWarn({
msg: message,
});
},
});
/* =========================
HANDLERS
========================= */
const handleChangeMedicine = (
index: number,
key: keyof PrescriptionItem,
value: string,
) => {
const clone = [...form.items];
clone[index] = {
...clone[index],
[key]: value,
};
setForm((prev) => ({
...prev,
items: clone,
}));
};
const handleAddMedicine = () => {
setForm((prev) => ({
...prev,
items: [
...prev.items,
{
medicineName: "",
dosage: "",
frequency: "",
duration: "",
instructions: "",
},
],
}));
};
const handleRemoveMedicine = (index: number) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== index),
}));
};
const handleSubmit = () => {
updateMutation.mutate();
};
const isLoading = updateMutation.isPending || detailQuery.isLoading;
/* =========================
UI
========================= */
return (
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow">
{/* HEADER */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-[28px] font-semibold text-[#111827]">
Cập nhật đơn thuốc
</h2>
<p className="mt-1 text-sm text-[#697586]">
Chỉnh sửa đơn thuốc cho bệnh nhân
</p>
</div>
<div>
<CustomButton
type="submit"
variant="midnightBlue"
// Vừa loading vừa check id, nếu id không tồn tại thì không cho click submit
disabled={isLoading || !id || id === "undefined"}
>
{isLoading ? "Đang cập nhật..." : "Lưu đơn thuốc"}
</CustomButton>
</div>
</div>
{/* GENERAL INFO */}
<div className="rounded-2xl border border-gray-200 p-5">
<h3 className="mb-5 text-lg font-semibold text-[#111827]">
Thông tin chung
</h3>
<div className="grid grid-cols-1 gap-5">
{/* EXAM SESSION */}
<SelectForm
label={
<span className="text-[14px] font-medium text-[#374151]">
Phiên khám <span className="text-red-500">*</span>
</span>
}
placeholder="Chọn phiên khám"
value={form.examinationSessionId}
readOnly
options={examinationSessionOptions}
onSelect={(item: ConsultationItem) =>
setForm((prev) => ({
...prev,
examinationSessionId: item.id,
}))
}
onClean={() =>
setForm((prev) => ({
...prev,
examinationSessionId: "",
}))
}
getOptionLabel={(item: ConsultationItem) => item.sessionCode}
getOptionValue={(item: ConsultationItem) => item.id}
/>
{/* IMAGE */}
<UploadMultipleFile
images={images}
setImages={(files: UploadImageItem[]) => {
setImages(files);
const imagePath =
files?.[0]?.path || files?.[0]?.url || files?.[0]?.img || "";
setForm((prev) => ({
...prev,
prescriptionImg: imagePath,
}));
}}
/>
{/* NOTES */}
<TextArea
label="Ghi chú"
name="notes"
placeholder="Nhập ghi chú"
value={form.notes}
isBlur
max={5000}
onChangeValue={(v) =>
setForm((prev) => ({ ...prev, notes: String(v) }))
}
/>
</div>
</div>
{/* MEDICINE LIST */}
<div className="rounded-2xl border border-gray-200 p-5">
<div className="mb-5 flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#111827]">
Danh sách thuốc
</h3>
<div>
<CustomButton
type="button"
variant="midnightBlue"
icon={<Plus size={18} />}
onClick={handleAddMedicine}
>
Thêm thuốc
</CustomButton>
</div>
</div>
<div className="flex flex-col gap-6">
{form.items.map((medicine, index) => (
<div
key={index}
className="rounded-2xl border border-gray-200 p-5"
>
{/* TITLE */}
<div className="mb-4 flex items-center justify-between">
<h4 className="font-semibold text-[#111827]">
Thuốc #{index + 1}
</h4>
{form.items.length > 1 && (
<button
type="button"
onClick={() => handleRemoveMedicine(index)}
className="text-red-500 transition hover:text-red-600"
>
<Trash2 size={18} />
</button>
)}
</div>
{/* INPUTS */}
<div className="grid grid-cols-2 gap-5">
<InputForm
label="Tên thuốc"
name={`medicineName-${index}`}
type="text"
placeholder="Nhập tên thuốc"
value={medicine.medicineName}
onChangeValue={(v) =>
handleChangeMedicine(index, "medicineName", String(v))
}
/>
<InputForm
label="Liều lượng"
name={`dosage-${index}`}
type="text"
placeholder="Nhập liều lượng"
value={medicine.dosage}
onChangeValue={(v) =>
handleChangeMedicine(index, "dosage", String(v))
}
/>
<InputForm
label="Tần suất"
name={`frequency-${index}`}
type="text"
placeholder="Nhập tần suất"
value={medicine.frequency}
onChangeValue={(v) =>
handleChangeMedicine(index, "frequency", String(v))
}
/>
<InputForm
label="Thời gian"
name={`duration-${index}`}
type="text"
placeholder="Nhập thời gian"
value={medicine.duration}
onChangeValue={(v) =>
handleChangeMedicine(index, "duration", String(v))
}
/>
</div>
{/* INSTRUCTIONS */}
<div className="mt-5">
<TextArea
label="Hướng dẫn sử dụng"
name={`instructions-${index}`}
placeholder="Nhập hướng dẫn sử dụng"
value={medicine.instructions}
onChangeValue={(v) =>
handleChangeMedicine(index, "instructions", String(v))
}
/>
</div>
</div>
))}
</div>
</div>
</div>
</FormCustom>
);
};
export default UpdatePrescription;

View File

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

View File

@ -31,5 +31,6 @@ export interface PropsInputForm {
isUppercase?: boolean; isUppercase?: boolean;
onBlur?: () => void; onBlur?: () => void;
onChangeValue?: (val: string | number) => void; onChangeValue?: (val: string | number) => void;
} }

View File

@ -81,7 +81,7 @@ function SelectForm<OptionType>({
onClick={() => !readOnly && setOpen((prev) => !prev)} onClick={() => !readOnly && setOpen((prev) => !prev)}
className={clsx( className={clsx(
"flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition", "flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition",
"border-gray-300 bg-white hover:border-blue-600", "border-gray-300 bg-gray-300 hover:border-blue-600",
open && "border-blue-600", open && "border-blue-600",
readOnly && readOnly &&
"bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200", "bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200",

View File

@ -2,7 +2,6 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { PropsTextArea } from "./interface"; import { PropsTextArea } from "./interface";
import { ContextFormCustom, IContextFormCustom } from "../../contexts"; import { ContextFormCustom, IContextFormCustom } from "../../contexts";
@ -18,6 +17,7 @@ function TextArea({
min, min,
isRequired, isRequired,
textRequired, textRequired,
onChangeValue, // Nhận callback custom từ cha
}: PropsTextArea) { }: PropsTextArea) {
const [isFocus, setIsFocus] = useState(false); const [isFocus, setIsFocus] = useState(false);
@ -31,159 +31,175 @@ function TextArea({
setErrorText, setErrorText,
} = useContext<IContextFormCustom<any>>(ContextFormCustom); } = useContext<IContextFormCustom<any>>(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 // VALIDATE LOGIC
// ========================= // =========================
const handleValidate = (): boolean => { const handleValidate = (inputValue?: string) => {
const currentValue = `${form[name] || ""}`.trim(); const finalValue = (inputValue ?? String(currentValue)).trim();
if (isRequired && currentValue === "") return false; if (isRequired && finalValue === "") {
return {
valid: false,
message: textRequired || "Vui lòng nhập trường này",
};
}
if (min && currentValue.length < min) return false; if (max && finalValue.length > max) {
return {
valid: false,
message: `Nhập tối đa ${max} kí tự`,
};
}
if (max && currentValue.length > max) return false; if (min && finalValue.length < min) {
return {
valid: false,
message: `Nhập tối thiểu ${min} kí tự`,
};
}
return true; return {
valid: true,
message: null,
};
}; };
// ========================= // Cập nhật lỗi và trạng thái validate vào Context
// ERROR MESSAGE const handleSetMessage = (inputValue?: string) => {
// ========================= const result = handleValidate(inputValue);
const handleSetMessage = () => {
const currentValue = `${form[name] || ""}`.trim();
setErrorText((prev) => ({ setErrorText((prev) => ({
...prev, ...prev,
[name]: null, [name]: result.message,
})); }));
if (isRequired && currentValue === "") { setValidate((prev) => ({
return setErrorText((prev) => ({ ...prev,
...prev, [name]: result.valid,
[name]: textRequired || "Vui lòng nhập trường này", }));
}));
}
if (max && currentValue.length > max) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối đa ${max} kí tự`,
}));
}
if (min && currentValue.length < min) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối thiểu ${min} kí tự`,
}));
}
}; };
// ========================= // =========================
// EFFECTS // EFFECTS
// ========================= // =========================
// Chạy validate toàn cục khi Form yêu cầu (countValidate tăng)
useEffect(() => { useEffect(() => {
if (countValidate > 0) { if (countValidate > 0) {
handleSetMessage(); handleSetMessage();
} }
}, [countValidate]); }, [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(() => { useEffect(() => {
const result = handleValidate();
setValidate((prev) => ({ setValidate((prev) => ({
...prev, ...prev,
[name]: handleValidate(), [name]: result.valid,
})); }));
}, [form[name]]); }, [currentValue]);
// ========================= // =========================
// EVENTS // EVENT HANDLERS
// ========================= // =========================
const handlerFocused = () => { const handleFocus = () => {
setIsFocus(true); setIsFocus(true);
// Xóa thông báo lỗi khi người dùng bắt đầu sửa lại
setErrorText((prev) => ({ setErrorText((prev) => ({
...prev, ...prev,
[name]: null, [name]: null,
})); }));
}; };
const handlerBlur = () => { const handleBlur = () => {
setIsFocus(false); setIsFocus(false);
if (isBlur) { if (isBlur) {
handleSetMessage(); handleSetMessage();
setValidate((prev) => ({
...prev,
[name]: handleValidate(),
}));
} }
}; };
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target; 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) => ({ setForm((prev: any) => ({
...prev, ...prev,
[name]: value, [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 ( return (
<div className="w-full space-y-1"> <div className="w-full space-y-1">
{/* LABEL */} {/* LABEL */}
{label && ( {label && (
<label <label
htmlFor={`textarea_${name}`} htmlFor={`textarea_${name}`}
className="block text-[16px] font-medium" className="block text-[16px] font-medium text-[#171717]"
> >
{label} {label}
</label> </label>
)} )}
{/* TEXTAREA */} {/* TEXTAREA WRAPPER */}
<textarea <textarea
id={`textarea_${name}`} id={`textarea_${name}`}
name={name} name={name}
value={value ?? form[name] ?? ""} value={String(currentValue)}
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" autoComplete="off"
readOnly={readOnly} readOnly={readOnly}
disabled={readOnly} disabled={readOnly}
onChange={handleChange} onChange={handleChange}
onFocus={handlerFocused} onFocus={handleFocus}
onBlur={handlerBlur} onBlur={handleBlur}
className={clsx( className={clsx(
"w-full min-h-[140px] resize-none rounded-[20px] border px-4 py-3 text-[16px] font-medium outline-none transition", "w-full min-h-[140px] resize-none rounded-[20px] border px-4 py-3 text-[16px] font-medium outline-none transition-all",
// default // Trạng thái mặc định
"border-[#CDD5DF] bg-white", "border-[#CDD5DF] bg-white text-[#171717]",
// hover // Hover (chỉ khi không readOnly)
!readOnly && "hover:border-[#0019F6]", !readOnly && "hover:border-[#0019F6]",
// focus // Focus
isFocus && "border-[#0019F6]", isFocus && "border-[#0019F6]",
// error // Có lỗi
errorText[name] && "border-[#E5493F]", errorText?.[name] && "border-red-500",
// done // Đã hoàn tất và không có lỗi
showDone && isDone && "border-[#1CAD4C]", showDone && isDone && !errorText?.[name] && "border-green-600",
// readonly // Trạng thái ReadOnly/Disabled
readOnly && readOnly && "cursor-not-allowed border-gray-200 bg-gray-100",
"cursor-not-allowed border-gray-200 bg-gray-100 hover:border-gray-200",
)} )}
/> />
{/* ERROR */} {/* ERROR MESSAGE */}
{errorText[name] && ( {errorText?.[name] && (
<p className="text-[12px] text-red-500">{errorText[name]}</p> <p className="mt-1 text-xs font-medium text-red-500">
{errorText[name]}
</p>
)} )}
</div> </div>
); );

View File

@ -14,4 +14,5 @@ export interface PropsTextArea {
min?: number; min?: number;
isRequired?: boolean; isRequired?: boolean;
onChangeValue?: (val: string) => void;
} }

View File

@ -0,0 +1,104 @@
"use client";
import React from "react";
import Image from "next/image";
import { X, CirclePlus } from "lucide-react";
import clsx from "clsx";
import { PropsUploadMultipleFile } from "./interface";
export default function UploadMultipleFile({
images = [],
setImages,
isDisableDelete = false,
}: PropsUploadMultipleFile) {
// 👉 upload files
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
const newImages = Array.from(files).map((file) => ({
url: URL.createObjectURL(file),
file,
}));
setImages((prev: any) => [...prev, ...newImages]);
};
// 👉 delete file
const handleDelete = (index: number) => {
setImages((prev: any) => {
const target = prev[index];
if (target?.url) URL.revokeObjectURL(target.url);
return prev.filter((_: any, i: number) => i !== index);
});
};
return (
<div className="flex items-center flex-wrap gap-2">
{/* LIST IMAGE */}
{images?.length > 0 && (
<div className="flex items-center flex-wrap gap-2">
{images.map((image, index) => (
<div
key={index}
className="relative w-[72px] h-[72px] rounded-md border border-[#99a2b34d] overflow-hidden select-none"
>
<Image
src={
image?.url ||
`${process.env.NEXT_PUBLIC_IMAGE}/${image?.path}`
}
alt="image"
fill
className="object-cover"
/>
{/* DELETE BUTTON */}
{isDisableDelete && !image?.file && image?.img ? null : (
<div
onClick={() => handleDelete(index)}
className="absolute top-[2px] right-[2px] w-[18px] h-[18px] bg-white rounded-sm flex items-center justify-center cursor-pointer transition active:scale-90"
>
<X size={14} color="#8496AC" />
</div>
)}
</div>
))}
</div>
)}
{/* UPLOAD BUTTON */}
<div className="flex items-center gap-2">
<label
className={clsx(
"flex items-center justify-center",
"w-[72px] h-[72px] rounded-md border cursor-pointer bg-white",
"border-[#99a2b34d] hover:border-gray-400 transition",
)}
>
<CirclePlus color="rgba(198, 201, 206, 1)" />
<input
hidden
type="file"
multiple
accept="image/png, image/jpeg, image/gif"
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
onChange={handleFileChange}
/>
</label>
{/* NOTE */}
<div className="flex flex-col">
<p className="text-[14px] font-medium text-[#2f3643]">Upload file</p>
<p className="text-[12px] font-medium text-[#99a2b3]">
File không vượt quá 50MB
</p>
</div>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,5 @@
export interface PropsUploadMultipleFile {
images: any[];
setImages: (any: any) => void;
isDisableDelete?: boolean;
}

View File

@ -1,3 +1,26 @@
export enum QUERY_KEY {
table_list_patient,
table_list_consultation,
table_list_patientrescription,
chi_tiet_benh_nhan,
chi_tiet_don_thuoc,
chi_tiet_phien_kham,
}
export enum TYPE_STATUS {
Draft = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
}
export enum TYPE_GENDER {
MALE = "male",
FEMALE = "female",
OTHER = "other",
}
export enum TYPE_DATE { export enum TYPE_DATE {
ALL = -1, ALL = -1,
TODAY = 1, TODAY = 1,

View File

@ -6,6 +6,7 @@ export enum PATH {
PATIENT = "/patient", PATIENT = "/patient",
CONSULTATION = "/consultation", CONSULTATION = "/consultation",
PRESCRIPTION = "/prescription", PRESCRIPTION = "/prescription",
CREATE_PRESCRIPTION = "/prescription/create",
LOGIN = "/auth/login", LOGIN = "/auth/login",
REGISTER = "/auth/register", REGISTER = "/auth/register",
@ -26,17 +27,17 @@ export const Menus: {
}[] = [ }[] = [
{ {
group: [ group: [
{ // {
title: "Dashboard", // title: "Dashboard",
icon: CircleGauge, // icon: CircleGauge,
path: PATH.DASHBOARD || PATH.HOME, // path: PATH.DASHBOARD || PATH.HOME,
pathActive: PATH.DASHBOARD, // pathActive: PATH.DASHBOARD,
}, // },
{ {
title: "Bệnh nhân", title: "Bệnh nhân",
icon: Users, icon: Users,
path: PATH.PATIENT, path: PATH.PATIENT || PATH.HOME,
pathActive: PATH.PATIENT, pathActive: PATH.PATIENT || PATH.HOME,
}, },
{ {
title: "Phiên khám", title: "Phiên khám",

View File

@ -3,6 +3,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit";
export interface IDataLoginStorage { export interface IDataLoginStorage {
usernameStorage: string; usernameStorage: string;
passwordStorage: string; passwordStorage: string;
deviceIdStorage: string;
} }
export interface AuthState { export interface AuthState {

View File

@ -5,9 +5,7 @@ const authServices = {
data: { data: {
username: string; username: string;
password: string; password: string;
ip: string; deviceId: string;
address: string;
type: number;
}, },
tokenAxios?: any, tokenAxios?: any,
) => { ) => {
@ -15,11 +13,38 @@ const authServices = {
cancelToken: tokenAxios, cancelToken: tokenAxios,
}); });
}, },
logout: (data: void, tokenAxios?: any) => { register: (
return axiosClient.post(`/Auth/logout`, data, { data: {
username: string;
password: string;
fullName: string;
email: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Auth/register`, data, {
cancelToken: tokenAxios, cancelToken: tokenAxios,
}); });
}, },
refresh: (
data: {
identifier: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Auth/refresh`, data, {
cancelToken: tokenAxios,
});
},
logout: (tokenAxios?: any) => {
return axiosClient.post(
`/Auth/logout`,
{},
{
cancelToken: tokenAxios,
},
);
},
}; };
export default authServices; export default authServices;

View File

@ -0,0 +1,101 @@
import axiosClient from ".";
export interface GetConsultationParams {
Page?: number;
PageSize?: number;
Search?: string;
PatientId?: string;
SortBy?: string;
Desc?: boolean;
}
export interface ConsultationItem {
id: string;
patientId: string;
sessionCode: string;
visitDate: string;
status: number;
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
createdBy: string;
isDeleted: boolean;
}
export interface ConsultationDetail {
id: string;
patientId: string;
sessionCode: string;
visitDate: string;
status: number;
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
createdBy: string;
isDeleted: boolean;
}
export interface ConsultationResponse {
items: ConsultationItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
const consultationServices = {
getConsultations: (params?: GetConsultationParams, tokenAxios?: any) => {
return axiosClient.get<ConsultationResponse>(`/ExaminationSession/list`, {
params,
cancelToken: tokenAxios,
});
},
createConsultations: (data?: { patientId: string }, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession`, data, {
cancelToken: tokenAxios,
});
},
detailConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.get<ConsultationDetail>(`/ExaminationSession/${id}`, {
cancelToken: tokenAxios,
});
},
putConsultations: (
id: string,
data: {
chiefComplaint: string | null;
symptomsText: string | null;
vitalSigns: string | null;
diagnosis: string | null;
treatmentPlan: string | null;
doctorNotes: string | null;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/ExaminationSession/${id}`, data, {
cancelToken: tokenAxios,
});
},
cancelConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession/${id}/cancel`, {
cancelToken: tokenAxios,
});
},
completeConsultations: (id: string, tokenAxios?: any) => {
return axiosClient.post(`/ExaminationSession/${id}/complete`, {
cancelToken: tokenAxios,
});
},
};
export default consultationServices;

View File

@ -1,7 +1,6 @@
import axios, { import axios, {
AxiosError, AxiosError,
AxiosInstance, AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig, InternalAxiosRequestConfig,
} from "axios"; } from "axios";
@ -9,17 +8,22 @@ import { delay } from "@/common/funcs/delay";
import { getKeyCert } from "@/common/funcs/optionConvert"; import { getKeyCert } from "@/common/funcs/optionConvert";
import { toastInfo, toastSuccess, toastWarn } from "@/common/funcs/toast"; import { toastInfo, toastSuccess, toastWarn } from "@/common/funcs/toast";
import { logout } from "@/redux/reducer/auth"; import { logout, setToken } from "@/redux/reducer/auth";
import { setInfoUser } from "@/redux/reducer/user"; import { setInfoUser } from "@/redux/reducer/user";
import { store } from "@/redux/store"; import { store } from "@/redux/store";
/* =========================================================
* TYPES
* =======================================================*/
interface ApiError { interface ApiError {
code: number; code: string | number;
message: string; message: string;
} }
interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
error: ApiError; success?: boolean;
error?: ApiError | null;
data?: T; data?: T;
items?: T[]; items?: T[];
pagination?: unknown; pagination?: unknown;
@ -42,16 +46,20 @@ interface HttpRequestOptions<T> {
isData?: boolean; isData?: boolean;
} }
const axiosClient = axios.create({ /* =========================================================
headers: { * AXIOS CLIENT
"content-type": "application/json", * =======================================================*/
},
baseURL: "/api-proxy", const axiosClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL_DEV,
timeout: 15000, timeout: 15000,
timeoutErrorMessage: "Timeout error request", timeoutErrorMessage: "Timeout error request",
headers: {
"content-type": "application/json",
},
}) as AxiosInstance & { }) as AxiosInstance & {
get<T = unknown>(url: string, config?: any): Promise<ApiResponse<T>>; get<T = unknown>(url: string, config?: any): Promise<ApiResponse<T>>;
@ -70,16 +78,120 @@ const axiosClient = axios.create({
delete<T = unknown>(url: string, config?: any): Promise<ApiResponse<T>>; delete<T = unknown>(url: string, config?: any): Promise<ApiResponse<T>>;
}; };
/* =========================================================
* REFRESH CLIENT
* =======================================================*/
const refreshClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL_DEV,
timeout: 15000,
});
/* =========================================================
* REFRESH REQUEST INTERCEPTOR
* =======================================================*/
refreshClient.interceptors.request.use((config) => {
if (!(config.data instanceof FormData)) {
config.data = {
...getKeyCert(),
...(config.data || {}),
};
}
return config;
});
/* =========================================================
* REFRESH TOKEN
* =======================================================*/
let refreshPromise: Promise<string | null> | null = null;
const refreshAccessToken = async (): Promise<string | null> => {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
try {
const state = store.getState();
const refreshToken = state.user?.infoUser?.refreshToken || null;
if (!refreshToken) {
return null;
}
const response = await refreshClient.post<{
token?: string;
accessToken?: string;
refreshToken?: string;
}>("/Auth/refresh", {
identifier: refreshToken,
});
const responseData = response.data;
const newToken = responseData?.token || responseData?.accessToken;
if (!newToken) {
return null;
}
/* UPDATE ACCESS TOKEN */
store.dispatch(setToken(newToken));
/* UPDATE USER INFO */
const prev = store.getState().user.infoUser;
store.dispatch(
setInfoUser({
accessToken: newToken,
refreshToken: responseData?.refreshToken || prev?.refreshToken || "",
accessExpiresAt: prev?.accessExpiresAt || "",
refreshExpiresAt: prev?.refreshExpiresAt || "",
avatar: prev?.avatar || "",
fullname: prev?.fullname || "",
}),
);
return newToken;
} catch (error) {
console.error("Refresh token failed:", error);
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
/* =========================================================
* REQUEST INTERCEPTOR
* =======================================================*/
axiosClient.interceptors.request.use( axiosClient.interceptors.request.use(
async ( async (
config: InternalAxiosRequestConfig, config: InternalAxiosRequestConfig,
): Promise<InternalAxiosRequestConfig> => { ): Promise<InternalAxiosRequestConfig> => {
const token = store.getState().auth.token; const token = store.getState().auth.token;
config.headers = config.headers || {};
/* AUTHORIZATION */
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
/* AUTO ADD CERT */
if (!(config.data instanceof FormData)) { if (!(config.data instanceof FormData)) {
config.data = { config.data = {
...getKeyCert(), ...getKeyCert(),
@ -91,106 +203,131 @@ axiosClient.interceptors.request.use(
}, },
); );
/* =========================================================
* RESPONSE INTERCEPTOR
* =======================================================*/
axiosClient.interceptors.response.use( axiosClient.interceptors.response.use(
(response) => { (response) => {
return response.data; return (
response.data || {
success: false,
error: {
code: "UNKNOWN_ERROR",
message: "Unknown error",
},
}
);
}, },
(error: AxiosError<ApiError>) => { async (error: AxiosError<ApiResponse>) => {
return Promise.reject(error.response?.data || error); const status = error.response?.status;
const data = error.response?.data;
const config = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
/* =====================================================
* AUTO REFRESH TOKEN
* ===================================================*/
if (status === 401 && !config._retry) {
config._retry = true;
const newToken = await refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
return axiosClient(config);
}
/* REFRESH FAILED */
store.dispatch(logout());
store.dispatch(setInfoUser(null));
}
/* =====================================================
* HANDLE EMPTY RESPONSE
* ===================================================*/
if ((status === 401 || status === 403) && !data) {
return Promise.reject({
success: false,
error: {
code: status,
message: error.response?.statusText || "Unauthorized",
},
});
}
return Promise.reject(
data || {
success: false,
error: {
code: "SERVER_ERROR",
message: "Có lỗi xảy ra",
},
},
);
}, },
); );
export default axiosClient; export default axiosClient;
/* =========================================================
* HTTP REQUEST
* =======================================================*/
export const httpRequest = async <T>({ export const httpRequest = async <T>({
http, http,
setLoading, setLoading,
msgSuccess = "Thành công", msgSuccess = "Thành công",
showMessageSuccess = false, showMessageSuccess = false,
showMessageFailed = false, showMessageFailed = false,
onError, onError,
}: HttpRequestOptions<T>): Promise<T> => {
isDropdown = false,
isPagination = false,
isData = false,
}: HttpRequestOptions<T>) => {
try { try {
setLoading?.(true); setLoading?.(true);
await delay(500); await delay(300);
const res = await http; const res = await http;
if (res.error.code === 0) { const safeRes =
res ??
({
success: false,
error: {
code: "UNKNOWN_ERROR",
message: "Unknown error",
},
} as ApiResponse<T>);
// ================= SUCCESS =================
if (safeRes.success) {
if (showMessageSuccess) { if (showMessageSuccess) {
toastSuccess({ toastSuccess({ msg: msgSuccess });
msg: msgSuccess || res.error.message,
});
} }
if (isDropdown) { return safeRes.data as T;
return res.items || [];
}
if (isPagination) {
return {
...res.data,
items: res.items || [],
pagination: res.pagination,
};
}
if (isData) {
return res;
}
return res.data || true;
} }
// ================= FAIL =================
const error = {
message: safeRes.error?.message || "Có lỗi xảy ra",
code: safeRes.error?.code,
};
onError?.(); onError?.();
throw new Error(res.error.message); throw error; // 👈 QUAN TRỌNG
} catch (error: unknown) { } catch (error) {
const err = error as AxiosError<ApiResponse> | Error; throw error; // 👈 QUAN TRỌNG: KHÔNG NUỐT
const errorData = (err as AxiosError<ApiResponse>)?.response?.data;
if (errorData?.error?.code === 401 || errorData?.error?.code === 403) {
store.dispatch(logout());
store.dispatch(setInfoUser(null));
if (showMessageFailed) {
toastWarn({
msg: errorData?.error?.message || "Có lỗi đã xảy ra!",
});
}
} else if (err instanceof Error) {
if (err.message === "ERR_NETWORK" || err.message === "ECONNABORTED") {
if (showMessageFailed) {
toastInfo({
msg: "Kiểm tra kết nối internet",
});
}
} else {
if (showMessageFailed) {
toastWarn({
msg: err.message || "Có lỗi đã xảy ra!",
});
}
}
} else {
if (showMessageFailed) {
toastWarn({
msg: "Có lỗi đã xảy ra!",
});
}
}
} finally { } finally {
setLoading?.(false); setLoading?.(false);
} }

View File

@ -0,0 +1,118 @@
import axiosClient from ".";
export interface PatientItem {
id: string;
patientCode: string;
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName?: string;
emergencyContactPhone?: string;
allergyNotes?: string;
chronicDiseaseNotes?: string;
insuranceNumber?: string;
notes?: string;
isActive: boolean;
}
export interface PatientDetail {
id: string;
patientCode: string;
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
allergyNotes: string | null;
chronicDiseaseNotes: string | null;
insuranceNumber: string | null;
notes: string | null;
isActive: boolean;
}
export interface GetPatientParams {
Page?: number;
PageSize?: number;
Search?: string;
IdentificationNumber?: string;
SortBy?: string;
Desc?: boolean;
}
export interface PatientResponse {
items: PatientItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
const patientServices = {
getPatient: (params?: GetPatientParams, tokenAxios?: any) => {
return axiosClient.get<PatientResponse>(`/Patient/list`, {
params,
cancelToken: tokenAxios,
});
},
createPatient: (
data: {
identificationNumber: string;
fullName: string;
dateOfBirth: string;
gender: string;
phone: string;
email: string;
address: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Patient`, data, {
cancelToken: tokenAxios,
});
},
putPatient: (
id: string,
data: {
fullName: string;
gender: string;
phone: string;
email: string;
address: string;
emergencyContactName: string;
emergencyContactPhone: string;
allergyNotes: string;
chronicDiseaseNotes: string;
insuranceNumber: string;
notes: string;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/Patient/${id}`, data, {
cancelToken: tokenAxios,
});
},
detailPatient: (id: string, tokenAxios?: any) => {
return axiosClient.get<PatientDetail>(`/Patient/${id}`, {
cancelToken: tokenAxios,
});
},
deletePatient: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/Patient/${id}`, {
cancelToken: tokenAxios,
});
},
};
export default patientServices;

View File

@ -0,0 +1,109 @@
import axiosClient from ".";
export interface GetPrescriptionParams {
Page?: number;
PageSize?: number;
Search?: string;
SortBy?: string;
Desc?: boolean;
}
export interface PrescriptionItem {
id: string;
examinationSessionId: string;
prescriptionCode: string;
prescriptionImg: string | null;
notes: string;
createdBy: string;
items: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface PrescriptionDetail {
id: string;
examinationSessionId: string;
prescriptionCode: string;
prescriptionImg: string | null;
notes: string;
createdBy: string;
items: {
id: string;
prescriptionId: string;
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
}
export interface PrescriptionResponse {
items: PrescriptionItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
const prescriptionServices = {
createPrescription: (
data: {
examinationSessionId: string;
notes: string;
prescriptionImg: string;
items: {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Prescription`, data, {
cancelToken: tokenAxios,
});
},
getPrescription: (params?: GetPrescriptionParams, tokenAxios?: any) => {
return axiosClient.get<PrescriptionResponse>(`/Prescription`, {
params,
cancelToken: tokenAxios,
});
},
putPrescription: (
id: string,
data: {
notes: string;
prescriptionImg: string;
items: {
medicineName: string;
dosage: string;
frequency: string;
duration: string;
instructions: string;
}[];
},
tokenAxios?: any,
) => {
return axiosClient.put(`/Prescription/${id}`, data, {
cancelToken: tokenAxios,
});
},
detailPrescription: (id: string, tokenAxios?: any) => {
return axiosClient.get<PrescriptionDetail>(`/Prescription/${id}`, {
cancelToken: tokenAxios,
});
},
};
export default prescriptionServices;

View File

@ -0,0 +1,68 @@
import axiosClient from ".";
interface GetUserParams {
Page?: number;
PageSize?: number;
Search?: string;
Role?: number;
IsActive?: boolean;
SortBy?: string;
Desc?: boolean;
}
const userServices = {
getUser: (params?: GetUserParams, tokenAxios?: any) => {
return axiosClient.get(`/User/list`, {
params,
cancelToken: tokenAxios,
});
},
postUser: (
data: {
username: string;
password: string;
role: number;
roleType: number;
fullName: string;
email: string;
phone: string;
},
tokenAxios?: any,
) => {
return axiosClient.post(`/Users/`, data, {
cancelToken: tokenAxios,
});
},
putUser: (
id: string,
data: {
role: number;
roleType: number;
fullName: string;
email: string;
phone: string;
},
tokenAxios?: any,
) => {
return axiosClient.put(`/Users/${id}`, data, {
cancelToken: tokenAxios,
});
},
deleteUser: (id: string, tokenAxios?: any) => {
return axiosClient.delete(`/Users/${id}`, {
cancelToken: tokenAxios,
});
},
detailUser: (id: string, tokenAxios?: any) => {
return axiosClient.get(`/Users/${id}`, {
cancelToken: tokenAxios,
});
},
meUser: (tokenAxios?: any) => {
return axiosClient.get(`/Users/me`, {
cancelToken: tokenAxios,
});
},
};
export default userServices;